accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS password_reset_tokens;
ALTER TABLE users DROP COLUMN disabled;

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE documents DROP COLUMN archived_at;

View File

@@ -0,0 +1 @@
ALTER TABLE documents ADD COLUMN archived_at TEXT;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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)

View 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';
});
});
})();

View File

@@ -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]");

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 }} &middot; {{ .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 }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}