Dev #2
No files matched your search
@@ -34,6 +34,15 @@ func main() {
|
|||||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
// 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)
|
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()
|
wsHub := ws.NewHub()
|
||||||
|
|
||||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- 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)
|
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
- SMTP_HOST=${SMTP_HOST:-}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- 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)
|
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
- SMTP_HOST=${SMTP_HOST:-}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
|||||||
@@ -399,13 +399,44 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
{authTab === 'login' && (
|
{authTab === 'login' && (
|
||||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||||
|
{authStatus.demoMode && (
|
||||||
|
<div className="p-2.5 rounded border border-accent/30 bg-accent/10 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-semibold text-accent">
|
||||||
|
<span>🧪 Demo-Modus aktiv</span>
|
||||||
|
<span className="text-[10px] text-muted font-normal">Klick zum Ausfüllen:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail('admin');
|
||||||
|
setPassword('admin');
|
||||||
|
}}
|
||||||
|
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||||
|
>
|
||||||
|
admin / admin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail('user');
|
||||||
|
setPassword('user');
|
||||||
|
}}
|
||||||
|
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||||
|
>
|
||||||
|
user / user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">E-Mail-Adresse</label>
|
<label className="block text-muted mb-1">Benutzername oder E-Mail-Adresse</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="name@beispiel.de"
|
placeholder="admin oder name@beispiel.de"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ export const api = {
|
|||||||
return res;
|
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', {
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
|
||||||
});
|
});
|
||||||
setToken(res.token);
|
setToken(res.token);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ export interface AuthStatus {
|
|||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
instanceName: string;
|
instanceName: string;
|
||||||
|
demoMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
username?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|||||||
+18
-2
@@ -301,6 +301,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"needsSetup": count == 0,
|
"needsSetup": count == 0,
|
||||||
"allowRegistration": allowReg,
|
"allowRegistration": allowReg,
|
||||||
"instanceName": instanceName,
|
"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) {
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Login string `json:"login"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !user.IsActive {
|
if !user.IsActive {
|
||||||
|
|||||||
@@ -431,3 +431,90 @@ func TestNewFullFeatures(t *testing.T) {
|
|||||||
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type Config struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
DemoMode bool
|
||||||
|
|
||||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
@@ -41,6 +42,9 @@ func Load() *Config {
|
|||||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||||
logLevel := getEnv("LOG_LEVEL", "info")
|
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", "")
|
smtpHost := getEnv("SMTP_HOST", "")
|
||||||
smtpPort := getEnv("SMTP_PORT", "587")
|
smtpPort := getEnv("SMTP_PORT", "587")
|
||||||
smtpUser := getEnv("SMTP_USER", "")
|
smtpUser := getEnv("SMTP_USER", "")
|
||||||
@@ -56,6 +60,7 @@ func Load() *Config {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
AllowRegistration: allowReg,
|
AllowRegistration: allowReg,
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
|
DemoMode: demoMode,
|
||||||
SMTPHost: smtpHost,
|
SMTPHost: smtpHost,
|
||||||
SMTPPort: smtpPort,
|
SMTPPort: smtpPort,
|
||||||
SMTPUser: smtpUser,
|
SMTPUser: smtpUser,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func FormatGermanDateTime(isoStr string) string {
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"` // admin | team_admin | member
|
Role string `json:"role"` // admin | team_admin | member
|
||||||
|
|||||||
+78
-16
@@ -68,6 +68,7 @@ func (s *Store) migrate() error {
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
@@ -267,6 +268,8 @@ func (s *Store) migrate() error {
|
|||||||
|
|
||||||
// Idempotent column migrations for existing databases
|
// Idempotent column migrations for existing databases
|
||||||
columnMigrations := []string{
|
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 role TEXT NOT NULL DEFAULT 'member';",
|
||||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||||||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
"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)
|
_, _ = s.db.Exec(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove legacy auto-seeded default user ('admin@mindsift.local') so First-Boot Setup triggers cleanly
|
// Backfill username for existing accounts where username is empty
|
||||||
var legacyUserID string
|
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
// 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
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||||
@@ -397,7 +393,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
|||||||
|
|
||||||
// --- USERS ---
|
// --- 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 {
|
func parseAlarmChannels(raw string) []string {
|
||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
@@ -418,7 +414,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
|||||||
var isActiveInt int
|
var isActiveInt int
|
||||||
var channelsRaw string
|
var channelsRaw string
|
||||||
err := row.Scan(
|
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.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
&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) {
|
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" {
|
if role != "admin" && role != "team_admin" && role != "member" {
|
||||||
role = "member"
|
role = "member"
|
||||||
}
|
}
|
||||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||||
|
cleanUsername := strings.ToLower(strings.TrimSpace(username))
|
||||||
|
if cleanUsername == "" {
|
||||||
|
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
||||||
|
}
|
||||||
u := &domain.User{
|
u := &domain.User{
|
||||||
ID: domain.NewID("usr"),
|
ID: domain.NewID("usr"),
|
||||||
Email: cleanEmail,
|
Email: cleanEmail,
|
||||||
|
Username: cleanUsername,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
DisplayName: strings.TrimSpace(displayName),
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
Role: role,
|
Role: role,
|
||||||
@@ -462,9 +469,9 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
|
|||||||
CreatedAt: domain.NowISO(),
|
CreatedAt: domain.NowISO(),
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, 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)
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
||||||
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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(
|
row := s.db.QueryRow(
|
||||||
`SELECT `+userSelectCols+` FROM users WHERE email = ?`,
|
`SELECT `+userSelectCols+` FROM users
|
||||||
strings.ToLower(strings.TrimSpace(email)),
|
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)
|
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) {
|
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"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")
|
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