From 7f312fef148daa60ef929e949e8daf906fdfd20e Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 10:12:13 +0100 Subject: [PATCH] feat(auth): add MINDA_DEMO support with auto-seeded demo accounts and username login --- cmd/server/main.go | 9 ++ compose.yaml | 2 + docker-compose.yml | 2 + .../src/features/auth/SetupAndAuthView.tsx | 37 +++++++- frontend/src/lib/api.ts | 4 +- frontend/src/lib/types.ts | 2 + internal/api/server.go | 20 +++- internal/api/server_test.go | 87 +++++++++++++++++ internal/config/config.go | 5 + internal/domain/models.go | 1 + internal/storage/sqlite.go | 94 +++++++++++++++---- internal/storage/sqlite_test.go | 57 +++++++++++ 12 files changed, 297 insertions(+), 23 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index da1b50e..b52aeb4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,6 +34,15 @@ func main() { // Seed SMTP defaults from environment variables into DB if not yet configured in DB store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) + if cfg.DemoMode { + log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") + if err := store.EnsureDemoAccounts(); err != nil { + log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err) + } else { + log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") + } + } + wsHub := ws.NewHub() staticFS, err := fs.Sub(embeddedDist, "dist") diff --git a/compose.yaml b/compose.yaml index b74ba1a..fc22f8d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,8 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user + - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} diff --git a/docker-compose.yml b/docker-compose.yml index b74ba1a..fc22f8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user + - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index abda554..ccd2541 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -399,13 +399,44 @@ export const SetupAndAuthView: React.FC = ({ {authTab === 'login' && (
+ {authStatus.demoMode && ( +
+
+ 🧪 Demo-Modus aktiv + Klick zum Ausfüllen: +
+
+ + +
+
+ )} +
- + setEmail(e.target.value)} - placeholder="name@beispiel.de" + placeholder="admin oder name@beispiel.de" required autoFocus className="w-full h-8 px-2.5 input-field" diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f3ee948..1f62480 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -125,10 +125,10 @@ export const api = { return res; }, - async login(email: string, password: string): Promise<{ token: string; user: User }> { + async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> { const res = await request<{ token: string; user: User }>('/api/v1/auth/login', { method: 'POST', - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }), }); setToken(res.token); return res; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c5fda65..6e2da2b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -6,11 +6,13 @@ export interface AuthStatus { needsSetup: boolean; allowRegistration: boolean; instanceName: string; + demoMode?: boolean; } export interface User { id: string; email: string; + username?: string; displayName: string; role: UserRole; isActive: boolean; diff --git a/internal/api/server.go b/internal/api/server.go index 1d36cfc..9c5d390 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -301,6 +301,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "needsSetup": count == 0, "allowRegistration": allowReg, "instanceName": instanceName, + "demoMode": s.cfg.DemoMode, }) } @@ -451,6 +452,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` + Username string `json:"username"` + Login string `json:"login"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -458,9 +461,22 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - user, err := s.store.GetUserByEmail(req.Email) + identifier := strings.TrimSpace(req.Login) + if identifier == "" { + identifier = strings.TrimSpace(req.Username) + } + if identifier == "" { + identifier = strings.TrimSpace(req.Email) + } + + if identifier == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich") + return + } + + user, err := s.store.GetUserByLogin(identifier) if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { - writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig") + writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") return } if !user.IsActive { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 9212171..5df8d6c 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -431,3 +431,90 @@ func TestNewFullFeatures(t *testing.T) { t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought) } } + +func TestLoginWithUsernameAndDemoMode(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "demo_api_test.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + if err := store.EnsureDemoAccounts(); err != nil { + t.Fatalf("failed to seed demo accounts: %v", err) + } + + cfg := &config.Config{ + Port: "8080", + DatabasePath: dbPath, + JWTSecret: "demo-jwt-secret", + DemoMode: true, + } + srv := NewServer(cfg, store, ws.NewHub(), nil) + router := srv.Router() + + // 1. Check auth status reports demoMode: true + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code) + } + var statusResp struct { + NeedsSetup bool `json:"needsSetup"` + DemoMode bool `json:"demoMode"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &statusResp) + if !statusResp.DemoMode { + t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode) + } + if statusResp.NeedsSetup { + t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup) + } + + // 2. Login with username "admin" / "admin" + loginBody := []byte(`{"email":"admin","password":"admin"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String()) + } + var loginRes struct { + Token string `json:"token"` + User domain.User `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginRes) + if loginRes.Token == "" || loginRes.User.Role != "admin" { + t.Fatalf("invalid admin login response: %+v", loginRes) + } + + // 3. Login with username "user" / "user" + userLoginBody := []byte(`{"username":"user","password":"user"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String()) + } + var userLoginRes struct { + Token string `json:"token"` + User domain.User `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes) + if userLoginRes.Token == "" || userLoginRes.User.Role != "member" { + t.Fatalf("invalid user login response: %+v", userLoginRes) + } + + // 4. Invalid credentials must return 401 + wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f388cef..e9a3668 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { BaseURL string AllowRegistration bool LogLevel string + DemoMode bool // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string @@ -41,6 +42,9 @@ func Load() *Config { allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" logLevel := getEnv("LOG_LEVEL", "info") + demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false")))) + demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on" + smtpHost := getEnv("SMTP_HOST", "") smtpPort := getEnv("SMTP_PORT", "587") smtpUser := getEnv("SMTP_USER", "") @@ -56,6 +60,7 @@ func Load() *Config { BaseURL: baseURL, AllowRegistration: allowReg, LogLevel: logLevel, + DemoMode: demoMode, SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPUser: smtpUser, diff --git a/internal/domain/models.go b/internal/domain/models.go index e51429c..ce2563b 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -95,6 +95,7 @@ func FormatGermanDateTime(isoStr string) string { type User struct { ID string `json:"id"` Email string `json:"email"` + Username string `json:"username,omitempty"` PasswordHash string `json:"-"` DisplayName string `json:"displayName"` Role string `json:"role"` // admin | team_admin | member diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 7e8a03f..b696e2e 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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 = ?`, diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index d7ab2b6..64450c2 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +}