accounts and email
This commit is contained in:
@@ -13,9 +13,9 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/email"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/email"
|
||||
"github.com/tim/cairnquire/apps/server/internal/httpserver"
|
||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||
@@ -71,6 +71,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
var emailSender collaboration.EmailSender
|
||||
if cfg.Email.SMTPHost != "" {
|
||||
emailSender = email.NewSMTPSender(cfg.Email, logger)
|
||||
authService.SetEmailSender(emailSender)
|
||||
} else {
|
||||
emailSender = email.NewNoOpSender(logger)
|
||||
}
|
||||
|
||||
@@ -49,16 +49,18 @@ func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var user User
|
||||
var created, lastSeen, passwordHash, role string
|
||||
var disabled int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &created, &lastSeen)
|
||||
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &disabled, &created, &lastSeen)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.PasswordHash = passwordHash
|
||||
user.Role = Role(role)
|
||||
user.Disabled = disabled == 1
|
||||
user.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("parse user created_at: %w", err)
|
||||
@@ -94,7 +96,7 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) {
|
||||
|
||||
func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count); err != nil {
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
@@ -189,7 +191,7 @@ func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) err
|
||||
|
||||
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
@@ -202,10 +204,12 @@ func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
|
||||
for rows.Next() {
|
||||
var user User
|
||||
var created, lastSeen, role string
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil {
|
||||
var disabled int
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &disabled, &created, &lastSeen); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
user.Role = Role(role)
|
||||
user.Disabled = disabled == 1
|
||||
user.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if lastSeen != "" {
|
||||
user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
@@ -245,6 +249,41 @@ func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Rol
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessUpdate) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin user access update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, update := range updates {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET role = ?, disabled = ?
|
||||
WHERE id = ?
|
||||
`, string(update.Role), boolInt(update.Disabled), update.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user access: %w", err)
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
}
|
||||
|
||||
var admins int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&admins); err != nil {
|
||||
return fmt.Errorf("count enabled admins: %w", err)
|
||||
}
|
||||
if admins == 0 {
|
||||
return fmt.Errorf("cannot disable or demote the last admin account")
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit user access update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
@@ -411,6 +450,87 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin password reset token: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = ?
|
||||
WHERE user_id = ? AND used_at IS NULL
|
||||
`, now, token.UserID); err != nil {
|
||||
return fmt.Errorf("expire prior password reset tokens: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO password_reset_tokens (id, user_id, token_hash, created_at, expires_at, initiated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, token.ID, token.UserID, token.TokenHash, token.CreatedAt.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339), nullString(token.InitiatedBy)); err != nil {
|
||||
return fmt.Errorf("create password reset token: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit password reset token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExpirePasswordResetToken(ctx context.Context, tokenHash string) {
|
||||
_, _ = r.db.ExecContext(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = ?
|
||||
WHERE token_hash = ? AND used_at IS NULL
|
||||
`, time.Now().UTC().Format(time.RFC3339), tokenHash)
|
||||
}
|
||||
|
||||
func (r *Repository) CompletePasswordReset(ctx context.Context, tokenHash, passwordHash string) (string, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin password reset: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var id, userID, expires, used string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, expires_at, COALESCE(used_at, '')
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = ?
|
||||
`, tokenHash).Scan(&id, &userID, &expires, &used); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if used != "" {
|
||||
return "", fmt.Errorf("password reset link has already been used")
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, expires)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse password reset expiry: %w", err)
|
||||
}
|
||||
if time.Now().UTC().After(expiresAt) {
|
||||
return "", fmt.Errorf("password reset link has expired")
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE password_reset_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL`, now, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("consume password reset token: %w", err)
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return "", fmt.Errorf("password reset link has already been used")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID); err != nil {
|
||||
return "", fmt.Errorf("update reset password: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, now, userID); err != nil {
|
||||
return "", fmt.Errorf("revoke password reset sessions: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", fmt.Errorf("commit password reset: %w", err)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at)
|
||||
|
||||
@@ -18,13 +18,19 @@ const (
|
||||
sessionTTL = 24 * time.Hour
|
||||
challengeTTL = 5 * time.Minute
|
||||
deviceCodeTTL = 15 * time.Minute
|
||||
passwordResetTTL = time.Hour
|
||||
devicePollSeconds = 5
|
||||
)
|
||||
|
||||
type EmailSender interface {
|
||||
Send(ctx context.Context, to []string, subject, body string) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
webauthn *webauthn.WebAuthn
|
||||
publicOrigin string
|
||||
email EmailSender
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
@@ -55,6 +61,10 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetEmailSender(sender EmailSender) {
|
||||
s.email = sender
|
||||
}
|
||||
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
||||
return s.repo.GetInstanceSettings(ctx)
|
||||
}
|
||||
@@ -119,7 +129,7 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
ok, err := verifyPassword(password, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
principal, token, err := s.createSession(ctx, user, ip, userAgent)
|
||||
@@ -185,6 +195,9 @@ func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, str
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if user.Disabled {
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
assertion, session, err := s.webauthn.BeginLogin(user)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -205,6 +218,9 @@ func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
credential, err := s.webauthn.FinishLogin(user, session, r)
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
@@ -224,6 +240,9 @@ func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Princ
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
|
||||
}
|
||||
|
||||
@@ -324,37 +343,129 @@ func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principa
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return User{}, fmt.Errorf("admin role required")
|
||||
}
|
||||
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
|
||||
switch normalizedRole {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return User{}, fmt.Errorf("invalid role")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if user.Role == RoleAdmin && normalizedRole != RoleAdmin {
|
||||
admins, err := s.repo.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if admins <= 1 {
|
||||
return User{}, fmt.Errorf("cannot demote the last admin account")
|
||||
}
|
||||
}
|
||||
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
|
||||
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
|
||||
ID: userID,
|
||||
Role: Role(role),
|
||||
Disabled: user.Disabled,
|
||||
}})
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole})
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
return users[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return nil, fmt.Errorf("admin role required")
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil, fmt.Errorf("at least one user update is required")
|
||||
}
|
||||
normalized := make([]UserAccessUpdate, 0, len(updates))
|
||||
seen := make(map[string]struct{}, len(updates))
|
||||
for _, update := range updates {
|
||||
update.ID = strings.TrimSpace(update.ID)
|
||||
if update.ID == "" {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
if _, ok := seen[update.ID]; ok {
|
||||
return nil, fmt.Errorf("duplicate user update")
|
||||
}
|
||||
seen[update.ID] = struct{}{}
|
||||
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
|
||||
switch update.Role {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid role")
|
||||
}
|
||||
normalized = append(normalized, update)
|
||||
}
|
||||
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]User, 0, len(normalized))
|
||||
for _, update := range normalized {
|
||||
user, err := s.repo.GetUserByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
|
||||
"role": user.Role,
|
||||
"disabled": user.Disabled,
|
||||
})
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return fmt.Errorf("admin role required")
|
||||
}
|
||||
if s.email == nil {
|
||||
return fmt.Errorf("email sender is not configured")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, err := randomToken(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := randomToken(18)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
|
||||
ID: "reset:" + id,
|
||||
UserID: user.ID,
|
||||
TokenHash: hashSecret(secret),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(passwordResetTTL),
|
||||
InitiatedBy: actor.UserID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
|
||||
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
|
||||
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
|
||||
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
|
||||
return err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||
if len(newPassword) < 12 {
|
||||
return fmt.Errorf("password must be at least 12 characters")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return fmt.Errorf("password reset token is required")
|
||||
}
|
||||
passwordHash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
|
||||
@@ -400,6 +511,9 @@ func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Princi
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
expiresAt := time.Time{}
|
||||
if key.ExpiresAt != nil {
|
||||
expiresAt = *key.ExpiresAt
|
||||
@@ -491,6 +605,9 @@ func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (Create
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
|
||||
if user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("account disabled")
|
||||
}
|
||||
token, err := randomToken(32)
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +12,20 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
)
|
||||
|
||||
type testEmailSender struct {
|
||||
to []string
|
||||
subject string
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||
s.to = append([]string(nil), to...)
|
||||
s.subject = subject
|
||||
s.body = body
|
||||
return s.err
|
||||
}
|
||||
|
||||
func setupAuthTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
@@ -158,11 +174,131 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||
t.Fatal("expected demoting last admin to fail")
|
||||
}
|
||||
if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil {
|
||||
t.Fatal("expected disabling last admin to fail")
|
||||
}
|
||||
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
|
||||
t.Fatal("expected deleting last admin to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginPassword() before disable error = %v", err)
|
||||
}
|
||||
apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||
}
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil {
|
||||
t.Fatalf("UpdateUserAccess() error = %v", err)
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||
t.Fatal("disabled user password login succeeded")
|
||||
}
|
||||
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||
t.Fatal("disabled user session remained valid")
|
||||
}
|
||||
if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil {
|
||||
t.Fatal("disabled user api token remained valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginPassword() before reset error = %v", err)
|
||||
}
|
||||
sender := &testEmailSender{}
|
||||
service.SetEmailSender(sender)
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||
t.Fatalf("SendPasswordReset() first error = %v", err)
|
||||
}
|
||||
firstToken := passwordResetTokenFromBody(t, sender.body)
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||
t.Fatalf("SendPasswordReset() second error = %v", err)
|
||||
}
|
||||
secondToken := passwordResetTokenFromBody(t, sender.body)
|
||||
if firstToken == secondToken {
|
||||
t.Fatal("expected each password reset email to contain a fresh token")
|
||||
}
|
||||
if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil {
|
||||
t.Fatal("first password reset link remained valid after requesting a new one")
|
||||
}
|
||||
if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil {
|
||||
t.Fatalf("ResetPassword() error = %v", err)
|
||||
}
|
||||
if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil {
|
||||
t.Fatal("password reset link was reusable")
|
||||
}
|
||||
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||
t.Fatal("password reset did not revoke existing sessions")
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||
t.Fatal("old password still works after reset")
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||
t.Fatalf("new password login error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
sender := &testEmailSender{err: errors.New("smtp unavailable")}
|
||||
service.SetEmailSender(sender)
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil {
|
||||
t.Fatal("expected password reset email delivery failure")
|
||||
}
|
||||
token := passwordResetTokenFromBody(t, sender.body)
|
||||
if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil {
|
||||
t.Fatal("password reset link remained valid after email delivery failed")
|
||||
}
|
||||
}
|
||||
|
||||
func passwordResetTokenFromBody(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(line)
|
||||
if err != nil {
|
||||
t.Fatalf("parse password reset URL: %v", err)
|
||||
}
|
||||
if token := parsed.Query().Get("token"); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -41,12 +41,29 @@ type User struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
Role Role
|
||||
Disabled bool
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
type UserAccessUpdate struct {
|
||||
ID string `json:"id"`
|
||||
Role Role `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
InitiatedBy string
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte {
|
||||
return []byte(u.ID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS password_reset_tokens;
|
||||
ALTER TABLE users DROP COLUMN disabled;
|
||||
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
initiated_by TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(initiated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE documents DROP COLUMN archived_at;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE documents ADD COLUMN archived_at TEXT;
|
||||
@@ -14,13 +14,14 @@ type Repository struct {
|
||||
}
|
||||
|
||||
type DocumentRecord struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||
}
|
||||
|
||||
type UserRecord struct {
|
||||
@@ -101,18 +102,32 @@ func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentI
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||
return r.getDocumentByPath(ctx, path, false)
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||
return r.getDocumentByPath(ctx, path, true)
|
||||
}
|
||||
|
||||
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
|
||||
var (
|
||||
record DocumentRecord
|
||||
tagsJSON string
|
||||
created string
|
||||
updated string
|
||||
archived sql.NullString
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at
|
||||
query := `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
|
||||
FROM documents
|
||||
WHERE path = ?
|
||||
`, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated)
|
||||
`
|
||||
if !includeArchived {
|
||||
query += ` AND archived_at IS NULL`
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +135,13 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
|
||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, archived.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse document archived_at: %w", err)
|
||||
}
|
||||
record.ArchivedAt = &t
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||
}
|
||||
@@ -129,8 +151,9 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
|
||||
|
||||
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
|
||||
FROM documents
|
||||
WHERE archived_at IS NULL
|
||||
ORDER BY path ASC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -138,6 +161,25 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDocumentRows(rows)
|
||||
}
|
||||
|
||||
func (r *Repository) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
|
||||
FROM documents
|
||||
WHERE archived_at IS NOT NULL
|
||||
ORDER BY archived_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list archived documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDocumentRows(rows)
|
||||
}
|
||||
|
||||
func scanDocumentRows(rows *sql.Rows) ([]DocumentRecord, error) {
|
||||
var records []DocumentRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
@@ -145,13 +187,21 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
||||
tagsJSON string
|
||||
created string
|
||||
updated string
|
||||
archived sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil {
|
||||
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived); err != nil {
|
||||
return nil, fmt.Errorf("scan document: %w", err)
|
||||
}
|
||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, archived.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse document archived_at: %w", err)
|
||||
}
|
||||
record.ArchivedAt = &t
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||
}
|
||||
@@ -161,6 +211,41 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ArchiveDocument(ctx context.Context, path string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE documents SET archived_at = ? WHERE path = ? AND archived_at IS NULL
|
||||
`, now, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive document %s: %w", path, err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive document rows affected %s: %w", path, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) RestoreDocument(ctx context.Context, path string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE documents SET archived_at = NULL WHERE path = ? AND archived_at IS NOT NULL
|
||||
`, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore document %s: %w", path, err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore document rows affected %s: %w", path, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||
@@ -310,7 +395,7 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
|
||||
SELECT d.path, d.title
|
||||
FROM document_search ds
|
||||
JOIN documents d ON d.rowid = ds.rowid
|
||||
WHERE document_search MATCH ?
|
||||
WHERE document_search MATCH ? AND d.archived_at IS NULL
|
||||
ORDER BY rank
|
||||
LIMIT 50
|
||||
`, query)
|
||||
|
||||
@@ -123,6 +123,53 @@ func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
return s.repo.ListDocuments(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||
return s.repo.ListArchivedDocuments(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record, _, err := s.resolveRecord(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.onChange != nil {
|
||||
s.onChange(DocumentChange{
|
||||
Path: record.Path,
|
||||
Title: record.Title,
|
||||
Hash: record.CurrentHash,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RestoreDocument(ctx context.Context, path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.repo.RestoreDocument(ctx, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
|
||||
if _, err := s.SyncSourceDir(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -317,7 +364,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
|
||||
return nil, fmt.Errorf("store content file %s: %w", path, err)
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetDocumentByPath(ctx, relative)
|
||||
existing, err := s.repo.GetDocumentByPathIncludingArchived(ctx, relative)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -347,6 +394,10 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
|
||||
s.logger.Warn("index search content", "path", relative, "error", err)
|
||||
}
|
||||
|
||||
if existing != nil && existing.ArchivedAt != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
||||
return &DocumentChange{
|
||||
Path: relative,
|
||||
|
||||
@@ -182,6 +182,39 @@ func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
|
||||
service, _ := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("initial SyncSourceDir() error = %v", err)
|
||||
}
|
||||
|
||||
var changed []DocumentChange
|
||||
service.OnChange(func(change DocumentChange) {
|
||||
changed = append(changed, change)
|
||||
})
|
||||
|
||||
if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
|
||||
t.Fatalf("ArchiveDocument() error = %v", err)
|
||||
}
|
||||
if len(changed) != 1 {
|
||||
t.Fatalf("changes after archive = %d, want 1", len(changed))
|
||||
}
|
||||
|
||||
changed = nil
|
||||
changes, err := service.SyncSourceDir(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
t.Fatalf("sync changes = %#v, want none for archived document", changes)
|
||||
}
|
||||
if len(changed) != 0 {
|
||||
t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -40,10 +40,10 @@ func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string
|
||||
return fmt.Errorf("email host not configured")
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
||||
msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to[0], subject, body))
|
||||
|
||||
msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n", s.cfg.From, to[0], subject, body))
|
||||
|
||||
s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr)
|
||||
|
||||
|
||||
// Use SMTP without auth for local testing (Mailpit/MailHog)
|
||||
var auth smtp.Auth
|
||||
err := smtp.SendMail(addr, auth, s.cfg.From, to, msg)
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
@@ -23,6 +25,17 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/sync"
|
||||
)
|
||||
|
||||
type testEmailSender struct {
|
||||
body string
|
||||
to []string
|
||||
}
|
||||
|
||||
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||
s.to = append([]string(nil), to...)
|
||||
s.body = body
|
||||
return nil
|
||||
}
|
||||
|
||||
type apiTestServer struct {
|
||||
handler http.Handler
|
||||
auth *auth.Service
|
||||
@@ -36,6 +49,10 @@ func newAPITestServer(t *testing.T) *apiTestServer {
|
||||
}
|
||||
|
||||
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
|
||||
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
|
||||
}
|
||||
|
||||
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -88,7 +105,8 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
|
||||
SourceDir: sourceDir,
|
||||
StoreDir: storeDir,
|
||||
},
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
DevMode: devMode,
|
||||
},
|
||||
Logger: logger,
|
||||
Documents: docService,
|
||||
@@ -112,6 +130,93 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
|
||||
server := newAPITestServerWithSetupAndDevMode(t, true, true)
|
||||
|
||||
devMe := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
|
||||
}
|
||||
|
||||
logout := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
|
||||
if logout.Code != http.StatusOK {
|
||||
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
|
||||
}
|
||||
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
|
||||
if devLogoutCookie.Value != "1" {
|
||||
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
|
||||
}
|
||||
|
||||
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
loggedOutMeRequest.AddCookie(devLogoutCookie)
|
||||
loggedOutMe := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
|
||||
if loggedOutMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
|
||||
}
|
||||
|
||||
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
loginPageRequest.AddCookie(devLogoutCookie)
|
||||
loginPage := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(loginPage, loginPageRequest)
|
||||
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
|
||||
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
|
||||
}
|
||||
|
||||
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
|
||||
devLoginRequest.AddCookie(devLogoutCookie)
|
||||
devLogin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devLogin, devLoginRequest)
|
||||
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
|
||||
}
|
||||
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
|
||||
if clearedByDevLogin.MaxAge != -1 {
|
||||
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
|
||||
}
|
||||
|
||||
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
devTokenRequest.AddCookie(devLogoutCookie)
|
||||
devTokenRequest.Header.Set("Authorization", "Bearer dev")
|
||||
devToken := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devToken, devTokenRequest)
|
||||
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
|
||||
}
|
||||
|
||||
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
})
|
||||
loginRequest.AddCookie(devLogoutCookie)
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, loginRequest)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
|
||||
}
|
||||
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
if sessionCookie.Value == "" {
|
||||
t.Fatalf("session cookie = %#v, want value", sessionCookie)
|
||||
}
|
||||
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
|
||||
if clearedDevLogoutCookie.MaxAge != -1 {
|
||||
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
|
||||
server := newAPITestServerWithSetup(t, false)
|
||||
|
||||
@@ -160,6 +265,123 @@ func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
accountRequest.AddCookie(session)
|
||||
account := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(account, accountRequest)
|
||||
if account.Code != http.StatusOK {
|
||||
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
|
||||
}
|
||||
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
|
||||
t.Fatal("account page should not render the token creation form")
|
||||
}
|
||||
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
|
||||
t.Fatal("account page should link to the token creation page")
|
||||
}
|
||||
|
||||
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
|
||||
createRequest.AddCookie(session)
|
||||
create := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(create, createRequest)
|
||||
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
|
||||
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
sender := &testEmailSender{}
|
||||
server.auth.SetEmailSender(sender)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
|
||||
"users": []map[string]any{{
|
||||
"id": server.userID,
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}},
|
||||
})
|
||||
updateRequest.AddCookie(session)
|
||||
update := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(update, updateRequest)
|
||||
if update.Code != http.StatusOK {
|
||||
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
|
||||
}
|
||||
|
||||
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
|
||||
sendRequest.AddCookie(session)
|
||||
send := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(send, sendRequest)
|
||||
if send.Code != http.StatusOK {
|
||||
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
|
||||
}
|
||||
token := passwordResetTokenFromEmail(t, sender.body)
|
||||
|
||||
page := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
|
||||
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
|
||||
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
|
||||
}
|
||||
|
||||
reset := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
|
||||
"token": token,
|
||||
"newPassword": "new very secure password",
|
||||
}))
|
||||
if reset.Code != http.StatusOK {
|
||||
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
|
||||
}
|
||||
|
||||
newLogin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "new very secure password",
|
||||
}))
|
||||
if newLogin.Code != http.StatusOK {
|
||||
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func passwordResetTokenFromEmail(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(line)
|
||||
if err != nil {
|
||||
t.Fatalf("parse password reset URL: %v", err)
|
||||
}
|
||||
if token := parsed.Query().Get("token"); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -415,6 +637,74 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
nestedDir := filepath.Join(server.root, "content", "guide")
|
||||
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||
t.Fatalf("create nested source dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
|
||||
t.Fatalf("write nested source fixture: %v", err)
|
||||
}
|
||||
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("sync nested source fixture: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
if strings.Contains(documents.Body.String(), "guide/setup.md") {
|
||||
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
|
||||
t.Fatalf("write special source fixture: %v", err)
|
||||
}
|
||||
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("sync special source fixture: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
if strings.Contains(documents.Body.String(), "@dani.md") {
|
||||
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ type changePasswordRequest struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type passwordResetRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type deleteAccountRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
@@ -79,14 +84,15 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
|
||||
Title: "Sign in",
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "login_content",
|
||||
Data: struct {
|
||||
Next string
|
||||
SignupsEnabled bool
|
||||
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled},
|
||||
DevMode bool
|
||||
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,13 +106,24 @@ func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
|
||||
Title: "Initial setup",
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "setup_content",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
|
||||
Title: "Reset password",
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "password_reset_content",
|
||||
Data: struct {
|
||||
Token string
|
||||
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
@@ -135,7 +152,7 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
|
||||
Title: "Account",
|
||||
BodyClass: "page-account",
|
||||
BodyTemplate: "account_content",
|
||||
@@ -143,6 +160,20 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
|
||||
Title: "Create access token",
|
||||
BodyClass: "page-account",
|
||||
BodyTemplate: "token_create_content",
|
||||
Data: principal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
@@ -193,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
if s.config.DevMode {
|
||||
setDevLogoutCookie(w, r)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.config.DevMode || s.devUserID == "" {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
|
||||
return
|
||||
}
|
||||
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
clearDevLogoutCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
@@ -213,6 +261,22 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
var req passwordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
@@ -425,7 +489,7 @@ func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
|
||||
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
|
||||
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||
Title: "Approve device",
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "device_verify_content",
|
||||
@@ -464,6 +528,36 @@ func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
var req struct {
|
||||
Users []auth.UserAccessUpdate `json:"users"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
public := make([]map[string]any, 0, len(users))
|
||||
for _, user := range users {
|
||||
public = append(public, publicUser(user))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
|
||||
}
|
||||
|
||||
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.authLimiter.Allow(authRateLimitKey(r)) {
|
||||
return true
|
||||
@@ -473,6 +567,7 @@ func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
clearDevLogoutCookie(w)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: token,
|
||||
@@ -484,6 +579,29 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
|
||||
})
|
||||
}
|
||||
|
||||
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: devLogoutCookieName,
|
||||
Value: "1",
|
||||
Path: "/",
|
||||
MaxAge: int((24 * time.Hour).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: isSecureRequest(r),
|
||||
})
|
||||
}
|
||||
|
||||
func clearDevLogoutCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: devLogoutCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
@@ -501,6 +619,7 @@ func publicUser(user auth.User) map[string]any {
|
||||
"email": user.Email,
|
||||
"displayName": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"disabled": user.Disabled,
|
||||
"createdAt": user.CreatedAt,
|
||||
"lastSeenAt": user.LastSeenAt,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type layoutData struct {
|
||||
BodyClass string
|
||||
BodyTemplate string
|
||||
Data any
|
||||
Principal *auth.Principal
|
||||
}
|
||||
|
||||
type indexData struct {
|
||||
@@ -43,6 +45,7 @@ type documentData struct {
|
||||
Hash string
|
||||
HTML template.HTML
|
||||
Breadcrumbs []breadcrumb
|
||||
CanWrite bool
|
||||
}
|
||||
|
||||
type documentEditData struct {
|
||||
@@ -53,6 +56,7 @@ type documentEditData struct {
|
||||
Hash string
|
||||
Source string
|
||||
Breadcrumbs []breadcrumb
|
||||
CanWrite bool
|
||||
}
|
||||
|
||||
type breadcrumb struct {
|
||||
@@ -98,7 +102,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
||||
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
|
||||
Title: "Cairnquire",
|
||||
BodyClass: "page-index",
|
||||
BodyTemplate: "index_content",
|
||||
@@ -117,7 +121,7 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
|
||||
s.logger.Warn("search failed", "error", err)
|
||||
}
|
||||
|
||||
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
|
||||
Title: "Search: " + query,
|
||||
BodyClass: "page-search",
|
||||
BodyTemplate: "search_content",
|
||||
@@ -223,7 +227,9 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
||||
return
|
||||
}
|
||||
|
||||
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
|
||||
canWrite := s.canWriteDocuments(r)
|
||||
|
||||
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
|
||||
Title: "Edit: " + page.Title,
|
||||
BodyClass: "page-document-edit",
|
||||
BodyTemplate: "document_edit_content",
|
||||
@@ -235,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
||||
Hash: page.Hash,
|
||||
Source: page.Content,
|
||||
Breadcrumbs: buildBreadcrumbs(page.Path),
|
||||
CanWrite: canWrite,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -256,7 +263,9 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
||||
return
|
||||
}
|
||||
|
||||
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
|
||||
canWrite := s.canWriteDocuments(r)
|
||||
|
||||
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
|
||||
Title: page.Title,
|
||||
BodyClass: "page-document",
|
||||
BodyTemplate: "document_content",
|
||||
@@ -268,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
||||
Hash: page.Hash,
|
||||
HTML: template.HTML(page.HTML),
|
||||
Breadcrumbs: buildBreadcrumbs(page.Path),
|
||||
CanWrite: canWrite,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleReadyz(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -286,11 +304,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
contentStatus = err.Error()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
ready := dbStatus == "ok" && contentStatus == "ok"
|
||||
payload := map[string]string{
|
||||
"status": "ok",
|
||||
"database": dbStatus,
|
||||
"contentStore": contentStatus,
|
||||
})
|
||||
}
|
||||
if !ready {
|
||||
payload["status"] = "unavailable"
|
||||
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -515,7 +540,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
|
||||
if data.Principal == nil {
|
||||
if principal, ok := principalFromContext(r.Context()); ok {
|
||||
p := principal
|
||||
data.Principal = &p
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
@@ -524,7 +555,7 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
|
||||
}
|
||||
|
||||
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
s.renderTemplate(w, status, "error.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, status, "error.gohtml", layoutData{
|
||||
Title: http.StatusText(status),
|
||||
BodyClass: "page-error",
|
||||
BodyTemplate: "error_content",
|
||||
@@ -649,8 +680,30 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
|
||||
pagePath := chi.URLParam(r, "*")
|
||||
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
|
||||
pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(pagePath, "/archive"):
|
||||
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
|
||||
case strings.HasSuffix(pagePath, "/restore"):
|
||||
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
|
||||
default:
|
||||
s.handleDocumentSavePath(w, r, pagePath)
|
||||
}
|
||||
}
|
||||
|
||||
func unescapeDocumentRoutePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
return url.PathUnescape(path)
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
if pagePath == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||
return
|
||||
@@ -705,6 +758,50 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func (s *Server) canWriteDocuments(r *http.Request) bool {
|
||||
principal, ok := principalFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return auth.Allows(principal, auth.ScopeDocsWrite)
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
if pagePath == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
if pagePath == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
|
||||
}
|
||||
|
||||
func trimEditSuffix(path string) (string, bool) {
|
||||
if path == "edit" {
|
||||
return "", true
|
||||
|
||||
@@ -414,3 +414,135 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandleHealthzReturnsOK(t *testing.T) {
|
||||
server := setupHealthTestServer(t)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "ok" {
|
||||
t.Fatalf("status = %q, want ok", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
|
||||
server := setupHealthTestServer(t)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "ok" {
|
||||
t.Fatalf("status = %q, want ok", payload["status"])
|
||||
}
|
||||
if payload["database"] != "ok" {
|
||||
t.Fatalf("database = %q, want ok", payload["database"])
|
||||
}
|
||||
if payload["contentStore"] != "ok" {
|
||||
t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
|
||||
server := setupHealthTestServer(t)
|
||||
server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "unavailable" {
|
||||
t.Fatalf("status = %q, want unavailable", payload["status"])
|
||||
}
|
||||
if payload["contentStore"] == "ok" {
|
||||
t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthAliasForReadyz(t *testing.T) {
|
||||
server := setupHealthTestServer(t)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "ok" {
|
||||
t.Fatalf("status = %q, want ok", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
|
||||
for _, path := range []string{"/healthz", "/readyz", "/health"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupHealthTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
storeDir := filepath.Join(root, "files")
|
||||
if err := os.MkdirAll(storeDir, 0o755); err != nil {
|
||||
t.Fatalf("create store dir: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
contentStore, err := store.New(storeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create content store: %v", err)
|
||||
}
|
||||
repository := docs.NewRepository(db)
|
||||
documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||
|
||||
return &Server{
|
||||
config: config.Config{
|
||||
Content: config.ContentConfig{StoreDir: storeDir},
|
||||
},
|
||||
documents: documents,
|
||||
repository: repository,
|
||||
contentStore: contentStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
)
|
||||
|
||||
const devLogoutCookieName = "cairnquire_dev_logout"
|
||||
|
||||
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
timeoutMiddleware := middleware.Timeout(timeout)
|
||||
return func(next http.Handler) http.Handler {
|
||||
@@ -99,6 +101,8 @@ func isSetupAllowedPath(path string) bool {
|
||||
return path == "/setup" ||
|
||||
path == "/api/setup" ||
|
||||
path == "/health" ||
|
||||
path == "/healthz" ||
|
||||
path == "/readyz" ||
|
||||
path == "/sw.js" ||
|
||||
strings.HasPrefix(path, "/static/")
|
||||
}
|
||||
@@ -125,7 +129,7 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
}
|
||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
if s.devUserID == "" {
|
||||
if s.devUserID == "" || hasDevLogoutCookie(r) {
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||
@@ -138,6 +142,11 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func hasDevLogoutCookie(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(devLogoutCookieName)
|
||||
return err == nil && cookie.Value == "1"
|
||||
}
|
||||
|
||||
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
path := r.URL.Path
|
||||
method := r.Method
|
||||
|
||||
@@ -104,15 +104,21 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Get("/", server.handleIndex)
|
||||
router.Get("/setup", server.handleSetupPage)
|
||||
router.Get("/login", server.handleLoginPage)
|
||||
router.Get("/password-reset", server.handlePasswordResetPage)
|
||||
router.Get("/account", server.handleAccountPage)
|
||||
router.Get("/account/tokens/new", server.handleTokenCreatePage)
|
||||
router.Get("/device/verify", server.handleDeviceVerifyPage)
|
||||
router.Get("/health", server.handleHealth)
|
||||
router.Get("/healthz", server.handleHealthz)
|
||||
router.Get("/readyz", server.handleReadyz)
|
||||
router.Get("/sw.js", server.handleServiceWorker)
|
||||
router.Get("/ws", server.handleWebSocket)
|
||||
router.Get("/api/auth/me", server.handleAuthMe)
|
||||
router.Post("/api/setup", server.handleSetup)
|
||||
router.Post("/api/auth/register/password", server.handlePasswordRegister)
|
||||
router.Post("/api/auth/login/password", server.handlePasswordLogin)
|
||||
router.Post("/api/auth/login/dev", server.handleDevLogin)
|
||||
router.Post("/api/auth/password/reset", server.handlePasswordReset)
|
||||
router.Post("/api/auth/logout", server.handleLogout)
|
||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||
@@ -132,6 +138,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Post("/api/admin/users", server.handleAdminCreateUser)
|
||||
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
|
||||
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
|
||||
router.Patch("/api/admin/auth-users", server.handleAdminUpdateAuthUsers)
|
||||
router.Post("/api/admin/auth-users/{id}/password-reset", server.handleAdminSendPasswordReset)
|
||||
router.Get("/api/admin/settings", server.handleAdminSettings)
|
||||
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
|
||||
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
|
||||
@@ -139,7 +147,7 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Get("/docs", server.handleDocsIndex)
|
||||
router.Get("/docs/*", server.handleDocument)
|
||||
router.Get("/api/documents", server.handleDocuments)
|
||||
router.Post("/api/documents/*", server.handleDocumentSave)
|
||||
router.Post("/api/documents/*", server.handleDocumentMutation)
|
||||
router.Get("/api/search", server.handleSearch)
|
||||
router.Post("/api/uploads", server.handleUpload)
|
||||
router.Get("/api/attachments", server.handleAttachmentsList)
|
||||
|
||||
45
apps/server/internal/httpserver/static/archive.js
Normal file
45
apps/server/internal/httpserver/static/archive.js
Normal file
@@ -0,0 +1,45 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function apiDocumentPath(path) {
|
||||
return (path || '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-archive-path]');
|
||||
if (!btn) return;
|
||||
|
||||
var path = btn.getAttribute('data-archive-path');
|
||||
if (!path) return;
|
||||
|
||||
if (!confirm('Archive this document? It will be hidden from the site but can be restored later.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Archiving...';
|
||||
|
||||
fetch('/api/documents/' + apiDocumentPath(path) + '/archive', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
return res.json().then(function (data) {
|
||||
throw new Error(data.error || 'Archive failed');
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert(err.message || 'Failed to archive document');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Archive';
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -34,10 +34,14 @@
|
||||
const loginSection = document.querySelector('[data-login-section]');
|
||||
const registerCancel = document.querySelector('[data-register-cancel]');
|
||||
|
||||
const authHeading = document.querySelector('[data-auth-heading]');
|
||||
|
||||
if (registerToggle && registerPanel && loginSection) {
|
||||
registerToggle.addEventListener('click', () => {
|
||||
loginSection.hidden = true;
|
||||
registerPanel.hidden = false;
|
||||
if (authHeading) authHeading.textContent = 'Create an Account';
|
||||
document.title = 'Create an Account';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +49,8 @@
|
||||
registerCancel.addEventListener('click', () => {
|
||||
registerPanel.hidden = true;
|
||||
loginSection.hidden = false;
|
||||
if (authHeading) authHeading.textContent = 'Log In';
|
||||
document.title = 'Sign in';
|
||||
// Clear any registration form status messages
|
||||
document.querySelector('[data-passkey-register-status]').textContent = '';
|
||||
document.querySelector('[data-register-status]').textContent = '';
|
||||
@@ -152,6 +158,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-dev-login]")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/login/dev", {});
|
||||
window.location.href = authNext();
|
||||
} catch (error) {
|
||||
setStatus("[data-dev-login-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -237,19 +252,37 @@
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
window.location.href = "/login";
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} catch (error) {
|
||||
console.warn("Logout request failed:", error);
|
||||
} finally {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
});
|
||||
|
||||
const loadTokens = async () => {
|
||||
const list = document.querySelector("[data-token-list]");
|
||||
if (!list) return;
|
||||
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
|
||||
if (!data.tokens?.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "token-empty";
|
||||
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
|
||||
list.replaceChildren(empty);
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(
|
||||
...(data.tokens || []).map((token) => {
|
||||
...data.tokens.map((token) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
|
||||
const meta = document.createElement("span");
|
||||
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||
meta.className = "token-meta";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = token.name;
|
||||
const details = document.createElement("small");
|
||||
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||
meta.append(name, details);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Revoke";
|
||||
@@ -297,6 +330,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-password-reset]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
|
||||
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
|
||||
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
|
||||
} catch (error) {
|
||||
setStatus("[data-password-reset-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!confirm("Delete this account permanently?")) return;
|
||||
@@ -324,7 +368,7 @@
|
||||
|
||||
const loadAdminUsers = async () => {
|
||||
const section = document.querySelector("[data-admin-users-section]");
|
||||
const list = document.querySelector("[data-admin-users]");
|
||||
const list = document.querySelector("[data-admin-user-list]");
|
||||
if (!section || !list) return;
|
||||
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
|
||||
if (me?.principal?.role !== "admin") return;
|
||||
@@ -332,8 +376,9 @@
|
||||
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
|
||||
list.replaceChildren(
|
||||
...(data.users || []).map((user) => {
|
||||
const row = document.createElement("form");
|
||||
const row = document.createElement("div");
|
||||
row.className = "user-row";
|
||||
row.dataset.userId = user.id;
|
||||
const identity = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.displayName;
|
||||
@@ -348,24 +393,52 @@
|
||||
option.selected = user.role === role;
|
||||
select.append(option);
|
||||
}
|
||||
const button = document.createElement("button");
|
||||
button.type = "submit";
|
||||
button.textContent = "Save";
|
||||
row.append(identity, select, button);
|
||||
row.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
select.name = "role";
|
||||
select.setAttribute("aria-label", `Role for ${user.displayName}`);
|
||||
const disabledLabel = document.createElement("label");
|
||||
disabledLabel.className = "user-disable-toggle";
|
||||
const disabled = document.createElement("input");
|
||||
disabled.type = "checkbox";
|
||||
disabled.name = "disabled";
|
||||
disabled.checked = Boolean(user.disabled);
|
||||
const disabledText = document.createElement("span");
|
||||
disabledText.textContent = "Disabled";
|
||||
disabledLabel.append(disabled, disabledText);
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.textContent = "Send password reset";
|
||||
reset.addEventListener("click", async () => {
|
||||
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
|
||||
try {
|
||||
await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" });
|
||||
setStatus("[data-admin-users-status]", "Role updated.");
|
||||
await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
|
||||
setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
|
||||
} catch (error) {
|
||||
setStatus("[data-admin-users-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
row.append(identity, select, disabledLabel, reset);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
|
||||
id: row.dataset.userId,
|
||||
role: row.querySelector('select[name="role"]').value,
|
||||
disabled: row.querySelector('input[name="disabled"]').checked,
|
||||
}));
|
||||
try {
|
||||
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
|
||||
setStatus("[data-admin-users-status]", "User access settings saved.");
|
||||
await loadAdminUsers();
|
||||
} catch (error) {
|
||||
setStatus("[data-admin-users-status]", error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
const loadAdminSettings = async () => {
|
||||
const section = document.querySelector("[data-admin-settings-section]");
|
||||
const form = document.querySelector("[data-admin-settings]");
|
||||
|
||||
@@ -4,20 +4,28 @@
|
||||
const bell = document.querySelector('[data-notification-bell]');
|
||||
if (!bell) return;
|
||||
|
||||
const trigger = bell.querySelector('.notification-bell__trigger');
|
||||
const countEl = bell.querySelector('[data-unread-count]');
|
||||
const dropdown = bell.querySelector('[data-notification-dropdown]');
|
||||
let isOpen = false;
|
||||
|
||||
bell.addEventListener('click', () => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
isOpen = !isOpen;
|
||||
dropdown.hidden = !isOpen;
|
||||
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||
if (isOpen) loadNotifications();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!bell.contains(e.target)) {
|
||||
dropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
if (isOpen) {
|
||||
isOpen = false;
|
||||
dropdown.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,8 +59,17 @@
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('li');
|
||||
header.className = 'notification-header';
|
||||
header.textContent = 'Notifications';
|
||||
list.appendChild(header);
|
||||
|
||||
if (!notifications.length) {
|
||||
list.innerHTML = '<li class="notification-empty">No notifications</li>';
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'notification-empty';
|
||||
empty.textContent = 'No notifications';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,143 @@ code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notification bell */
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.notification-bell__trigger svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-bell__count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-bell__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.notification-bell__dropdown[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.notification-item.is-read {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-footer button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.notification-footer button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.site-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -150,13 +287,18 @@ code {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.75rem 0.4rem 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.825rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.site-search input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
@@ -376,6 +518,16 @@ code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-dev-login {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-dev-login .btn-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Register CTA */
|
||||
.auth-register-cta {
|
||||
margin-top: 1.5rem;
|
||||
@@ -456,31 +608,282 @@ code {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: min(100%, 1120px);
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
.account-identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: inline-flex;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.account-header h1 {
|
||||
margin-top: 0.15rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
width: min(100%, 1120px);
|
||||
.account-role {
|
||||
padding: 0.18rem 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-header .account-signout {
|
||||
display: inline-flex;
|
||||
min-height: 2.45rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-color: var(--border);
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-header .account-signout:hover {
|
||||
border-color: var(--muted);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.account-layout {
|
||||
display: grid;
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.account-main,
|
||||
.account-sidebar {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-section {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.account-section__header {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.account-section__header--action {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.account-section__header h2,
|
||||
.account-list-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-section__header h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.account-section__header p,
|
||||
.account-list-heading p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.account-section__kicker {
|
||||
margin: 0 0 0.25rem !important;
|
||||
color: var(--accent) !important;
|
||||
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace !important;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-section__divider {
|
||||
height: 1px;
|
||||
margin: 1.25rem 0 1rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.account-list-heading {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.account-list-heading h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.account-action,
|
||||
.account-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account-action {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.account-action:hover {
|
||||
background: color-mix(in srgb, var(--accent) 85%, black);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.account-action:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.account-page-header,
|
||||
.token-create-layout {
|
||||
width: min(100%, 920px);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.account-page-header {
|
||||
margin-bottom: 1rem;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-page-header h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.account-page-header p:last-child {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-back {
|
||||
width: fit-content;
|
||||
margin-bottom: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.token-create-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(15rem, 18rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.token-create-note h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.token-create-note p:last-child {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.account-shell .auth-form button[type="submit"] {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.account-form-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.account-form-footer .form-status {
|
||||
flex: 1 1 14rem;
|
||||
}
|
||||
|
||||
.account-section--danger {
|
||||
border-color: color-mix(in srgb, #b42318 40%, var(--border));
|
||||
background: color-mix(in srgb, #b42318 4%, var(--panel));
|
||||
}
|
||||
|
||||
.account-section--danger .account-section__kicker {
|
||||
color: #b42318 !important;
|
||||
}
|
||||
|
||||
.account-shell .account-danger-button[type="submit"] {
|
||||
border-color: color-mix(in srgb, #b42318 70%, var(--border));
|
||||
background: #b42318;
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, #b42318 22%, transparent);
|
||||
}
|
||||
|
||||
.account-shell .account-danger-button[type="submit"]:hover {
|
||||
background: #8f1c13;
|
||||
}
|
||||
|
||||
.scope-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 3%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.scope-grid legend {
|
||||
@@ -494,11 +897,47 @@ code {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.scope-grid input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-form .auth-choice.account-toggle {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.auth-form .account-toggle input {
|
||||
width: auto;
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.account-toggle span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.account-toggle strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.account-toggle small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.token-output {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -521,14 +960,40 @@ code {
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
|
||||
.token-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.user-row span {
|
||||
.token-meta {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.token-meta small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.token-row.is-revoked {
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.token-empty {
|
||||
display: block !important;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto auto;
|
||||
}
|
||||
|
||||
.user-row > span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
@@ -537,6 +1002,84 @@ code {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.user-management {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-management .account-form-footer {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.user-management button[type="submit"] {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-disable-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-disable-toggle input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.account-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.token-create-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-sidebar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.account-shell {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.account-header {
|
||||
align-items: flex-start;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
|
||||
.account-header .account-signout {
|
||||
min-height: 2.2rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.account-signout svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace layout - adaptive grid */
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
@@ -1166,6 +1709,12 @@ code {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.document-edit-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1178,6 +1727,25 @@ code {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.document-archive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.4rem;
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, #b42318 40%, var(--border));
|
||||
background: transparent;
|
||||
color: #b42318;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.document-archive-btn:hover {
|
||||
background: color-mix(in srgb, #b42318 8%, transparent);
|
||||
}
|
||||
|
||||
.document-shell--editor {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
@@ -1595,6 +2163,10 @@ code {
|
||||
background: oklch(0.16 0.014 55 / 0.92);
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.miller-column h2 {
|
||||
background: oklch(0.20 0.015 55 / 0.72);
|
||||
}
|
||||
@@ -1826,4 +2398,3 @@ code {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,82 +3,130 @@
|
||||
{{ define "account_content" }}
|
||||
<section class="account-shell">
|
||||
<header class="account-header">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>{{ .DisplayName }}</h1>
|
||||
<p>{{ .Email }} · {{ .Role }}</p>
|
||||
<div class="account-identity">
|
||||
<span class="account-avatar" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="eyebrow">Account center</p>
|
||||
<h1>{{ .DisplayName }}</h1>
|
||||
<div class="account-meta">
|
||||
<span>{{ .Email }}</span>
|
||||
<span class="account-role">{{ .Role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" data-auth-logout>Sign out</button>
|
||||
<button class="account-signout" type="button" data-auth-logout>
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10 17l5-5-5-5" />
|
||||
<path d="M15 12H3" />
|
||||
<path d="M21 19V5a2 2 0 0 0-2-2h-6" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="account-section">
|
||||
<h2>API Tokens</h2>
|
||||
<form class="auth-form" data-token-create>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" placeholder="CLI on laptop" required />
|
||||
</label>
|
||||
<fieldset class="scope-grid">
|
||||
<legend>Scopes</legend>
|
||||
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||
</fieldset>
|
||||
<button type="submit">Create token</button>
|
||||
<p class="form-status" data-token-status></p>
|
||||
<pre class="token-output" data-token-output hidden></pre>
|
||||
</form>
|
||||
<ul class="token-list" data-token-list></ul>
|
||||
</section>
|
||||
<div class="account-layout">
|
||||
<div class="account-main">
|
||||
<section class="account-section account-section--tokens">
|
||||
<header class="account-section__header account-section__header--action">
|
||||
<div>
|
||||
<p class="account-section__kicker">Integrations</p>
|
||||
<h2>Access tokens</h2>
|
||||
<p>Manage access for the macOS app, CLI, or another trusted client.</p>
|
||||
</div>
|
||||
<a class="account-action" href="/account/tokens/new">Create token</a>
|
||||
</header>
|
||||
<div class="account-list-heading">
|
||||
<h3>Issued tokens</h3>
|
||||
<p>Tokens can be revoked immediately. New secrets are shown only once.</p>
|
||||
</div>
|
||||
<ul class="token-list" data-token-list></ul>
|
||||
</section>
|
||||
|
||||
<section class="account-section">
|
||||
<h2>Password</h2>
|
||||
<form class="auth-form" data-password-change>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Change password</button>
|
||||
<p class="form-status" data-password-status></p>
|
||||
</form>
|
||||
</section>
|
||||
<section class="account-section account-section--users" data-admin-users-section hidden>
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Administration</p>
|
||||
<h2>People and roles</h2>
|
||||
</div>
|
||||
<p>Control access levels for everyone with an account on this server.</p>
|
||||
</header>
|
||||
<form class="user-management" data-admin-users>
|
||||
<div class="user-list" data-admin-user-list></div>
|
||||
<div class="account-form-footer">
|
||||
<button type="submit">Save changes</button>
|
||||
<p class="form-status" data-admin-users-status></p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="account-section" data-admin-users-section hidden>
|
||||
<h2>Users</h2>
|
||||
<div class="user-list" data-admin-users></div>
|
||||
<p class="form-status" data-admin-users-status></p>
|
||||
</section>
|
||||
<aside class="account-sidebar">
|
||||
<section class="account-section">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Security</p>
|
||||
<h2>Change password</h2>
|
||||
</div>
|
||||
<p>Use at least 12 characters for your new password.</p>
|
||||
</header>
|
||||
<form class="auth-form" data-password-change>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Update password</button>
|
||||
<p class="form-status" data-password-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section" data-admin-settings-section hidden>
|
||||
<h2>Registration</h2>
|
||||
<form class="auth-form" data-admin-settings>
|
||||
<label class="auth-choice">
|
||||
<input type="checkbox" name="signupsEnabled" />
|
||||
Allow visitors to create accounts
|
||||
</label>
|
||||
<button type="submit">Save registration setting</button>
|
||||
<p class="form-status" data-admin-settings-status></p>
|
||||
</form>
|
||||
</section>
|
||||
<section class="account-section" data-admin-settings-section hidden>
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Administration</p>
|
||||
<h2>Registration</h2>
|
||||
</div>
|
||||
<p>Choose whether new visitors can create their own accounts.</p>
|
||||
</header>
|
||||
<form class="auth-form" data-admin-settings>
|
||||
<label class="auth-choice account-toggle">
|
||||
<input type="checkbox" name="signupsEnabled" />
|
||||
<span>
|
||||
<strong>Public signups</strong>
|
||||
<small>Allow visitors to register without an invitation.</small>
|
||||
</span>
|
||||
</label>
|
||||
<button type="submit">Save setting</button>
|
||||
<p class="form-status" data-admin-settings-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section account-section--danger">
|
||||
<h2>Delete Account</h2>
|
||||
<form class="auth-form" data-account-delete>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<button type="submit">Delete account</button>
|
||||
<p class="form-status" data-delete-status></p>
|
||||
</form>
|
||||
</section>
|
||||
<section class="account-section account-section--danger">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Danger zone</p>
|
||||
<h2>Delete account</h2>
|
||||
</div>
|
||||
<p>This permanently removes your account and cannot be undone.</p>
|
||||
</header>
|
||||
<form class="auth-form" data-account-delete>
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||
</label>
|
||||
<button class="account-danger-button" type="submit">Delete account</button>
|
||||
<p class="form-status" data-delete-status></p>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
<input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" />
|
||||
</form>
|
||||
<nav class="site-nav">
|
||||
{{ if .Principal }}
|
||||
<div class="notification-bell" data-notification-bell>
|
||||
<button class="notification-bell__trigger" type="button" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
<span class="notification-bell__count" data-unread-count hidden>0</span>
|
||||
</button>
|
||||
<div class="notification-bell__dropdown" data-notification-dropdown role="menu" hidden>
|
||||
<ul class="notification-list" aria-live="polite"></ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="/help" aria-label="Help" title="Help">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
</a>
|
||||
@@ -52,8 +66,12 @@
|
||||
{{ template "login_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "setup_content" }}
|
||||
{{ template "setup_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "password_reset_content" }}
|
||||
{{ template "password_reset_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "account_content" }}
|
||||
{{ template "account_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "token_create_content" }}
|
||||
{{ template "token_create_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "device_verify_content" }}
|
||||
{{ template "device_verify_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "error_content" }}
|
||||
@@ -79,7 +97,7 @@
|
||||
<ul data-sync-queue-list></ul>
|
||||
</section>
|
||||
<div class="version-notice" data-version-notice hidden>
|
||||
<p>A newer version is available.</p>
|
||||
<p>Document changes are available.</p>
|
||||
<button type="button" data-version-reload>Reload</button>
|
||||
</div>
|
||||
<script src="/static/cache.js" defer></script>
|
||||
@@ -89,6 +107,7 @@
|
||||
<script src="/static/auth.js" defer></script>
|
||||
<script src="/static/render.js" defer></script>
|
||||
<script src="/static/comments.js" defer></script>
|
||||
<script src="/static/archive.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
<div class="document-meta">
|
||||
<div class="document-meta__header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
{{ if .CanWrite }}
|
||||
<div class="document-actions">
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<details class="document-meta-panel">
|
||||
<summary>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Cairnquire</p>
|
||||
<h1>Log In</h1>
|
||||
<h1 data-auth-heading>Log In</h1>
|
||||
</div>
|
||||
|
||||
<div class="auth-login-section" data-login-section>
|
||||
@@ -43,6 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .DevMode }}<div class="auth-dev-login">
|
||||
<p class="auth-note">Development mode is enabled. Use the local development admin without a password.</p>
|
||||
<button type="button" class="btn-secondary" data-dev-login>Continue as development admin</button>
|
||||
<p class="form-status" data-dev-login-status></p>
|
||||
</div>{{ end }}
|
||||
|
||||
{{ if .SignupsEnabled }}<div class="auth-register-cta">
|
||||
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
|
||||
</div>{{ end }}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{{ define "password_reset.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "password_reset_content" }}
|
||||
<section class="auth-shell">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Cairnquire</p>
|
||||
<h1>Reset password</h1>
|
||||
<p class="auth-note">Choose a new password with at least 12 characters.</p>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" data-password-reset>
|
||||
<input type="hidden" name="token" value="{{ .Token }}" />
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Set new password</button>
|
||||
<p class="form-status" data-password-reset-status></p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{ define "token_create.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "token_create_content" }}
|
||||
<section class="account-shell">
|
||||
<header class="account-page-header">
|
||||
<a class="account-back" href="/account">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5" />
|
||||
<path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to account
|
||||
</a>
|
||||
<p class="account-section__kicker">Integrations</p>
|
||||
<h1>Create access token</h1>
|
||||
<p>Give a trusted client the smallest set of permissions it needs.</p>
|
||||
</header>
|
||||
|
||||
<div class="token-create-layout">
|
||||
<section class="account-section">
|
||||
<form class="auth-form token-create-form" data-token-create>
|
||||
<label>
|
||||
Token name
|
||||
<input type="text" name="name" placeholder="MacBook sync app" required />
|
||||
</label>
|
||||
<fieldset class="scope-grid">
|
||||
<legend>Permissions</legend>
|
||||
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||
</fieldset>
|
||||
<div class="account-form-footer">
|
||||
<button type="submit">Create token</button>
|
||||
<a class="btn-secondary" href="/account">Cancel</a>
|
||||
</div>
|
||||
<p class="form-status" data-token-status></p>
|
||||
<pre class="token-output" data-token-output hidden></pre>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="account-section token-create-note">
|
||||
<p class="account-section__kicker">Before you create</p>
|
||||
<h2>Keep the secret somewhere safe</h2>
|
||||
<p>The token is shown once after creation. Store it in the client that needs access, then return to your account to revoke it later.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user