feat(auth): support API tokens and bearer token authentication
- Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
This commit is contained in:
@@ -67,6 +67,61 @@ func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenUploadActsAsUser(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("token upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
if box.OwnerID != user.ID {
|
||||
t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID)
|
||||
}
|
||||
|
||||
// An invalid bearer token must not authenticate as the user.
|
||||
badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x")
|
||||
badRequest.Header.Set("Accept", "application/json")
|
||||
badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret")
|
||||
badResponse := httptest.NewRecorder()
|
||||
app.Upload(badResponse, badRequest)
|
||||
if badResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("anonymous fallback upload status = %d, body = %s", badResponse.Code, badResponse.Body.String())
|
||||
}
|
||||
var badPayload services.UploadResult
|
||||
if err := json.Unmarshal(badResponse.Body.Bytes(), &badPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
badBox, err := app.uploadService.GetBox(badPayload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
if badBox.OwnerID != "" {
|
||||
t.Fatalf("invalid token OwnerID = %q, want empty", badBox.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -56,6 +56,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
||||
mux.HandleFunc("POST /account/tokens", a.CreateUserToken)
|
||||
mux.HandleFunc("POST /account/tokens/{tokenID}/delete", a.DeleteUserToken)
|
||||
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -122,16 +123,92 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
type apiTokenView struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
LastUsedAt string
|
||||
}
|
||||
|
||||
type accountData struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Tokens []apiTokenView
|
||||
NewToken string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
||||
}
|
||||
|
||||
// CreateUserToken mints a new personal access token and renders the account
|
||||
// page with the one-time plaintext shown. The secret is never recoverable after
|
||||
// this response.
|
||||
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
|
||||
return
|
||||
}
|
||||
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
||||
if err != nil {
|
||||
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
||||
return
|
||||
}
|
||||
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
||||
}
|
||||
|
||||
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
||||
}
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
|
||||
tokens, err := a.authService.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
views := make([]apiTokenView, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
lastUsed := "Never"
|
||||
if token.LastUsedAt != nil {
|
||||
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
|
||||
}
|
||||
views = append(views, apiTokenView{
|
||||
ID: token.ID,
|
||||
Name: token.Name,
|
||||
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
|
||||
LastUsedAt: lastUsed,
|
||||
})
|
||||
}
|
||||
data.ID = user.ID
|
||||
data.Email = user.Email
|
||||
data.Role = user.Role
|
||||
data.Tokens = views
|
||||
|
||||
a.renderPage(w, r, status, "account.html", web.PageData{
|
||||
Title: "Account settings",
|
||||
Description: "Manage your Warpbox account.",
|
||||
CurrentUser: a.authService.PublicUser(user),
|
||||
Data: user,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,6 +251,17 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa
|
||||
}
|
||||
|
||||
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
||||
// Personal access tokens via Authorization: Bearer act as their owning user.
|
||||
// A bearer header is never set by browsers cross-site, so this path is not
|
||||
// subject to CSRF and intentionally bypasses the session cookie.
|
||||
if header := r.Header.Get("Authorization"); header != "" {
|
||||
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
|
||||
if user, err := a.authService.UserForAPIToken(raw); err == nil {
|
||||
return user, true
|
||||
}
|
||||
return services.User{}, false
|
||||
}
|
||||
}
|
||||
cookie, err := r.Cookie(userSessionCookieName)
|
||||
if err != nil {
|
||||
return services.User{}, false
|
||||
|
||||
@@ -25,6 +25,15 @@ var (
|
||||
sessionsBucket = []byte("sessions")
|
||||
invitesBucket = []byte("invites")
|
||||
collectionsBucket = []byte("collections")
|
||||
apiTokensBucket = []byte("api_tokens")
|
||||
)
|
||||
|
||||
// apiTokenPrefix marks raw API tokens so clients and logs can recognise them.
|
||||
const apiTokenPrefix = "wbx_"
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("api token is invalid")
|
||||
ErrTokenNotFound = errors.New("api token not found")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -111,6 +120,23 @@ type Collection struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// APIToken is a long-lived personal access token. Only the SHA-256 hash of the
|
||||
// secret is stored; the plaintext is shown to the user exactly once at creation.
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenResult carries the one-time plaintext alongside the stored token.
|
||||
type APITokenResult struct {
|
||||
Token APIToken
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
type InviteResult struct {
|
||||
Invite Invite
|
||||
URL string
|
||||
@@ -120,7 +146,7 @@ type InviteResult struct {
|
||||
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
||||
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} {
|
||||
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -225,6 +251,131 @@ func (s *AuthService) Logout(raw string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a new personal access token for the user. The returned
|
||||
// plaintext is the only time the secret is available; only its hash is stored.
|
||||
func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) {
|
||||
if userID == "" {
|
||||
return APITokenResult{}, fmt.Errorf("user is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Untitled token"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
|
||||
secret := randomID(32)
|
||||
token := APIToken{
|
||||
ID: randomID(12),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
TokenHash: apiTokenHash(secret),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.saveAPIToken(token); err != nil {
|
||||
return APITokenResult{}, err
|
||||
}
|
||||
plaintext := apiTokenPrefix + token.ID + "." + secret
|
||||
return APITokenResult{Token: token, Plaintext: plaintext}, nil
|
||||
}
|
||||
|
||||
// ListAPITokens returns the user's tokens, newest first.
|
||||
func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) {
|
||||
tokens := make([]APIToken, 0)
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error {
|
||||
var token APIToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
if token.UserID == userID {
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(tokens, func(i, j int) bool {
|
||||
return tokens[i].CreatedAt.After(tokens[j].CreatedAt)
|
||||
})
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// DeleteAPIToken removes a token, but only if it belongs to the given user.
|
||||
func (s *AuthService) DeleteAPIToken(userID, tokenID string) error {
|
||||
if userID == "" || tokenID == "" {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(apiTokensBucket)
|
||||
data := bucket.Get([]byte(tokenID))
|
||||
if data == nil {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
var token APIToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
if token.UserID != userID {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
return bucket.Delete([]byte(tokenID))
|
||||
})
|
||||
}
|
||||
|
||||
// UserForAPIToken resolves a raw bearer token to its owning user. It records
|
||||
// last-used time on a best-effort basis. The user must exist and be enabled.
|
||||
func (s *AuthService) UserForAPIToken(raw string) (User, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, apiTokenPrefix)
|
||||
tokenID, secret, ok := strings.Cut(raw, ".")
|
||||
if !ok || tokenID == "" || secret == "" {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
var token APIToken
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID))
|
||||
if data == nil {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
return json.Unmarshal(data, &token)
|
||||
})
|
||||
if err != nil {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
user, err := s.UserByID(token.UserID)
|
||||
if err != nil {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
if user.Status != UserStatusActive {
|
||||
return User{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token.LastUsedAt = &now
|
||||
_ = s.saveAPIToken(token)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) saveAPIToken(token APIToken) error {
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
data, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
||||
email, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
@@ -673,6 +824,11 @@ func tokenHash(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func apiTokenHash(secret string) string {
|
||||
sum := sha256.Sum256([]byte("warpbox-api-token:" + secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashPassword(password string) string {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -103,6 +104,107 @@ func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
auth := newTestAuthService(t)
|
||||
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
|
||||
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
|
||||
}
|
||||
// The secret must never be stored in plaintext — only its hash.
|
||||
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
|
||||
t.Fatalf("stored token hash leaks the plaintext secret")
|
||||
}
|
||||
|
||||
resolved, err := auth.UserForAPIToken(result.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("UserForAPIToken returned error: %v", err)
|
||||
}
|
||||
if resolved.ID != user.ID {
|
||||
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
|
||||
}
|
||||
|
||||
tokens, err := auth.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("token count = %d, want 1", len(tokens))
|
||||
}
|
||||
if tokens[0].Name != "CLI laptop" {
|
||||
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
|
||||
}
|
||||
if tokens[0].LastUsedAt == nil {
|
||||
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
|
||||
}
|
||||
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
|
||||
t.Fatalf("UserForAPIToken accepted a tampered token")
|
||||
}
|
||||
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
|
||||
t.Fatalf("UserForAPIToken accepted an unknown token")
|
||||
}
|
||||
|
||||
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
|
||||
t.Fatalf("DeleteAPIToken returned error: %v", err)
|
||||
}
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||
t.Fatalf("deleted token still resolved")
|
||||
}
|
||||
remaining, err := auth.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("token count after delete = %d, want 0", len(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
|
||||
auth := newTestAuthService(t)
|
||||
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := auth.CreateAPIToken(owner.ID, "owner token")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
tokens, err := auth.ListAPITokens(owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
|
||||
// Another user cannot delete tokens they do not own.
|
||||
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
|
||||
t.Fatalf("DeleteAPIToken allowed deletion across users")
|
||||
}
|
||||
|
||||
// A disabled owner cannot authenticate with their token.
|
||||
if err := auth.DisableUser(owner.ID, true); err != nil {
|
||||
t.Fatalf("DisableUser returned error: %v", err)
|
||||
}
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||
t.Fatalf("disabled user token still resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAuthService(t *testing.T) *AuthService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -431,6 +432,7 @@ func GigabytesToBytes(value float64) int64 {
|
||||
|
||||
func FormatMegabytesFromBytes(value int64) string {
|
||||
mb := float64(value) / 1024 / 1024
|
||||
mb = math.Round(mb*100) / 100
|
||||
return FormatMegabytesLabel(mb)
|
||||
}
|
||||
|
||||
|
||||
@@ -1818,3 +1818,63 @@ pre code {
|
||||
.storage-new-card .storage-card-body {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* ── Access tokens ───────────────────────────────────────────────────────── */
|
||||
|
||||
.token-create-form {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-create-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(134, 239, 172, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(134, 239, 172, 0.08);
|
||||
}
|
||||
|
||||
.token-reveal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.token-reveal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-reveal-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy {
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Access token: copy one-time secret */
|
||||
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
||||
if (tokenCopyBtn) {
|
||||
tokenCopyBtn.addEventListener("click", () => {
|
||||
const valueEl = document.querySelector("[data-token-value]");
|
||||
if (!valueEl) return;
|
||||
copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,61 @@
|
||||
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-panel">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Access tokens</h2>
|
||||
<p>Personal tokens act as your account for the API and CLI. They never expire until you delete them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
|
||||
{{if .Data.NewToken}}
|
||||
<div class="token-reveal">
|
||||
<p class="token-reveal-title">Copy your new token now — it won't be shown again.</p>
|
||||
<div class="token-reveal-row">
|
||||
<code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code>
|
||||
<button class="button button-outline button-sm" type="button" data-token-copy>Copy</button>
|
||||
</div>
|
||||
<p class="muted-copy">Use it as a bearer token: <code>Authorization: Bearer {{.Data.NewToken}}</code></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="token-create-form" action="/account/tokens" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>Token name</span><input name="name" placeholder="e.g. CLI on laptop" maxlength="80" required></label>
|
||||
<button class="button button-primary button-sm" type="submit">Generate token</button>
|
||||
</form>
|
||||
|
||||
{{if .Data.Tokens}}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Tokens}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.LastUsedAt}}</td>
|
||||
<td class="table-actions">
|
||||
<form action="/account/tokens/{{.ID}}/delete" method="post" onsubmit="return confirm('Delete this token? Any client using it will stop working.');">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted-copy">No tokens yet. Generate one above to use the API or CLI.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user