remove preact and create setup wizard
This commit is contained in:
@@ -100,6 +100,93 @@ func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
||||
var setupCompleted string
|
||||
var signupsEnabled int
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(setup_completed_at, ''), signups_enabled
|
||||
FROM instance_settings
|
||||
WHERE id = 1
|
||||
`).Scan(&setupCompleted, &signupsEnabled); err != nil {
|
||||
return InstanceSettings{}, fmt.Errorf("get instance settings: %w", err)
|
||||
}
|
||||
return InstanceSettings{
|
||||
SetupComplete: setupCompleted != "",
|
||||
SignupsEnabled: signupsEnabled == 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CompleteInitialSetup(ctx context.Context, user User, signupsEnabled bool) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
if user.ID == "" {
|
||||
user.ID = "user:" + user.Email
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Email
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("begin initial setup: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var setupCompleted string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(setup_completed_at, '')
|
||||
FROM instance_settings
|
||||
WHERE id = 1
|
||||
`).Scan(&setupCompleted); err != nil {
|
||||
return User{}, fmt.Errorf("get initial setup state: %w", err)
|
||||
}
|
||||
if setupCompleted != "" {
|
||||
return User{}, fmt.Errorf("initial setup has already been completed")
|
||||
}
|
||||
|
||||
var userCount int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
return User{}, fmt.Errorf("count users during initial setup: %w", err)
|
||||
}
|
||||
if userCount != 0 {
|
||||
return User{}, fmt.Errorf("initial setup requires an empty user database")
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(RoleAdmin), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
||||
return User{}, fmt.Errorf("create initial admin: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE instance_settings
|
||||
SET setup_completed_at = ?, signups_enabled = ?, updated_at = ?
|
||||
WHERE id = 1 AND setup_completed_at IS NULL
|
||||
`, now.Format(time.RFC3339), boolInt(signupsEnabled), now.Format(time.RFC3339)); err != nil {
|
||||
return User{}, fmt.Errorf("complete initial setup: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return User{}, fmt.Errorf("commit initial setup: %w", err)
|
||||
}
|
||||
return r.GetUserByEmail(ctx, user.Email)
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE instance_settings
|
||||
SET signups_enabled = ?, updated_at = ?
|
||||
WHERE id = 1 AND setup_completed_at IS NOT NULL
|
||||
`, boolInt(enabled), time.Now().UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update signup setting: %w", err)
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return fmt.Errorf("initial setup is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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, '')
|
||||
@@ -522,6 +609,13 @@ func nullString(value string) any {
|
||||
return value
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func timePtrString(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -55,7 +55,34 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
||||
return s.repo.GetInstanceSettings(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return User{}, fmt.Errorf("email is required")
|
||||
}
|
||||
if len(password) < 12 {
|
||||
return User{}, fmt.Errorf("password must be at least 12 characters")
|
||||
}
|
||||
passwordHash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return s.repo.CompleteInitialSetup(ctx, User{
|
||||
Email: email,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleAdmin,
|
||||
}, signupsEnabled)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
|
||||
if err := s.requirePublicSignups(ctx); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return User{}, fmt.Errorf("email is required")
|
||||
@@ -72,21 +99,11 @@ func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName,
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
} else if strings.EqualFold(role, string(RoleViewer)) {
|
||||
normalizedRole = RoleViewer
|
||||
}
|
||||
return s.repo.UpsertUser(ctx, User{
|
||||
Email: email,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: passwordHash,
|
||||
Role: normalizedRole,
|
||||
Role: RoleViewer,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,23 +130,18 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
|
||||
}
|
||||
|
||||
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
|
||||
if err := s.requirePublicSignups(ctx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||
if err != nil {
|
||||
if !isNoRows(err) {
|
||||
return nil, "", err
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
}
|
||||
user, err = s.repo.UpsertUser(ctx, User{
|
||||
Email: normalizeEmail(email),
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: normalizedRole,
|
||||
Role: RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -278,6 +290,43 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||
return s.repo.ListUsers(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return InstanceSettings{}, fmt.Errorf("admin role required")
|
||||
}
|
||||
if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil {
|
||||
return InstanceSettings{}, err
|
||||
}
|
||||
return s.repo.GetInstanceSettings(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureDevUser(ctx context.Context) (User, error) {
|
||||
const email = "dev@localhost"
|
||||
settings, err := s.repo.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if !settings.SetupComplete {
|
||||
return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true)
|
||||
}
|
||||
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if !isNoRows(err) {
|
||||
return User{}, err
|
||||
}
|
||||
return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin})
|
||||
}
|
||||
|
||||
func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) {
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
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")
|
||||
@@ -453,6 +502,20 @@ func (s *Service) createSession(ctx context.Context, user User, ip, userAgent st
|
||||
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
|
||||
}
|
||||
|
||||
func (s *Service) requirePublicSignups(ctx context.Context) error {
|
||||
settings, err := s.repo.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settings.SetupComplete {
|
||||
return fmt.Errorf("initial setup is required")
|
||||
}
|
||||
if !settings.SignupsEnabled {
|
||||
return fmt.Errorf("signups are disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
|
||||
return Principal{
|
||||
UserID: user.ID,
|
||||
|
||||
@@ -28,13 +28,23 @@ func setupAuthTestService(t *testing.T) *Service {
|
||||
return service
|
||||
}
|
||||
|
||||
func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User {
|
||||
t.Helper()
|
||||
|
||||
user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor")
|
||||
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
||||
}
|
||||
if user.Email != "dev@example.com" {
|
||||
t.Fatalf("email = %q, want normalized", user.Email)
|
||||
@@ -48,7 +58,7 @@ func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||
t.Fatal("expected session token")
|
||||
}
|
||||
if principal.Role != RoleAdmin {
|
||||
t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role)
|
||||
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
|
||||
}
|
||||
|
||||
validated, err := service.ValidateSessionToken(ctx, token)
|
||||
@@ -64,10 +74,7 @@ func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
user := setupInitialAdmin(t, service, true)
|
||||
expires := time.Now().UTC().Add(time.Hour)
|
||||
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
|
||||
if err != nil {
|
||||
@@ -99,10 +106,7 @@ func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
user := setupInitialAdmin(t, service, true)
|
||||
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
||||
if err != nil {
|
||||
t.Fatalf("StartDeviceFlow() error = %v", err)
|
||||
@@ -132,10 +136,7 @@ func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
user := setupInitialAdmin(t, service, true)
|
||||
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
|
||||
t.Fatalf("ChangePassword() error = %v", err)
|
||||
@@ -152,10 +153,7 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
user := setupInitialAdmin(t, service, true)
|
||||
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||
t.Fatal("expected demoting last admin to fail")
|
||||
@@ -169,6 +167,7 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
setupInitialAdmin(t, service, true)
|
||||
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
@@ -179,3 +178,32 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
t.Fatal("expected public passkey registration for existing account to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, false)
|
||||
|
||||
if admin.Role != RoleAdmin {
|
||||
t.Fatalf("initial role = %s, want admin", admin.Role)
|
||||
}
|
||||
if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err)
|
||||
}
|
||||
|
||||
principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
settings, err := service.UpdateSignupsEnabled(ctx, principal, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSignupsEnabled() error = %v", err)
|
||||
}
|
||||
if !settings.SignupsEnabled {
|
||||
t.Fatal("expected signups to be enabled")
|
||||
}
|
||||
viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err)
|
||||
}
|
||||
if viewer.Role != RoleViewer {
|
||||
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ type CreatedAPIKey struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type InstanceSettings struct {
|
||||
SetupComplete bool `json:"setupComplete"`
|
||||
SignupsEnabled bool `json:"signupsEnabled"`
|
||||
}
|
||||
|
||||
type DeviceCode struct {
|
||||
ID string
|
||||
UserID string
|
||||
|
||||
Reference in New Issue
Block a user