feat(auth): add MINDA_DEMO support with auto-seeded demo accounts and username login
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
41c64c4924
commit
7f312fef14
12 files changed
+297
-23
No files matched your search
+78
-16
@@ -68,6 +68,7 @@ func (s *Store) migrate() error {
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
@@ -267,6 +268,8 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Idempotent column migrations for existing databases
|
||||
columnMigrations := []string{
|
||||
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
||||
@@ -291,15 +294,8 @@ func (s *Store) migrate() error {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Remove legacy auto-seeded default user ('admin@mindsift.local') so First-Boot Setup triggers cleanly
|
||||
var legacyUserID string
|
||||
if err := s.db.QueryRow(`SELECT id FROM users WHERE email = 'admin@mindsift.local'`).Scan(&legacyUserID); err == nil && legacyUserID != "" {
|
||||
_, _ = s.db.Exec(`DELETE FROM thoughts WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM documents WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM spaces WHERE owner_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_lists WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM users WHERE id = ?`, legacyUserID)
|
||||
}
|
||||
// Backfill username for existing accounts where username is empty
|
||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||
|
||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||
@@ -397,7 +393,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
const userSelectCols = `id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
@@ -418,7 +414,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
var isActiveInt int
|
||||
var channelsRaw string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName,
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
||||
)
|
||||
@@ -443,13 +439,24 @@ func (s *Store) CountUsers() (int, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
|
||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||
username := strings.Split(cleanEmail, "@")[0]
|
||||
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
|
||||
if role != "admin" && role != "team_admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||
cleanUsername := strings.ToLower(strings.TrimSpace(username))
|
||||
if cleanUsername == "" {
|
||||
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
||||
}
|
||||
u := &domain.User{
|
||||
ID: domain.NewID("usr"),
|
||||
Email: cleanEmail,
|
||||
Username: cleanUsername,
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: role,
|
||||
@@ -462,9 +469,9 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
||||
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -473,13 +480,68 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||||
return s.GetUserByLogin(email)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
|
||||
cleanLogin := strings.ToLower(strings.TrimSpace(login))
|
||||
if cleanLogin == "" {
|
||||
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
|
||||
}
|
||||
row := s.db.QueryRow(
|
||||
`SELECT `+userSelectCols+` FROM users WHERE email = ?`,
|
||||
strings.ToLower(strings.TrimSpace(email)),
|
||||
`SELECT `+userSelectCols+` FROM users
|
||||
WHERE LOWER(email) = ?
|
||||
OR (username != '' AND LOWER(username) = ?)
|
||||
OR LOWER(display_name) = ?
|
||||
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
|
||||
LIMIT 1`,
|
||||
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
|
||||
func (s *Store) EnsureDemoAccounts() error {
|
||||
s.initMu.Lock()
|
||||
defer s.initMu.Unlock()
|
||||
|
||||
adminHash, err := auth.HashPassword("admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userHash, err := auth.HashPassword("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Admin user (admin / admin)
|
||||
adminUser, err := s.GetUserByLogin("admin")
|
||||
if err != nil || adminUser == nil {
|
||||
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create demo admin: %w", err)
|
||||
}
|
||||
_ = s.SeedStarterContent(adminUser)
|
||||
} else {
|
||||
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
|
||||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
|
||||
}
|
||||
|
||||
// 2. Regular user (user / user)
|
||||
normalUser, err := s.GetUserByLogin("user")
|
||||
if err != nil || normalUser == nil {
|
||||
_, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create demo user: %w", err)
|
||||
}
|
||||
} else {
|
||||
_ = s.UpdateUserPassword(normalUser.ID, userHash)
|
||||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
@@ -162,3 +163,59 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("EnsureDemoAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Lookup by username "admin"
|
||||
adminUser, err := store.GetUserByLogin("admin")
|
||||
if err != nil || adminUser == nil {
|
||||
t.Fatalf("expected to find admin user by username 'admin', got %v", err)
|
||||
}
|
||||
if adminUser.Role != "admin" {
|
||||
t.Fatalf("expected role 'admin', got %s", adminUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("admin", adminUser.PasswordHash) {
|
||||
t.Fatalf("admin password check failed")
|
||||
}
|
||||
|
||||
// 2. Lookup by email "admin@mindsift.local"
|
||||
adminByEmail, err := store.GetUserByLogin("admin@mindsift.local")
|
||||
if err != nil || adminByEmail.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by email failed: got %v", err)
|
||||
}
|
||||
|
||||
// 3. Lookup with case insensitivity "ADMIN"
|
||||
adminCaps, err := store.GetUserByLogin("ADMIN")
|
||||
if err != nil || adminCaps.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by uppercase failed: got %v", err)
|
||||
}
|
||||
|
||||
// 4. Lookup by username "user"
|
||||
normalUser, err := store.GetUserByLogin("user")
|
||||
if err != nil || normalUser == nil {
|
||||
t.Fatalf("expected to find user by username 'user', got %v", err)
|
||||
}
|
||||
if normalUser.Role != "member" {
|
||||
t.Fatalf("expected role 'member', got %s", normalUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("user", normalUser.PasswordHash) {
|
||||
t.Fatalf("user password check failed")
|
||||
}
|
||||
|
||||
// 5. Ensure re-running is idempotent and preserves/refreshes credentials
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user