feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-24 22:59:48 +02:00
1 parent 50ac0c8f64
commit 7802eb2cf0
17 files changed
+4391 -1368

No files matched your search

+9 -1
View File
@@ -4,5 +4,13 @@ DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=true
ALLOW_REGISTRATION=false
LOG_LEVEL=info
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
SMTP_TLS=true
+18 -12
View File
@@ -22,25 +22,31 @@
- Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code.
- Teilen von Aufgabenlisten mit Teams/Spaces.
- Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern.
- [x] **Benutzer- & Rollensteuerung + Admin-Panel (`SettingsModal.tsx`, `server.go`, `sqlite.go`):**
- Benutzerrollen: `admin` und `member`.
- Admin-Verwaltungsbereich zur Erstellung von Accounts, Rollenwechsel, Deaktivierung und Passwort-Reset.
- Server-weite Sharing-Isolations-Modi: `open` (alle finden sich), `teams_only` (nur innerhalb von Teams/Organisationen sichtbar) und `strict_private` (kein Teilen).
- Organisationen & Teams zur Strukturierung von Benutzergruppen.
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
- Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf.
- Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen.
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden.
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
- Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü:
- **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Multi-Channel-Alarme, Private Webhooks & Daten-Export).
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver).
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
- 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied).
- Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt.
- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):**
- Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`).
- SMTP-Konfiguration (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS`) sowohl über `docker-compose.yml` Environment-Variablen als auch direkt in der Admin-Oberfläche (`system_settings` in SQLite) einstellbar, inklusive Live-Testversand.
- [x] **Crash-Fix für „Aufgaben" & „Organisationen & Teams" (`sqlite.go`, `api.ts`, `TasksView.tsx`):**
- Alle Go-Storage-Methoden initialisieren Slices strikt mit `make([]T, 0)` statt `var s []T`, damit `json.Marshal` niemals `null` statt `[]` liefert.
- Zusätzliche `ensureArray()`-Normalisierung in `frontend/src/lib/api.ts` sowie `ViewErrorBoundary` in `App.tsx`.
- [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):**
- Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken).
- Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung.
- Physische Speicherung auf Festplatte unter `/data/workspace/<user_id>/...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite.
- [x] **Webhooks, Alarm-Scheduler & Discord-Integration (`SettingsModal.tsx`, `server.go`):**
- "Webhooks & Export" aus dem Header in das Benutzereinstellungen-Modal verlagert.
- Eigene API-Tokens mit curl-Beispielen für Skripte und externe Alarme.
- Hintergrund-Goroutine im Backend prüft fällige Aufgaben und sendet rechtzeitig (z.B. 15, 30 oder 60 Min. vor Fälligkeit) formatierte Discord-Embeds via Webhook.
- Vollständiger JSON-DB-Export auf Knopfdruck.
- [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):**
- SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben.
- Stale Token Auto-Resolution via `EnsureDefaultUser()`.
- `Cache-Control: no-store` auf allen API-Routen und `index.html`.
- Optimistisches Leeren des Capture-Inputs und 8-Sekunden-Fetch-Timeout.
- Optimistisches Leeren des Capture-Inputs und 12-Sekunden-Fetch-Timeout.
---
+2 -4
View File
@@ -31,10 +31,8 @@ func main() {
}
defer store.Close()
// Ensure initial default workspace exists on fresh self-hosted boot
if _, err := store.EnsureDefaultUser(); err != nil {
log.Printf("Warning: could not seed default user: %v", err)
}
// 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)
wsHub := ws.NewHub()
+8 -1
View File
@@ -15,5 +15,12 @@ services:
- DATA_DIR=/data
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_TLS=${SMTP_TLS:-true}
+305 -91
View File
@@ -1,23 +1,83 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
FileText,
ShoppingCart,
Brain,
Settings,
Shield,
LogOut,
ChevronDown,
RotateCcw,
} from 'lucide-react';
import { DashboardStats, SharedList, Thought, User } from './lib/types';
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SettingsModal } from './features/settings/SettingsModal';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists';
type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'user_settings'
| 'admin_settings';
interface ErrorBoundaryState {
hasError: boolean;
errorText: string;
}
class ViewErrorBoundary extends React.Component<
{ children: React.ReactNode; onReset: () => void },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; onReset: () => void }) {
super(props);
this.state = { hasError: false, errorText: '' };
}
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
}
render() {
if (this.state.hasError) {
return (
<div className="p-6 bg-panel border border-border text-xs space-y-3 max-w-lg mx-auto my-8">
<h3 className="text-sm font-bold text-white">Ansicht konnte nicht geladen werden</h3>
<p className="text-zinc-400">{this.state.errorText}</p>
<button
onClick={() => {
this.setState({ hasError: false, errorText: '' });
this.props.onReset();
}}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Ansicht zurücksetzen</span>
</button>
</div>
);
}
return this.props.children;
}
}
export const App: React.FC = () => {
const [bootLoading, setBootLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus>({
needsSetup: false,
allowRegistration: false,
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
@@ -34,7 +94,9 @@ export const App: React.FC = () => {
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
const [showProfileModal, setShowProfileModal] = useState(false);
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const refreshAll = async () => {
try {
@@ -45,49 +107,85 @@ export const App: React.FC = () => {
]);
setUser(meRes.user);
setStats(meRes.stats);
setDeck(deckRes);
setLists(listsRes);
setDeck(Array.isArray(deckRes) ? deckRes : []);
setLists(Array.isArray(listsRes) ? listsRes : []);
} catch {
// Fallback to quickStart session
try {
const qs = await api.quickStart();
setUser(qs.user);
const [st, dk, ls] = await Promise.all([
api.getStats(),
api.getTriageDeck(),
api.getAllLists(),
]);
setStats(st);
setDeck(dk);
setLists(ls);
} catch {
// Offline or waiting for server
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
}
};
const checkBootAndAuth = async () => {
setBootLoading(true);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
if (st.needsSetup) {
clearToken();
setUser(null);
return;
}
if (getToken()) {
await api.flushOfflineQueue();
await refreshAll();
} else {
setUser(null);
}
} catch {
// Server temporarily unreachable
} finally {
setBootLoading(false);
}
};
useEffect(() => {
const init = async () => {
if (!getToken()) {
try {
await api.quickStart();
} catch {
// ignore
}
checkBootAndAuth();
const onUnauthorized = async () => {
setUser(null);
setShowProfileMenu(false);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
await api.flushOfflineQueue();
await refreshAll();
};
init();
const onOnline = async () => {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
if (getToken()) {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
}
};
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
return () => {
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
}, []);
// Close profile dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProfileMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAuthenticated = async (loggedInUser: User) => {
setUser(loggedInUser);
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
setActiveTab('triage');
await refreshAll();
};
const handleCaptured = (
thought: Thought,
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
@@ -110,7 +208,6 @@ export const App: React.FC = () => {
const handleTriggerAddButton = () => {
triggerHaptic(20);
// Scroll smoothly to top and focus capture bar
window.scrollTo({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
};
@@ -118,11 +215,43 @@ export const App: React.FC = () => {
const handleLogout = async () => {
triggerHaptic(20);
clearToken();
await api.quickStart();
await refreshAll();
setShowProfileModal(false);
setUser(null);
setShowProfileMenu(false);
setActiveTab('triage');
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
};
if (bootLoading) {
return (
<div className="min-h-screen bg-canvas text-zinc-400 flex items-center justify-center text-xs font-sans">
Verbinde mit MindSift…
</div>
);
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
/>
);
}
const isStaff = user.role === 'admin' || user.role === 'team_admin';
const roleLabel =
user.role === 'admin'
? 'Server-Admin'
: user.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="min-h-screen bg-canvas text-white flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar */}
@@ -141,7 +270,7 @@ export const App: React.FC = () => {
</div>
<div className="flex flex-col">
<span className="font-bold text-sm sm:text-base text-white tracking-tight leading-none">
MindSift
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[11px] text-zinc-400 mt-0.5 hidden sm:inline-block">
Der Kopf frei in drei Sekunden
@@ -158,21 +287,83 @@ export const App: React.FC = () => {
/>
</div>
{/* User Profile Pill */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* User Profile Pill & Dropdown Menu */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
<button
onClick={() => {
triggerHaptic(10);
setShowProfileModal(true);
setShowProfileMenu((prev) => !prev);
}}
className="h-8 pl-1 pr-3 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
title="Profil & Einstellungen"
className="h-8 pl-1 pr-2.5 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
title="Benutzermenü öffnen"
>
<div className="w-6 h-6 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center">
{user?.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<span className="max-w-[120px] truncate hidden sm:inline">{user?.displayName || 'Konto'}</span>
<span className="max-w-[120px] truncate hidden sm:inline">
{user.displayName}
</span>
<ChevronDown className="w-3.5 h-3.5 text-zinc-400" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-60 bg-panel border border-border shadow-2xl z-50 divide-y divide-border text-xs">
{/* User Info Header */}
<div className="p-3 bg-canvas">
<p className="font-bold text-white truncate">{user.displayName}</p>
<p className="text-[11px] text-zinc-400 truncate">{user.email}</p>
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('user_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-zinc-200'
}`}
>
<Settings className="w-4 h-4 text-accent" />
<span>Kontoeinstellungen</span>
</button>
{isStaff && (
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('admin_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'admin_settings'
? 'text-accent font-semibold'
: 'text-zinc-200'
}`}
>
<Shield className="w-4 h-4 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Logout */}
<div className="py-1">
<button
onClick={handleLogout}
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-red-400 hover:bg-red-950/30 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Abmelden</span>
</button>
</div>
</div>
)}
</div>
</div>
@@ -189,7 +380,13 @@ export const App: React.FC = () => {
<Layers className="w-4 h-4" />
<span>Triage</span>
{stats.inboxCount > 0 && (
<span className={activeTab === 'triage' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'triage'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.inboxCount}
</span>
)}
@@ -205,7 +402,13 @@ export const App: React.FC = () => {
<CheckSquare className="w-4 h-4" />
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className={activeTab === 'tasks' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'tasks'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.tasksOpenCount}
</span>
)}
@@ -220,7 +423,13 @@ export const App: React.FC = () => {
>
<FileText className="w-4 h-4" />
<span>Workspace</span>
<span className={activeTab === 'workspace' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'workspace'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.docsCount}
</span>
</button>
@@ -235,58 +444,63 @@ export const App: React.FC = () => {
<ShoppingCart className="w-4 h-4" />
<span>Geteilte Listen</span>
</button>
</div>
</nav>
</header>
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
{/* Tab View Content */}
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
{activeTab === 'tasks' && (
<TasksView
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/>
)}
{activeTab === 'tasks' && (
<TasksView
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
)}
</section>
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
)}
{activeTab === 'user_settings' && (
<UserSettingsView
currentUser={user}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onBack={() => setActiveTab('triage')}
/>
)}
{activeTab === 'admin_settings' && isStaff && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
/>
)}
</section>
</ViewErrorBoundary>
</main>
{/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */}
{showProfileModal && (
<SettingsModal
currentUser={user}
onClose={() => setShowProfileModal(false)}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onLoggedOut={handleLogout}
/>
)}
</div>
);
};
@@ -0,0 +1,527 @@
import React, { useState } from 'react';
import {
Brain,
Shield,
Lock,
KeyRound,
UserPlus,
LogIn,
Server,
Share2,
Check,
AlertCircle,
} from 'lucide-react';
import { AuthStatus, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
const [setupEmail, setSetupEmail] = useState('');
const [setupPassword, setSetupPassword] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>('teams_only');
const [allowRegistration, setAllowRegistration] = useState(false);
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.completeSetup({
email: setupEmail.trim(),
password: setupPassword,
displayName: setupName.trim(),
instanceName: instanceName.trim() || 'MindSift',
sharingMode,
allowRegistration,
createSampleData,
});
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Fehler bei der Ersteinrichtung');
} finally {
setSubmitting(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.login(email.trim(), password);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Ungültige Anmeldedaten');
} finally {
setSubmitting(false);
}
};
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const code = authTab === 'invite' ? inviteCode.trim() : '';
const res = await api.register(email.trim(), password, displayName.trim(), code);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Registrierung fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
// ============================================================================
// FIRST-BOOT SERVER SETUP WIZARD
// ============================================================================
if (authStatus.needsSetup) {
return (
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-xl bg-panel border border-border shadow-2xl">
{/* Top Brand Bar */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-base font-bold text-white tracking-tight leading-none">
MindSift Ersteinrichtung
</h1>
<p className="text-[11px] text-zinc-400 mt-0.5">
Initiales Administrator-Konto & Server-Konfiguration
</p>
</div>
</div>
<span className="px-2 py-0.5 bg-accent/15 border border-accent text-accent text-[10px] font-semibold uppercase tracking-wider">
First Boot
</span>
</div>
<form onSubmit={handleCompleteSetup} className="p-5 space-y-5 text-xs">
<p className="text-zinc-300 leading-relaxed">
Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in
der Datenbank. Lege jetzt das erste <strong>Server-Admin-Konto</strong> an und bestimme
die Sicherheitseinstellungen.
</p>
{error && (
<div className="p-3 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Section 1: Admin Account */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>1. Server-Administrator anlegen</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-zinc-400 mb-1">Dein Anzeigename *</label>
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
placeholder="z.B. Admin / Dein Name"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">E-Mail-Adresse (Login) *</label>
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="admin@deinedomain.de"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
<div>
<label className="block text-zinc-400 mb-1">Administrator-Passwort *</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
{/* Section 2: Server & Isolation */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-accent" />
<span>2. Instanz & Berechtigungen</span>
</h2>
<div>
<label className="block text-zinc-400 mb-1">Name der Instanz</label>
<input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="MindSift"
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1.5 flex items-center gap-1">
<Share2 className="w-3 h-3 text-accent" />
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSharingMode('strict_private')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'strict_private'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Strikt Privat</p>
<p className="text-[10px] text-zinc-400 mt-0.5">
Keiner sieht andere Nutzer. Komplett isoliert.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('teams_only')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'teams_only'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Teams isoliert</p>
<p className="text-[10px] text-zinc-400 mt-0.5">
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('open')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'open'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Alle sehen alle</p>
<p className="text-[10px] text-zinc-400 mt-0.5">
Offene Zusammenarbeit auf dem gesamten Server.
</p>
</button>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2">
<label className="flex items-center justify-between cursor-pointer select-none">
<div>
<span className="font-medium text-white block">
Öffentliche Selbstregistrierung erlauben
</span>
<span className="text-[11px] text-zinc-400">
Wenn deaktiviert, können neue Nutzer nur per Einladungscode oder durch einen Admin angelegt werden.
</span>
</div>
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
<div>
<span className="font-medium text-white block">
Start-Beispieldaten anlegen
</span>
<span className="text-[11px] text-zinc-400">
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
</span>
</div>
<input
type="checkbox"
checked={createSampleData}
onChange={(e) => setCreateSampleData(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
</div>
</div>
<div className="flex items-center justify-end pt-1">
<button
type="submit"
disabled={submitting}
className="btn-primary h-9 px-5 text-xs font-semibold flex items-center gap-2 disabled:opacity-50"
>
<Check className="w-4 h-4" />
<span>{submitting ? 'Richte Server ein…' : 'Ersteinrichtung abschließen & Starten'}</span>
</button>
</div>
</form>
</div>
</div>
);
}
// ============================================================================
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
// ============================================================================
return (
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md bg-panel border border-border shadow-2xl">
{/* Top Header */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-sm font-bold text-white tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</h1>
<p className="text-[11px] text-zinc-400 mt-0.5">
Der Kopf frei in drei Sekunden
</p>
</div>
</div>
<Lock className="w-4 h-4 text-zinc-500" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<LogIn className="w-3.5 h-3.5" />
<span>Anmelden</span>
</button>
<button
type="button"
onClick={() => {
setAuthTab('invite');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'invite'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>Einladungscode</span>
</button>
{authStatus.allowRegistration && (
<button
type="button"
onClick={() => {
setAuthTab('register');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 transition-colors ${
authTab === 'register'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<UserPlus className="w-3.5 h-3.5" />
<span>Registrieren</span>
</button>
)}
</div>
{/* Form Body */}
<div className="p-5 text-xs">
{error && (
<div className="mb-4 p-2.5 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
<div>
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<LogIn className="w-3.5 h-3.5" />
<span>{submitting ? 'Melde an…' : 'Anmelden'}</span>
</button>
</div>
{!authStatus.allowRegistration && (
<p className="text-[11px] text-zinc-500 text-center pt-2 border-t border-border">
Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '}
<button
type="button"
onClick={() => setAuthTab('invite')}
className="text-accent hover:underline"
>
Einladungscode
</button>
.
</p>
)}
</form>
)}
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'invite' && (
<div>
<label className="block text-zinc-400 mb-1">Einladungscode *</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
placeholder="MS-XXXXXXXX"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-white font-mono uppercase"
/>
</div>
)}
<div>
<label className="block text-zinc-400 mb-1">Anzeigename *</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dein Name"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">E-Mail-Adresse *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Passwort *</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<UserPlus className="w-3.5 h-3.5" />
<span>
{submitting
? 'Konto wird erstellt…'
: authTab === 'invite'
? 'Einladung einlösen & Konto erstellen'
: 'Konto registrieren'}
</span>
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -1,953 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
X,
User as UserIcon,
Shield,
Bell,
Key,
Users,
Settings as SettingsIcon,
Plus,
Trash2,
Check,
Copy,
RefreshCw,
LogOut,
Terminal,
Download,
AlertTriangle,
Lock,
Share2,
} from 'lucide-react';
import { APIKey, Organization, SystemSettings, Team, User } from '../../lib/types';
import { api, clearToken, getToken, triggerHaptic } from '../../lib/api';
interface SettingsModalProps {
currentUser: User | null;
onClose: () => void;
onUserUpdated: (u: User) => void;
onLoggedOut: () => void;
}
type SettingsTab = 'profile' | 'alarms' | 'webhooks' | 'teams' | 'admin';
export const SettingsModal: React.FC<SettingsModalProps> = ({
currentUser,
onClose,
onUserUpdated,
onLoggedOut,
}) => {
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser?.displayName || '');
const [email, setEmail] = useState(currentUser?.email || '');
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
// --- Alarms / Discord State ---
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser?.discordWebhookUrl || '');
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState(currentUser?.notifyBeforeMinutes || 30);
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [testingDiscord, setTestingDiscord] = useState(false);
// --- Webhooks / API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// --- Organizations & Teams State ---
const [orgs, setOrgs] = useState<Organization[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [newOrgName, setNewOrgName] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState('');
const [newTeamName, setNewTeamName] = useState('');
const [newTeamDesc, setNewTeamDesc] = useState('');
// --- Admin State ---
const [adminUsers, setAdminUsers] = useState<User[]>([]);
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
sharingMode: 'teams_only',
allowRegistration: true,
instanceName: 'MindSift',
});
const [newAdminUserEmail, setNewAdminUserEmail] = useState('');
const [newAdminUserPassword, setNewAdminUserPassword] = useState('');
const [newAdminUserDisplayName, setNewAdminUserDisplayName] = useState('');
const [newAdminUserRole, setNewAdminUserRole] = useState<'member' | 'admin'>('member');
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
useEffect(() => {
loadKeys();
loadOrgsAndTeams();
if (currentUser?.role === 'admin') {
loadAdminData();
}
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const loadOrgsAndTeams = async () => {
try {
const [o, t] = await Promise.all([api.getOrganizations(), api.getUserTeams()]);
setOrgs(o);
setTeams(t);
if (o.length > 0 && !selectedOrgId) {
setSelectedOrgId(o[0].id);
}
} catch {
// ignore
}
};
const loadAdminData = async () => {
try {
const [u, s] = await Promise.all([api.adminGetUsers(), api.adminGetSettings()]);
setAdminUsers(u);
setSystemSettings(s);
} catch {
// ignore
}
};
// --- Profile Handlers ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMsg(null);
try {
const patch: any = { displayName, email };
if (newPassword.trim()) {
patch.password = newPassword.trim();
}
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setProfileMsg({ type: 'ok', text: 'Profil erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
}
};
// --- Alarms Handlers ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
setAlarmMsg(null);
try {
const updated = await api.updateSettings({
discordWebhookUrl: discordWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes),
});
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Discord Alarm-Einstellungen gespeichert.' });
} catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
}
};
const handleTestDiscordWebhook = async () => {
if (!discordWebhookUrl.trim()) {
setAlarmMsg({ type: 'err', text: 'Bitte zuerst eine Discord Webhook URL eingeben.' });
return;
}
setTestingDiscord(true);
setAlarmMsg(null);
try {
const res = await fetch(discordWebhookUrl.trim(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: '🔔 **MindSift Test-Alarm**: Verbindung zu Discord erfolgreich hergestellt!',
embeds: [
{
title: 'MindSift Aufgaben-Alarm eingerichtet',
description: 'Du erhältst hier Benachrichtigungen, wenn Aufgaben fällig werden.',
color: 3066993,
footer: { text: 'MindSift System Test' },
},
],
}),
});
if (!res.ok) {
throw new Error(`Discord antwortete mit Status ${res.status}`);
}
setAlarmMsg({ type: 'ok', text: '✅ Test-Nachricht erfolgreich an Discord gesendet!' });
} catch (err: any) {
setAlarmMsg({ type: 'err', text: `Fehler: ${err.message}` });
} finally {
setTestingDiscord(false);
}
};
// --- Webhook / Key Handlers ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant / Script');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Schlüssel widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleExportData = async () => {
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
// --- Orgs & Teams Handlers ---
const handleCreateOrg = async (e: React.FormEvent) => {
e.preventDefault();
if (!newOrgName.trim()) return;
try {
const created = await api.createOrganization(newOrgName.trim());
setOrgs((prev) => [...prev, created]);
setSelectedOrgId(created.id);
setNewOrgName('');
} catch (err: any) {
alert(err.message);
}
};
const handleCreateTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedOrgId || !newTeamName.trim()) return;
try {
const created = await api.createOrgTeam(selectedOrgId, newTeamName.trim(), newTeamDesc.trim());
setTeams((prev) => [...prev, created]);
setNewTeamName('');
setNewTeamDesc('');
} catch (err: any) {
alert(err.message);
}
};
// --- Admin Handlers ---
const handleUpdateSystemSettings = async (patch: Partial<SystemSettings>) => {
const next = { ...systemSettings, ...patch };
setSystemSettings(next);
try {
await api.adminUpdateSettings(next);
} catch (err: any) {
alert(err.message);
}
};
const handleAdminCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
try {
const created = await api.adminCreateUser({
email: newAdminUserEmail,
password: newAdminUserPassword,
displayName: newAdminUserDisplayName || newAdminUserEmail.split('@')[0],
role: newAdminUserRole,
});
setAdminUsers((prev) => [...prev, created]);
setNewAdminUserEmail('');
setNewAdminUserPassword('');
setNewAdminUserDisplayName('');
} catch (err: any) {
alert(err.message);
}
};
const handleToggleUserActive = async (u: User) => {
try {
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
} catch (err: any) {
alert(err.message);
}
};
const handleToggleUserRole = async (u: User) => {
const nextRole = u.role === 'admin' ? 'member' : 'admin';
try {
const updated = await api.adminUpdateUser(u.id, { role: nextRole });
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
} catch (err: any) {
alert(err.message);
}
};
const handleConfirmResetPassword = async (userId: string) => {
if (!resetPwdVal.trim()) return;
try {
await api.adminResetPassword(userId, resetPwdVal.trim());
alert('Passwort erfolgreich zurückgesetzt.');
setResetPwdUserId(null);
setResetPwdVal('');
} catch (err: any) {
alert(err.message);
}
};
const handleAdminDeleteUser = async (userId: string) => {
if (!window.confirm('Benutzer unwiderruflich löschen? Alle Daten dieses Nutzers werden gelöscht.')) return;
try {
await api.adminDeleteUser(userId);
setAdminUsers((prev) => prev.filter((u) => u.id !== userId));
} catch (err: any) {
alert(err.message);
}
};
const baseUrl = window.location.origin;
const curlExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "Authorization: Bearer ${newlyCreatedKey || apiKeys[0]?.keyPrefix || 'DEIN_API_KEY'}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Backup abgeschlossen #homelab #backup", "priority": "high", "target": "task"}'`;
return (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-2 sm:p-4">
<div className="w-full max-w-3xl bg-panel border border-border shadow-2xl flex flex-col max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="p-3 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<SettingsIcon className="w-4 h-4 text-accent" />
<h2 className="text-sm font-bold text-white tracking-tight">
Konto-Einstellungen & Administration
</h2>
</div>
<button
onClick={onClose}
className="p-1 text-zinc-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Modal Navigation Tabs */}
<div className="flex items-stretch border-b border-border bg-canvas overflow-x-auto no-scrollbar text-xs">
<button
onClick={() => setActiveTab('profile')}
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
activeTab === 'profile' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
}`}
>
<UserIcon className="w-3.5 h-3.5" />
<span>Profil & Sicherheit</span>
</button>
<button
onClick={() => setActiveTab('alarms')}
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
activeTab === 'alarms' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
}`}
>
<Bell className="w-3.5 h-3.5" />
<span>Alarme & Discord</span>
</button>
<button
onClick={() => setActiveTab('webhooks')}
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
activeTab === 'webhooks' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
}`}
>
<Key className="w-3.5 h-3.5" />
<span>Webhooks & Export</span>
</button>
<button
onClick={() => setActiveTab('teams')}
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
activeTab === 'teams' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Organisationen & Teams</span>
</button>
{currentUser?.role === 'admin' && (
<button
onClick={() => setActiveTab('admin')}
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
activeTab === 'admin' ? 'bg-panel text-accent font-semibold border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
}`}
>
<Shield className="w-3.5 h-3.5 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Modal Body */}
<div className="p-4 overflow-y-auto flex-1 space-y-4 text-xs">
{/* TAB 1: PROFIL & SICHERHEIT */}
{activeTab === 'profile' && (
<div className="space-y-4 max-w-lg">
<div>
<h3 className="text-sm font-semibold text-white mb-1">Benutzerprofil bearbeiten</h3>
<p className="text-zinc-400 text-xs">
Passe deinen Anzeigenamen und dein Anmeldekennwort an.
</p>
</div>
{profileMsg && (
<div
className={`p-2 border text-xs ${
profileMsg.type === 'ok'
? 'bg-blue-900/20 border-accent text-blue-200'
: 'bg-red-900/20 border-red-500 text-red-200'
}`}
>
{profileMsg.text}
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-3">
<div>
<label className="block text-zinc-400 mb-1">Anzeigename</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Neues Passwort (leer lassen für keine Änderung)</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border">
<button
type="button"
onClick={onLoggedOut}
className="text-red-400 hover:underline flex items-center gap-1.5 py-1"
>
<LogOut className="w-3.5 h-3.5" />
<span>Abmelden</span>
</button>
<button type="submit" className="btn-primary h-8 px-4 text-xs">
Änderungen speichern
</button>
</div>
</form>
</div>
)}
{/* TAB 2: ALARME & DISCORD */}
{activeTab === 'alarms' && (
<div className="space-y-4 max-w-lg">
<div>
<h3 className="text-sm font-semibold text-white mb-1">Aufgaben-Alarme & Discord Benachrichtigungen</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Trage deine Discord Webhook URL ein, damit der MindSift Server dich automatisch erinnert, sobald Aufgaben fällig werden.
</p>
</div>
{alarmMsg && (
<div
className={`p-2 border text-xs ${
alarmMsg.type === 'ok'
? 'bg-blue-900/20 border-accent text-blue-200'
: 'bg-red-900/20 border-red-500 text-red-200'
}`}
>
{alarmMsg.text}
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-3">
<div>
<label className="block text-zinc-400 mb-1">Discord Webhook URL</label>
<input
type="url"
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/123456789/token..."
className="w-full h-8 px-2.5 input-field text-white font-mono text-xs"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Vorwarnzeit vor Fälligkeit</label>
<select
value={notifyBeforeMinutes}
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
className="w-full h-8 px-2 input-field text-white bg-canvas"
>
<option value={0}>Direkt bei Fälligkeit</option>
<option value={15}>15 Minuten vorher</option>
<option value={30}>30 Minuten vorher</option>
<option value={60}>1 Stunde vorher</option>
<option value={1440}>1 Tag vorher</option>
</select>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border">
<button
type="button"
onClick={handleTestDiscordWebhook}
disabled={testingDiscord || !discordWebhookUrl.trim()}
className="btn-action h-8 px-3 text-xs disabled:opacity-40"
>
<span>{testingDiscord ? 'Sende Test…' : 'Webhook testen'}</span>
</button>
<button type="submit" className="btn-primary h-8 px-4 text-xs">
Alarm-Einstellungen sichern
</button>
</div>
</form>
</div>
)}
{/* TAB 3: WEBHOOKS & EXPORT */}
{activeTab === 'webhooks' && (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-white mb-1">Private Webhook Keys & Daten-Export</h3>
<p className="text-zinc-400 text-xs">
Erstelle API-Keys für eigene Bash-Skripte, Home Assistant oder Cronjobs, um Gedanken direkt per Curl zu erfassen.
</p>
</div>
{/* API Key Creator */}
<form onSubmit={handleCreateAPIKey} className="flex gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Key Bezeichnung (z.B. Smart Home, Backup Skript)…"
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />
<span>Key erstellen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="p-2.5 bg-accent/15 border border-accent/40 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[10px] text-accent font-semibold">Neuer Key (jetzt kopieren):</p>
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
</div>
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-7 px-2.5 text-xs">
{copiedKey ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
)}
{/* Keys list */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-3 text-center text-zinc-500 text-xs">Noch keine API-Schlüssel angelegt.</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-2 flex items-center justify-between">
<div>
<p className="font-semibold text-white">{k.name}</p>
<p className="text-[10px] text-zinc-500 font-mono">Präfix: {k.keyPrefix}••••••••</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Key löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Interactive Curl Example */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-zinc-300 flex items-center gap-1">
<Terminal className="w-3 h-3 text-accent" /> Curl Schnellbefehl für eigene Skripte:
</span>
<button onClick={() => handleCopy(curlExample)} className="text-zinc-400 hover:text-white text-[10px]">
{copiedKey ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
<pre className="p-2.5 bg-black/60 border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
{curlExample}
</pre>
</div>
{/* Data Export Button */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<div>
<h4 className="font-semibold text-white">Vollständiger Daten-Export</h4>
<p className="text-zinc-500 text-[11px]">Sichere alle Notizen, Aufgaben, Listen und Metadaten als JSON.</p>
</div>
<button onClick={handleExportData} className="btn-action h-8 px-3 text-xs flex items-center gap-1.5">
<Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span>
</button>
</div>
</div>
)}
{/* TAB 4: ORGANISATIONEN & TEAMS */}
{activeTab === 'teams' && (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-white mb-1">Organisationen & Teams gründen</h3>
<p className="text-zinc-400 text-xs">
Erstelle Organisationen und unterteile sie in Teams, um Aufgabenlisten und Workspace-Dokumente gemeinsam zu bearbeiten.
</p>
</div>
{/* Org Creator */}
<div className="p-3 bg-canvas border border-border space-y-2">
<h4 className="font-semibold text-white">Neue Organisation</h4>
<form onSubmit={handleCreateOrg} className="flex gap-2">
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Name der Organisation (z.B. Familie, Startup, Homelab Crew)…"
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs">
Organisation anlegen
</button>
</form>
</div>
{/* Team Creator inside Org */}
{orgs.length > 0 && (
<div className="p-3 bg-canvas border border-border space-y-2">
<h4 className="font-semibold text-white">Neues Team in Organisation</h4>
<form onSubmit={handleCreateTeam} className="space-y-2">
<div className="flex gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="h-8 px-2 input-field text-white bg-panel"
>
{orgs.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
<input
type="text"
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder="Team Name (z.B. DevOps, Einkauf)…"
required
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={newTeamDesc}
onChange={(e) => setNewTeamDesc(e.target.value)}
placeholder="Optionale Teambeschreibung…"
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs">
Team erstellen
</button>
</div>
</form>
</div>
)}
{/* Active Teams List */}
<div className="space-y-2">
<h4 className="font-semibold text-zinc-300">Deine Teams ({teams.length}):</h4>
<div className="border border-border divide-y divide-border bg-canvas">
{teams.length === 0 ? (
<div className="p-3 text-center text-zinc-500">Du bist noch keinem Team beigetreten.</div>
) : (
teams.map((t) => (
<div key={t.id} className="p-2.5 flex items-center justify-between">
<div>
<p className="font-bold text-white flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-accent" />
<span>{t.name}</span>
</p>
{t.description && <p className="text-zinc-400 text-[11px]">{t.description}</p>}
</div>
<span className="text-[10px] text-zinc-500 font-mono">ID: {t.id}</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* TAB 5: ADMIN BEREICH */}
{activeTab === 'admin' && currentUser?.role === 'admin' && (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-white mb-1">Server-Administration & Benutzersteuerung</h3>
<p className="text-zinc-400 text-xs">
Steuere die Instanz-Einstellungen, das Sharing-Verhalten und verwalte registrierte Benutzer.
</p>
</div>
{/* Server Sharing Isolation Setting */}
<div className="p-3 bg-canvas border border-border space-y-3">
<h4 className="font-semibold text-white flex items-center gap-1.5">
<Share2 className="w-3.5 h-3.5 text-accent" />
<span>Sharing-Isolationsmodus</span>
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => handleUpdateSystemSettings({ sharingMode: 'open' })}
className={`p-2.5 text-left border transition-colors ${
systemSettings.sharingMode === 'open'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Offen (Open)</p>
<p className="text-[10px] text-zinc-400 mt-0.5">Alle Benutzer können Spaces finden und beitreten.</p>
</button>
<button
type="button"
onClick={() => handleUpdateSystemSettings({ sharingMode: 'teams_only' })}
className={`p-2.5 text-left border transition-colors ${
systemSettings.sharingMode === 'teams_only'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Teams isoliert</p>
<p className="text-[10px] text-zinc-400 mt-0.5">Sharing ausschließlich innerhalb zugewiesener Teams.</p>
</button>
<button
type="button"
onClick={() => handleUpdateSystemSettings({ sharingMode: 'strict_private' })}
className={`p-2.5 text-left border transition-colors ${
systemSettings.sharingMode === 'strict_private'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Strikt Privat</p>
<p className="text-[10px] text-zinc-400 mt-0.5">Vollständige Benutzer-Isolation; kein Teilen möglich.</p>
</button>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border">
<span className="text-zinc-300">Öffentliche Registrierung erlauben:</span>
<button
type="button"
onClick={() =>
handleUpdateSystemSettings({ allowRegistration: !systemSettings.allowRegistration })
}
className={`px-3 py-1 text-xs border ${
systemSettings.allowRegistration
? 'bg-accent/20 border-accent text-accent'
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
}`}
>
{systemSettings.allowRegistration ? 'Aktiviert' : 'Deaktiviert'}
</button>
</div>
</div>
{/* User Creation by Admin */}
<div className="p-3 bg-canvas border border-border space-y-2">
<h4 className="font-semibold text-white">Neuen Benutzer anlegen</h4>
<form onSubmit={handleAdminCreateUser} className="space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
type="email"
value={newAdminUserEmail}
onChange={(e) => setNewAdminUserEmail(e.target.value)}
placeholder="E-Mail"
required
className="h-8 px-2.5 input-field text-white"
/>
<input
type="password"
value={newAdminUserPassword}
onChange={(e) => setNewAdminUserPassword(e.target.value)}
placeholder="Initial-Passwort"
required
className="h-8 px-2.5 input-field text-white"
/>
<div className="flex gap-2">
<input
type="text"
value={newAdminUserDisplayName}
onChange={(e) => setNewAdminUserDisplayName(e.target.value)}
placeholder="Anzeigename"
className="flex-1 h-8 px-2.5 input-field text-white"
/>
<select
value={newAdminUserRole}
onChange={(e) => setNewAdminUserRole(e.target.value as any)}
className="h-8 px-2 input-field text-white bg-panel"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Benutzer erstellen
</button>
</div>
</form>
</div>
{/* Users Table */}
<div className="space-y-2">
<h4 className="font-semibold text-zinc-300">Registrierte Benutzer ({adminUsers.length}):</h4>
<div className="border border-border divide-y divide-border bg-canvas overflow-x-auto">
{adminUsers.map((u) => (
<div key={u.id} className="p-2.5 flex items-center justify-between gap-3 text-xs">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-white">{u.displayName}</span>
<span
className={`px-1.5 py-0.2 text-[10px] uppercase font-bold border ${
u.role === 'admin'
? 'bg-accent/20 border-accent/40 text-accent'
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
}`}
>
{u.role}
</span>
{!u.isActive && (
<span className="px-1.5 py-0.2 text-[10px] bg-red-900/30 border border-red-500/40 text-red-300">
Deaktiviert
</span>
)}
</div>
<p className="text-zinc-500 text-[11px] font-mono">{u.email}</p>
{/* Reset password inline form */}
{resetPwdUserId === u.id && (
<div className="mt-2 flex items-center gap-1.5 bg-panel p-1.5 border border-border">
<input
type="password"
value={resetPwdVal}
onChange={(e) => setResetPwdVal(e.target.value)}
placeholder="Neues Passwort…"
className="h-6 px-2 input-field text-xs text-white"
/>
<button
onClick={() => handleConfirmResetPassword(u.id)}
className="btn-primary h-6 px-2 text-[10px]"
>
Speichern
</button>
<button
onClick={() => setResetPwdUserId(null)}
className="btn-action h-6 px-1.5 text-[10px]"
>
Abbrechen
</button>
</div>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => handleToggleUserActive(u)}
className="btn-action h-7 px-2 text-[11px]"
title="Aktiv / Deaktivieren"
>
{u.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button
onClick={() => handleToggleUserRole(u)}
className="btn-action h-7 px-2 text-[11px]"
title="Rolle wechseln"
>
{u.role === 'admin' ? 'Zu Member' : 'Zu Admin'}
</button>
<button
onClick={() => {
setResetPwdUserId(u.id);
setResetPwdVal('');
}}
className="btn-action h-7 px-2 text-[11px]"
title="Passwort zurücksetzen"
>
<Lock className="w-3 h-3 text-zinc-400" />
</button>
{u.id !== currentUser.id && (
<button
onClick={() => handleAdminDeleteUser(u.id)}
className="btn-icon btn-icon-danger"
title="Benutzer löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,726 @@
import React, { useEffect, useState } from 'react';
import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
Mail,
MessageSquare,
Globe,
Send,
AlertCircle,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface UserSettingsViewProps {
currentUser: User;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'alarms' | 'webhooks';
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
);
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState<number>(
currentUser.notifyBeforeMinutes ?? 30
);
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
);
};
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMsg(null);
setSavingProfile(true);
try {
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
}
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
} finally {
setSavingProfile(false);
}
};
// --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
setAlarmMsg(null);
setSavingAlarms(true);
try {
const updated = await api.updateSettings({
alarmChannels,
alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes),
});
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
} finally {
setSavingAlarms(false);
}
};
const handleTestChannel = async (channel: 'email' | 'discord' | 'webhook') => {
setAlarmMsg(null);
let target = '';
if (channel === 'email') {
target = (alarmEmail || currentUser.email || '').trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Empfänger-E-Mail-Adresse an.' });
return;
}
} else if (channel === 'discord') {
target = discordWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Discord Webhook URL ein.' });
return;
}
} else if (channel === 'webhook') {
target = customWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine eigene Webhook URL ein.' });
return;
}
}
setTestingChannel(channel);
try {
await api.testAlarmChannel(channel, target);
const label =
channel === 'email' ? 'E-Mail' : channel === 'discord' ? 'Discord' : 'Eigener Webhook';
setAlarmMsg({
type: 'ok',
text: `Test-Alarm über "${label}" wurde erfolgreich vom Server versendet!`,
});
} catch (err: any) {
setAlarmMsg({
type: 'err',
text: `Test fehlgeschlagen: ${err.message || 'Unbekannter Fehler'}`,
});
} finally {
setTestingChannel(null);
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 2000);
};
const handleExportData = async () => {
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder = newlyCreatedKey || apiKeys[0]?.keyPrefix + '••••••••' || 'DEIN_API_KEY';
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
: currentUser.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-white tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-zinc-400">
Persönliches Profil, Aufgaben-Alarme, Private Webhooks & Daten-Backup
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-zinc-300 font-medium">
Rolle: <strong className="text-accent">{roleBadgeLabel}</strong>
</span>
</div>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="bg-panel border border-border divide-y divide-border">
<button
onClick={() => setSection('profile')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'profile'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Profil & Sicherheit</p>
<p className="text-[10px] text-zinc-500">Name, E-Mail & Passwort</p>
</div>
</button>
<button
onClick={() => setSection('alarms')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'alarms'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Alarme</p>
<p className="text-[10px] text-zinc-500">E-Mail, Discord & Webhooks</p>
</div>
</button>
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'webhooks'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Private Webhooks & Export</p>
<p className="text-[10px] text-zinc-500">API-Keys, Curl & JSON-Backup</p>
</div>
</button>
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 bg-panel border border-border p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */}
{section === 'profile' && (
<div className="space-y-5 max-w-2xl">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Profil & Anmeldedaten</h2>
<p className="text-zinc-400 mt-0.5">
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
</p>
</div>
{profileMsg && (
<div
className={`p-3 border flex items-center gap-2 ${
profileMsg.type === 'ok'
? 'bg-blue-950/30 border-accent text-blue-200'
: 'bg-red-950/30 border-red-500 text-red-200'
}`}
>
{profileMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
)}
<span>{profileMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-zinc-300 font-medium mb-1">Anzeigename</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full h-9 px-3 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-300 font-medium mb-1">E-Mail-Adresse (Login)</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field text-white"
/>
</div>
</div>
<div>
<label className="block text-zinc-300 font-medium mb-1">
Neues Passwort (optional — leer lassen für keine Änderung)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
className="w-full h-9 px-3 input-field text-white"
/>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingProfile}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingProfile ? 'Speichere…' : 'Profil speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */}
{/* ================================================================= */}
{section === 'alarms' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Aufgaben-Alarme & Benachrichtigungsziele</h2>
<p className="text-zinc-400 mt-0.5">
Wähle aus, über welche Kanäle du benachrichtigt werden möchtest, sobald eine Aufgabe
mit Fälligkeitsdatum ansteht. Mehrere Ziele können gleichzeitig aktiv sein.
</p>
</div>
{alarmMsg && (
<div
className={`p-3 border flex items-center gap-2 ${
alarmMsg.type === 'ok'
? 'bg-blue-950/30 border-accent text-blue-200'
: 'bg-red-950/30 border-red-500 text-red-200'
}`}
>
{alarmMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
)}
<span>{alarmMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<label className="font-semibold text-white block">
Vorwarnzeit vor Fälligkeit
</label>
<p className="text-[11px] text-zinc-400">
Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen?
</p>
</div>
<select
value={notifyBeforeMinutes}
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
className="h-9 px-3 input-field text-white bg-panel min-w-[200px]"
>
<option value={0}>Pünktlich bei Fälligkeit (0 Min.)</option>
<option value={15}>15 Minuten vorher</option>
<option value={30}>30 Minuten vorher</option>
<option value={60}>1 Stunde vorher</option>
<option value={120}>2 Stunden vorher</option>
<option value={1440}>1 Tag vorher (24 Std.)</option>
</select>
</div>
{/* Channel 1: E-Mail */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('email')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">Ziel: E-Mail</span>
<span className="text-[11px] text-zinc-400">
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">
Abweichende Empfänger-E-Mail (optional, Standard: {currentUser.email})
</label>
<input
type="email"
value={alarmEmail}
onChange={(e) => setAlarmEmail(e.target.value)}
placeholder={currentUser.email}
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
{/* Channel 2: Discord Webhook */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('discord')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">Ziel: Discord Webhook</span>
<span className="text-[11px] text-zinc-400">
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen Discord-Kanal.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">Discord Webhook URL</label>
<input
type="url"
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/123456789/token..."
className="w-full h-8 px-2.5 input-field text-white font-mono"
/>
</div>
</div>
{/* Channel 3: Custom HTTP Webhook */}
<div
className={`p-4 border transition-colors space-y-3 ${
alarmChannels.includes('webhook')
? 'bg-canvas border-accent'
: 'bg-canvas/60 border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-white block">
Ziel: Eigener Webhook (HTTP POST JSON)
</span>
<span className="text-[11px] text-zinc-400">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder Automatisierungen.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-zinc-400 mb-1">Ziel-URL (empfängt JSON-Payload per POST)</label>
<input
type="url"
value={customWebhookUrl}
onChange={(e) => setCustomWebhookUrl(e.target.value)}
placeholder="https://dein-server.local/webhook/mindsift-alarm"
className="w-full h-8 px-2.5 input-field text-white font-mono"
/>
</div>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingAlarms}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingAlarms ? 'Speichere…' : 'Alarm-Einstellungen speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: PRIVATE WEBHOOKS & EXPORT */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Private Webhooks (Eingehend) & Daten-Export</h2>
<p className="text-zinc-400 mt-0.5">
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring oder
Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="flex-1 h-9 px-3 input-field text-white"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="p-3 bg-accent/15 border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im Klartext angezeigt):
</p>
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-zinc-500">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-white">{k.name}</p>
<p className="text-[11px] text-zinc-500 font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt: {new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Example */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-zinc-300 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 bg-canvas border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-white">Vollständiger Daten-Export (JSON Backup)</h3>
<p className="text-zinc-400 text-[11px]">
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable JSON-Datei herunter.
</p>
</div>
<button
onClick={handleExportData}
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
+20 -15
View File
@@ -60,15 +60,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
setLoading(true);
try {
const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([
api.getTasks(selectedListId === 'all' ? undefined : selectedListId),
api.getTaskLists(),
api.getSpaces(),
api.getThoughts('discarded'),
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getSpaces().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
setTasks(allTasks);
setTaskLists(allLists);
setSpaces(allSpaces);
setDiscarded(allDiscarded);
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
setSpaces(Array.isArray(allSpaces) ? allSpaces : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
setLoading(false);
}
@@ -190,14 +192,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const openTasks = tasks.filter((t) => !t.isCompleted);
const doneTasks = tasks.filter((t) => t.isCompleted);
const safeTasks = Array.isArray(tasks) ? tasks : [];
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const todoKanban = tasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
const inProgressKanban = tasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
const doneKanban = tasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
const openTasks = safeTasks.filter((t) => !t.isCompleted);
const doneTasks = safeTasks.filter((t) => t.isCompleted);
const currentSelectedList = taskLists.find((l) => l.id === selectedListId);
const todoKanban = safeTasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
const inProgressKanban = safeTasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
const doneKanban = safeTasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
return (
<div className="w-full space-y-3">
@@ -216,7 +221,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
Alle Aufgaben
</button>
{taskLists.map((tl) => (
{safeTaskLists.map((tl) => (
<div key={tl.id} className="relative group flex items-center">
<button
onClick={() => setSelectedListId(tl.id)}
+198 -55
View File
@@ -1,5 +1,6 @@
import {
APIKey,
AuthStatus,
DashboardStats,
Document,
DocumentBlock,
@@ -10,10 +11,10 @@ import {
Space,
SystemSettings,
Team,
TeamMember,
TaskList,
Thought,
User,
UserInvite,
} from './types';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
@@ -41,7 +42,11 @@ export function triggerHaptic(pattern: number | number[] = 15): void {
}
}
async function request<T>(path: string, options: RequestInit = {}, isRetry = false): Promise<T> {
function ensureArray<T>(val: T[] | null | undefined): T[] {
return Array.isArray(val) ? val : [];
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -54,7 +59,7 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
}
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
let res: Response;
try {
@@ -68,23 +73,15 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
window.clearTimeout(timeoutId);
}
if (res.status === 401 && !isRetry && !path.includes('/auth/login') && !path.includes('/auth/quickstart')) {
try {
const qsRes = await fetch('/api/v1/auth/quickstart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (qsRes.ok) {
const qsData = await qsRes.json();
if (qsData.token) {
setToken(qsData.token);
return request<T>(path, options, true);
}
}
} catch {
// ignore and fall through to standard error handling
}
if (
res.status === 401 &&
!path.includes('/auth/login') &&
!path.includes('/auth/register') &&
!path.includes('/auth/setup') &&
!path.includes('/auth/status')
) {
clearToken();
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
}
if (!res.ok) {
@@ -102,10 +99,23 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
}
export const api = {
// --- AUTH & SETTINGS ---
async quickStart(): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/quickstart', {
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
async getAuthStatus(): Promise<AuthStatus> {
return request<AuthStatus>('/api/v1/auth/status');
},
async completeSetup(payload: {
email: string;
password: string;
displayName: string;
instanceName?: string;
sharingMode?: 'open' | 'teams_only' | 'strict_private';
allowRegistration?: boolean;
createSampleData?: boolean;
}): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
method: 'POST',
body: JSON.stringify(payload),
});
setToken(res.token);
return res;
@@ -120,10 +130,15 @@ export const api = {
return res;
},
async register(email: string, password: string, displayName: string): Promise<{ token: string; user: User }> {
async register(
email: string,
password: string,
displayName: string,
inviteCode?: string
): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, displayName }),
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
});
setToken(res.token);
return res;
@@ -137,7 +152,10 @@ export const api = {
displayName?: string;
email?: string;
password?: string;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
}): Promise<User> {
return request<User>('/api/v1/auth/settings', {
@@ -146,10 +164,25 @@ export const api = {
});
},
async testAlarmChannel(
channel: 'email' | 'discord' | 'webhook',
target?: string
): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/auth/alarms/test', {
method: 'POST',
body: JSON.stringify({ channel, target: target || '' }),
});
},
async getStats(): Promise<DashboardStats> {
return request<DashboardStats>('/api/v1/stats');
},
async getUsersDirectory(): Promise<User[]> {
const res = await request<User[]>('/api/v1/users/directory');
return ensureArray(res);
},
// --- INSTANT CAPTURE & OFFLINE SYNC ---
async captureThought(
content: string,
@@ -176,7 +209,7 @@ export const api = {
}),
});
return thought;
} catch (err) {
} catch {
// Queue locally if offline
const queue = getOfflineQueue();
const offlineThought: Thought = {
@@ -229,11 +262,13 @@ export const api = {
// --- THOUGHTS, TRIAGE & TASKS ---
async getTriageDeck(): Promise<Thought[]> {
return request<Thought[]>('/api/v1/triage/deck');
const res = await request<Thought[]>('/api/v1/triage/deck');
return ensureArray(res);
},
async getThoughts(status = 'all'): Promise<Thought[]> {
return request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
return ensureArray(res);
},
async triageAction(
@@ -260,7 +295,8 @@ export const api = {
async getTasks(taskListId?: string): Promise<Thought[]> {
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
return request<Thought[]>(`/api/v1/tasks${query}`);
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
return ensureArray(res);
},
async updateThought(
@@ -291,7 +327,8 @@ export const api = {
// --- TASK LISTS ---
async getTaskLists(): Promise<TaskList[]> {
return request<TaskList[]>('/api/v1/task-lists');
const res = await request<TaskList[]>('/api/v1/task-lists');
return ensureArray(res);
},
async createTaskList(payload: {
@@ -327,11 +364,21 @@ export const api = {
// --- WORKSPACE DOCUMENTS ---
async getDocuments(): Promise<Document[]> {
return request<Document[]>('/api/v1/documents');
const res = await request<Document[]>('/api/v1/documents');
return ensureArray(res).map((d) => ({
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
}));
},
async getDocument(id: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`);
const d = await request<Document>(`/api/v1/documents/${id}`);
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
async createDocument(payload: {
@@ -342,10 +389,15 @@ export const api = {
tags?: string[];
isPinned?: boolean;
}): Promise<Document> {
return request<Document>('/api/v1/documents', {
const d = await request<Document>('/api/v1/documents', {
method: 'POST',
body: JSON.stringify(payload),
});
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
async updateDocument(
@@ -359,10 +411,15 @@ export const api = {
isPinned?: boolean;
}
): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`, {
const d = await request<Document>(`/api/v1/documents/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
async deleteDocument(id: string): Promise<void> {
@@ -371,7 +428,11 @@ export const api = {
// --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> {
return request<Space[]>('/api/v1/spaces');
const res = await request<Space[]>('/api/v1/spaces');
return ensureArray(res).map((s) => ({
...s,
members: ensureArray(s.members),
}));
},
async createSpace(name: string, icon: string, description: string): Promise<Space> {
@@ -389,18 +450,30 @@ export const api = {
},
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
return request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
}));
},
async getAllLists(): Promise<SharedList[]> {
return request<SharedList[]>('/api/v1/lists');
const res = await request<SharedList[]>('/api/v1/lists');
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
}));
},
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
return request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
method: 'POST',
body: JSON.stringify({ title, icon, listType }),
});
return {
...l,
items: ensureArray(l.items),
};
},
async deleteList(listId: string): Promise<void> {
@@ -433,7 +506,14 @@ export const api = {
// --- ORGANIZATIONS & TEAMS ---
async getOrganizations(): Promise<Organization[]> {
return request<Organization[]>('/api/v1/organizations');
const res = await request<Organization[]>('/api/v1/organizations');
return ensureArray(res).map((o) => ({
...o,
teams: ensureArray(o.teams).map((t) => ({
...t,
members: ensureArray(t.members),
})),
}));
},
async createOrganization(name: string): Promise<Organization> {
@@ -443,8 +523,13 @@ export const api = {
});
},
async deleteOrganization(id: string): Promise<void> {
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
},
async getOrgTeams(orgId: string): Promise<Team[]> {
return request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
@@ -454,14 +539,22 @@ export const api = {
});
},
async getUserTeams(): Promise<Team[]> {
return request<Team[]>('/api/v1/teams');
async deleteTeam(teamId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
},
async addTeamMember(teamId: string, userId: string, role = 'editor'): Promise<void> {
async getUserTeams(): Promise<Team[]> {
const res = await request<Team[]>('/api/v1/teams');
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async addTeamMember(
teamId: string,
payload: { userId?: string; email?: string; role: string }
): Promise<void> {
await request(`/api/v1/teams/${teamId}/members`, {
method: 'POST',
body: JSON.stringify({ userId, role }),
body: JSON.stringify(payload),
});
},
@@ -469,12 +562,18 @@ export const api = {
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
},
// --- ADMIN PANEL ---
// --- ADMIN & STAFF PANEL ---
async adminGetUsers(): Promise<User[]> {
return request<User[]>('/api/v1/admin/users');
const res = await request<User[]>('/api/v1/admin/users');
return ensureArray(res);
},
async adminCreateUser(payload: { email: string; password: string; displayName: string; role: string }): Promise<User> {
async adminCreateUser(payload: {
email: string;
password: string;
displayName: string;
role: string;
}): Promise<User> {
return request<User>('/api/v1/admin/users', {
method: 'POST',
body: JSON.stringify(payload),
@@ -483,7 +582,13 @@ export const api = {
async adminUpdateUser(
id: string,
patch: { displayName?: string; email?: string; role?: string; isActive?: boolean; discordWebhookUrl?: string }
patch: {
displayName?: string;
email?: string;
role?: string;
isActive?: boolean;
discordWebhookUrl?: string;
}
): Promise<User> {
return request<User>(`/api/v1/admin/users/${id}`, {
method: 'PATCH',
@@ -502,6 +607,26 @@ export const api = {
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
},
async adminGetInvites(): Promise<UserInvite[]> {
const res = await request<UserInvite[]>('/api/v1/admin/invites');
return ensureArray(res);
},
async adminCreateInvite(payload: {
email?: string;
role: string;
teamId?: string;
}): Promise<UserInvite> {
return request<UserInvite>('/api/v1/admin/invites', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async adminDeleteInvite(id: string): Promise<void> {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
},
async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings');
},
@@ -513,9 +638,17 @@ export const api = {
});
},
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/admin/smtp/test', {
method: 'POST',
body: JSON.stringify({ toEmail }),
});
},
// --- API KEYS, WEBHOOKS & EXPORT ---
async getAPIKeys(): Promise<APIKey[]> {
return request<APIKey[]>('/api/v1/keys');
const res = await request<APIKey[]>('/api/v1/keys');
return ensureArray(res);
},
async createAPIKey(name: string): Promise<APIKey> {
@@ -529,15 +662,25 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
async testWebhookIngest(token: string, title: string, priority: PriorityLevel, tags: string[]): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, {
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
});
async testWebhookIngest(
token: string,
title: string,
priority: PriorityLevel,
tags: string[]
): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
{
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
}
);
},
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
return request<{ thoughts: Thought[]; documents: Document[] }>(`/api/v1/search?q=${encodeURIComponent(q)}`);
return request<{ thoughts: Thought[]; documents: Document[] }>(
`/api/v1/search?q=${encodeURIComponent(q)}`
);
},
async exportAll(): Promise<any> {
+32 -2
View File
@@ -1,17 +1,39 @@
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
export type UserRole = 'admin' | 'team_admin' | 'member';
export interface AuthStatus {
needsSetup: boolean;
allowRegistration: boolean;
instanceName: string;
}
export interface User {
id: string;
email: string;
displayName: string;
role: 'admin' | 'member';
role: UserRole;
isActive: boolean;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
createdAt: string;
}
export interface UserInvite {
id: string;
code: string;
email?: string;
role: UserRole;
teamId?: string;
createdBy: string;
usedBy?: string;
createdAt: string;
expiresAt: string;
}
export interface Thought {
id: string;
userId: string;
@@ -48,6 +70,7 @@ export interface Organization {
id: string;
name: string;
ownerId: string;
teams?: Team[];
createdAt: string;
}
@@ -65,7 +88,7 @@ export interface TeamMember {
userId: string;
displayName: string;
email: string;
role: 'owner' | 'editor' | 'viewer';
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
joinedAt: string;
}
@@ -73,6 +96,13 @@ export interface SystemSettings {
sharingMode: 'open' | 'teams_only' | 'strict_private';
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpConfiguredViaEnv?: boolean;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
+602 -81
View File
@@ -2,10 +2,12 @@ package api
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/smtp"
"strings"
"time"
@@ -33,7 +35,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
wsHub: wsHub,
staticFS: staticFS,
}
go srv.startDiscordAlarmTicker()
go srv.startAlarmTicker()
return srv
}
@@ -51,8 +53,10 @@ func (s *Server) Router() http.Handler {
MaxAge: 300,
}))
// Public Health & Webhook Routes
// Public Health, Setup & Auth Routes
r.Get("/api/v1/health", s.handleHealth)
r.Get("/api/v1/auth/status", s.handleAuthStatus)
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
r.Post("/api/v1/auth/register", s.handleRegister)
r.Post("/api/v1/auth/login", s.handleLogin)
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
@@ -65,6 +69,8 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/me", s.handleMe)
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
@@ -111,9 +117,11 @@ func (s *Server) Router() http.Handler {
// Organizations & Teams
r.Get("/api/v1/organizations", s.handleGetOrganizations)
r.Post("/api/v1/organizations", s.handleCreateOrganization)
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
r.Get("/api/v1/teams", s.handleGetUserTeams)
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
@@ -122,16 +130,26 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/keys", s.handleCreateAPIKey)
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
// Admin Panel Routes
// Staff Routes (Server-Admin & Team-Admin)
r.Group(func(r chi.Router) {
r.Use(s.adminMiddleware)
r.Use(s.staffMiddleware)
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
})
// Server-Admin Strictly Protected Routes
r.Group(func(r chi.Router) {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
})
})
@@ -144,12 +162,10 @@ func (s *Server) Router() http.Handler {
path = "index.html"
}
if _, err := fs.Stat(s.staticFS, path); err != nil {
// Never return index.html for missing JS/CSS assets
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
http.NotFound(w, req)
return
}
// SPA client-side routing fallback to index.html
req.URL.Path = "/"
path = "index.html"
}
@@ -186,21 +202,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
if err == nil && user != nil {
if err == nil && user != nil && user.IsActive {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
if err == nil && claims != nil {
userID := claims.UserID
if _, userErr := s.store.GetUserByID(userID); userErr != nil {
if defUser, defErr := s.store.EnsureDefaultUser(); defErr == nil && defUser != nil {
userID = defUser.ID
}
user, userErr := s.store.GetUserByID(claims.UserID)
if userErr == nil && user != nil && user.IsActive {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID)))
return
}
}
@@ -208,13 +221,29 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
apiKey := r.Header.Get("X-API-Key")
if apiKey != "" {
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
if err == nil && user != nil {
if err == nil && user != nil && user.IsActive {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen")
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
})
}
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
return
}
next.ServeHTTP(w, r)
})
}
@@ -227,7 +256,7 @@ func (s *Server) adminMiddleware(next http.Handler) http.Handler {
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "Administrator-Rechte erforderlich")
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
return
}
next.ServeHTTP(w, r)
@@ -244,20 +273,98 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
sysSettings, _ := s.store.GetSystemSettings()
if !s.cfg.AllowRegistration || (sysSettings != nil && !sysSettings.AllowRegistration) {
count, _ := s.store.CountUsers()
if count > 0 {
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
return
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
sys, _ := s.store.GetSystemSettings()
allowReg := false
instanceName := "MindSift"
if sys != nil {
allowReg = sys.AllowRegistration
if sys.InstanceName != "" {
instanceName = sys.InstanceName
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
})
}
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
count, err := s.store.CountUsers()
if err != nil || count > 0 {
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InstanceName string `json:"instanceName"`
AllowRegistration bool `json:"allowRegistration"`
SharingMode string `json:"sharingMode"`
SeedSampleData bool `json:"seedSampleData"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = "Administrator"
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sys, _ := s.store.GetSystemSettings()
if sys != nil {
if strings.TrimSpace(req.InstanceName) != "" {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
sys.SharingMode = req.SharingMode
}
_ = s.store.UpdateSystemSettings(sys)
}
if req.SeedSampleData {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
@@ -271,25 +378,50 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
count, _ := s.store.CountUsers()
role := "member"
var validInvite *domain.UserInvite
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
return
}
validInvite = inv
role = inv.Role
} else if count == 0 {
role = "admin"
} else {
sysSettings, _ := s.store.GetSystemSettings()
if sysSettings == nil || !sysSettings.AllowRegistration {
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
return
}
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
count, _ := s.store.CountUsers()
role := "member"
if count == 0 {
role = "admin"
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
_ = s.store.SeedStarterContent(user)
if validInvite != nil {
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
if validInvite.TeamID != "" {
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
@@ -336,22 +468,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
user, err := s.store.EnsureDefaultUser()
if err != nil {
writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token-Fehler")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
@@ -984,16 +1101,19 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, exportData)
}
// --- USER SETTINGS ---
// --- USER SETTINGS & ALARMS ---
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Password *string `json:"password"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Password *string `json:"password"`
AlarmChannels *[]string `json:"alarmChannels"`
AlarmEmail *string `json:"alarmEmail"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -1007,7 +1127,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
}
_ = s.store.UpdateUserPassword(userID, hash)
}
u, err := s.store.UpdateUser(userID, req.DisplayName, req.Email, nil, nil, req.DiscordWebhookURL, req.NotifyBeforeMinutes)
u, err := s.store.UpdateUserFull(
userID,
req.DisplayName,
req.Email,
nil,
nil,
req.AlarmChannels,
req.AlarmEmail,
req.DiscordWebhookURL,
req.CustomWebhookURL,
req.NotifyBeforeMinutes,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -1015,6 +1146,86 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
var req struct {
Channel string `json:"channel"` // email | discord | webhook
Target string `json:"target"` // optional override URL or email
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
channel := strings.ToLower(strings.TrimSpace(req.Channel))
target := strings.TrimSpace(req.Target)
switch channel {
case "discord":
url := target
if url == "" {
url = user.DiscordWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
return
}
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
return
}
case "webhook":
url := target
if url == "" {
url = user.CustomWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
return
}
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
return
}
case "email":
toEmail := target
if toEmail == "" {
toEmail = user.AlarmEmail
}
if toEmail == "" {
toEmail = user.Email
}
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"channel": channel,
})
}
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetDiscoverableUsers(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
// --- TASK LISTS ---
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
@@ -1107,6 +1318,15 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
if err := s.store.DeleteOrganization(orgID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
teams, err := s.store.GetTeamsForOrg(orgID)
@@ -1118,6 +1338,7 @@ func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgID := chi.URLParam(r, "id")
var req struct {
Name string `json:"name"`
@@ -1132,9 +1353,21 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id")
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teams, err := s.store.GetTeamsForUser(userID)
@@ -1149,17 +1382,32 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.AddTeamMember(teamID, req.UserID, req.Role); err != nil {
targetUserID := strings.TrimSpace(req.UserID)
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
u, err := s.store.GetUserByEmail(req.Email)
if err != nil || u == nil {
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
return
}
targetUserID = u.ID
}
if targetUserID == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"added": true})
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
@@ -1172,7 +1420,7 @@ func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
}
// --- ADMIN PANEL ---
// --- ADMIN & STAFF PANEL ---
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
@@ -1184,6 +1432,9 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Password string `json:"password"`
@@ -1198,6 +1449,14 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
return
}
// Only full server admin can create another server admin
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
@@ -1212,7 +1471,21 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, err := s.store.GetUserByID(targetUserID)
if err != nil || targetUser == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
// Team-Admins cannot modify Server-Admins
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
return
}
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
@@ -1224,6 +1497,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
return
}
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -1233,7 +1511,15 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung")
return
}
var req struct {
Password string `json:"password"`
}
@@ -1260,6 +1546,12 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
return
}
caller, _ := s.store.GetUserByID(currUserID)
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
return
}
if err := s.store.DeleteUser(targetUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -1267,12 +1559,60 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- INVITES MANAGEMENT ---
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.store.GetUserInvites()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Role string `json:"role"`
TeamID string `json:"teamId"`
ValidDays int `json:"validDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
inviteID := chi.URLParam(r, "id")
if err := s.store.DeleteUserInvite(inviteID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- SERVER SETTINGS & SMTP ---
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSystemSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
@@ -1286,17 +1626,180 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
// --- DISCORD ALARMS BACKGROUND SCHEDULER ---
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
func (s *Server) startDiscordAlarmTicker() {
var req struct {
ToEmail string `json:"toEmail"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
toEmail := strings.TrimSpace(req.ToEmail)
if toEmail == "" && user != nil {
toEmail = user.Email
}
if toEmail == "" {
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
return
}
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"toEmail": toEmail,
})
}
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
sys, _ := s.store.GetSystemSettings()
host := ""
port := "587"
user := ""
pass := ""
from := ""
useTLS := true
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
host = strings.TrimSpace(sys.SMTPHost)
port = strings.TrimSpace(sys.SMTPPort)
user = strings.TrimSpace(sys.SMTPUser)
pass = sys.SMTPPass
from = strings.TrimSpace(sys.SMTPFrom)
useTLS = sys.SMTPTLS
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
host = strings.TrimSpace(s.cfg.SMTPHost)
port = strings.TrimSpace(s.cfg.SMTPPort)
user = strings.TrimSpace(s.cfg.SMTPUser)
pass = s.cfg.SMTPPass
from = strings.TrimSpace(s.cfg.SMTPFrom)
useTLS = s.cfg.SMTPTLS
}
if host == "" {
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if port == "" {
port = "587"
}
if from == "" {
if user != "" && strings.Contains(user, "@") {
from = user
} else {
from = "noreply@mindsift.local"
}
}
addr := fmt.Sprintf("%s:%s", host, port)
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
var smtpAuth smtp.Auth
if user != "" || pass != "" {
smtpAuth = smtp.PlainAuth("", user, pass, host)
}
if port == "465" && useTLS {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Quit()
if smtpAuth != nil {
if err := client.Auth(smtpAuth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(toEmail); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
_, err = wc.Write([]byte(msg))
_ = wc.Close()
return err
}
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
httpClient := &http.Client{Timeout: 10 * time.Second}
payload := map[string]interface{}{
"content": "⏰ **MindSift Aufgaben-Alarm**",
"embeds": []map[string]interface{}{
{
"title": title,
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
"color": 2450411,
"footer": map[string]string{
"text": "MindSift Alarm-System",
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
httpClient := &http.Client{Timeout: 10 * time.Second}
payload := map[string]interface{}{
"event": event,
"title": title,
"notes": notes,
"dueDate": dueDate,
"priority": priority,
"timestamp": domain.NowISO(),
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) startAlarmTicker() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
httpClient := &http.Client{Timeout: 10 * time.Second}
for range ticker.C {
alarms, err := s.store.GetDueTasksForAlarm()
if err != nil || len(alarms) == 0 {
@@ -1306,29 +1809,47 @@ func (s *Server) startDiscordAlarmTicker() {
today := time.Now().Format("2006-01-02")
for _, a := range alarms {
if a.DueDate <= today && a.DiscordWebhookURL != "" {
payload := map[string]interface{}{
"content": fmt.Sprintf("⏰ **MindSift Aufgaben-Erinnerung**"),
"embeds": []map[string]interface{}{
{
"title": a.Title,
"description": fmt.Sprintf("Diese Aufgabe ist fällig am **%s** (Priorität: %s)", a.DueDate, a.Priority),
"color": 3066993,
"footer": map[string]string{
"text": "MindSift Reminder",
},
},
},
}
body, mErr := json.Marshal(payload)
if mErr == nil {
resp, pErr := httpClient.Post(a.DiscordWebhookURL, "application/json", bytes.NewReader(body))
if pErr == nil {
_ = resp.Body.Close()
_ = s.store.MarkTaskAlarmSent(a.TaskID)
if a.DueDate > today {
continue
}
if len(a.AlarmChannels) == 0 {
continue
}
sentAny := false
desc := a.Notes
if desc == "" {
desc = "Aufgabe ist jetzt fällig."
}
for _, ch := range a.AlarmChannels {
switch ch {
case "discord":
if a.DiscordWebhookURL != "" {
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "email":
if a.AlarmEmail != "" {
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
sentAny = true
}
}
}
}
if sentAny {
_ = s.store.MarkTaskAlarmSent(a.TaskID)
}
}
}
}
+162 -18
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
@@ -29,14 +30,15 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("failed to ensure default user: %v", err)
t.Fatalf("failed to ensure test user: %v", err)
}
_ = store.SeedStarterContent(user)
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: true,
AllowRegistration: false,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
@@ -49,6 +51,137 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
return srv, store, token
}
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create fresh store: %v", err)
}
defer store.Close()
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Fresh DB must report needsSetup = 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"`
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=true on fresh database, got false")
}
// 2. Complete First-Boot Setup (without sample data)
setupBody := []byte(`{
"email": "owner@example.com",
"password": "secretpassword",
"displayName": "Server Owner",
"instanceName": "My MindSift",
"allowRegistration": false,
"sharingMode": "teams_only",
"seedSampleData": false
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String())
}
var setupRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
if setupRes.User.Role != "admin" || setupRes.Token == "" {
t.Fatalf("expected admin user and token, got %+v", setupRes)
}
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
arrayEndpoints := []string{
"/api/v1/task-lists",
"/api/v1/tasks",
"/api/v1/organizations",
"/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists",
}
for _, ep := range arrayEndpoints {
req = httptest.NewRequest(http.MethodGet, ep, nil)
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", ep, rec.Code)
}
trimmed := strings.TrimSpace(rec.Body.String())
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
}
}
// 4. Public registration without invite must be rejected (403)
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
}
// 5. Create an Invite as Admin and register with that Invite Code
invBody := []byte(`{"role":"team_admin","validDays":7}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
}
var createdInvite domain.UserInvite
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
if createdInvite.Code == "" {
t.Fatalf("expected invite code, got empty")
}
regWithInviteBody, _ := json.Marshal(map[string]string{
"email": "invited@example.com",
"password": "password123",
"displayName": "Invited Team Admin",
"inviteCode": createdInvite.Code,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
}
var invitedRes struct {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
if invitedRes.User.Role != "team_admin" {
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
}
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
@@ -81,27 +214,31 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
wg.Wait()
}
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, _ := setupTestServer(t)
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
// Generate a stale token from a previous container run (non-existent user ID)
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
if err != nil {
t.Fatalf("failed to generate stale token: %v", err)
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
}
// 1. Capture Quick Dump (default target: inbox) using stale token
// 1. Capture Quick Dump (default target: inbox)
dumpPayload := map[string]string{
"content": "Schneller Quick Dump Testgedanke #test",
"priority": "high",
"target": "inbox",
}
body, _ := json.Marshal(dumpPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
@@ -125,7 +262,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(taskPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -154,7 +291,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(listPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -175,7 +312,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -219,10 +356,13 @@ func TestNewFullFeatures(t *testing.T) {
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
}
// 2. Test User Settings: Update Discord Webhook & Profile
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
settingsPayload := map[string]interface{}{
"displayName": "Sandro D.",
"displayName": "Admin User",
"alarmChannels": []string{"email", "discord", "webhook"},
"alarmEmail": "alarms@example.com",
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
"customWebhookUrl": "https://ntfy.sh/my-topic",
"notifyBeforeMinutes": 15,
}
body, _ = json.Marshal(settingsPayload)
@@ -235,6 +375,11 @@ func TestNewFullFeatures(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
}
var updatedUser domain.User
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
}
// 3. Test Admin routes (default user is admin)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
@@ -261,7 +406,6 @@ func TestNewFullFeatures(t *testing.T) {
"notes": "Detailnotizen für die Aufgabe",
"taskListId": createdTaskList.ID,
}
body, _ = json.Marshal(taskUpdatePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
+22 -1
View File
@@ -14,6 +14,14 @@ type Config struct {
BaseURL string
AllowRegistration bool
LogLevel string
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
SMTPFrom string
SMTPTLS bool
}
func Load() *Config {
@@ -30,9 +38,16 @@ func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false"
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info")
smtpHost := getEnv("SMTP_HOST", "")
smtpPort := getEnv("SMTP_PORT", "587")
smtpUser := getEnv("SMTP_USER", "")
smtpPass := getEnv("SMTP_PASS", "")
smtpFrom := getEnv("SMTP_FROM", "")
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
return &Config{
Port: port,
DataDir: dataDir,
@@ -41,6 +56,12 @@ func Load() *Config {
BaseURL: baseURL,
AllowRegistration: allowReg,
LogLevel: logLevel,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
}
}
+39 -15
View File
@@ -41,15 +41,31 @@ func ExtractTags(content string) []string {
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"`
DiscordWebhookURL string `json:"discordWebhookUrl"`
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | team_admin | member
IsActive bool `json:"isActive"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"`
CustomWebhookURL string `json:"customWebhookUrl"`
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
CreatedAt string `json:"createdAt"`
}
type UserInvite struct {
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | team_admin | admin
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
CreatedBy string `json:"createdBy"`
ExpiresAt string `json:"expiresAt"`
UsedBy string `json:"usedBy,omitempty"`
CreatedAt string `json:"createdAt"`
}
type ThoughtStatus string
@@ -97,6 +113,7 @@ type Organization struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"ownerId"`
Teams []Team `json:"teams,omitempty"`
CreatedAt string `json:"createdAt"`
}
@@ -105,7 +122,7 @@ type Team struct {
OrgID string `json:"orgId"`
Name string `json:"name"`
Description string `json:"description"`
Members []TeamMember `json:"members,omitempty"`
Members []TeamMember `json:"members"`
CreatedAt string `json:"createdAt"`
}
@@ -114,14 +131,21 @@ type TeamMember struct {
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // owner | editor | viewer
Role string `json:"role"` // team_admin | member | owner
JoinedAt string `json:"joinedAt"`
}
type SystemSettings struct {
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
}
type DocumentBlock struct {
@@ -165,7 +189,7 @@ type Space struct {
InviteCode string `json:"inviteCode"`
IsPersonal bool `json:"isPersonal"`
OwnerID string `json:"ownerId"`
Members []SpaceMember `json:"members,omitempty"`
Members []SpaceMember `json:"members"`
ListsCount int `json:"listsCount"`
CreatedAt string `json:"createdAt"`
}
+404 -119
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
@@ -71,11 +72,26 @@ func (s *Store) migrate() error {
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
is_active INTEGER NOT NULL DEFAULT 1,
alarm_channels TEXT NOT NULL DEFAULT 'discord',
alarm_email TEXT NOT NULL DEFAULT '',
discord_webhook_url TEXT NOT NULL DEFAULT '',
custom_webhook_url TEXT NOT NULL DEFAULT '',
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_invites (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'member',
team_id TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS thoughts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -237,7 +253,10 @@ func (s *Store) migrate() error {
columnMigrations := []string{
"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';",
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;",
"ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';",
@@ -251,20 +270,35 @@ func (s *Store) migrate() error {
_, _ = s.db.Exec(q)
}
// Default system settings
// 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)
}
// 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
('sharing_mode', 'teams_only'),
('allow_registration', 'true'),
('instance_name', 'MindSift')`)
('allow_registration', 'false'),
('instance_name', 'MindSift'),
('smtp_host', ''),
('smtp_port', '587'),
('smtp_user', ''),
('smtp_pass', ''),
('smtp_from', ''),
('smtp_tls', 'true')`)
return nil
}
// SeedStarterContent populates realistic initial content for a newly created user
// SeedStarterContent populates realistic initial content for a newly created user if requested
func (s *Store) SeedStarterContent(user *domain.User) error {
now := domain.NowISO()
// 1. Starter Thoughts in Inbox for Tinder-Style Triage
starterThoughts := []struct {
content string
priority string
@@ -286,80 +320,37 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
priority: "urgent",
source: "webhook",
},
{
content: "Buch-Empfehlung aus dem Podcast: 'The Pragmatic Programmer' nochmal lesen #lesen",
priority: "low",
source: "manual",
},
{
content: "Steuerunterlagen für Q3 an den Steuerberater schicken #finanzen #büro",
priority: "high",
source: "manual",
},
}
for _, st := range starterThoughts {
_, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate)
}
// Create one already kept Task with due date
keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10])
if err == nil && keptTask != nil {
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
}
// 2. Create Shared Family Space & Shopping List
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
if err == nil && familySpace != nil {
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil {
items := []struct {
content string
category string
quantity string
done bool
}{
{"Hafermilch (Barista Edition)", "Kühlregal", "3x", false},
{"San Marzano Dosentomaten", "Konserven", "2 Dosen", false},
{"Frischer Basilikum & Knoblauch", "Obst & Gemüse", "1 Bund", false},
{"Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", false},
{"Spülmaschinentabs", "Drogerie", "1 Packung", true},
}
for idx, it := range items {
created, err := s.CreateListItem(shopList.ID, it.content, it.category, it.quantity, idx)
if err == nil && it.done && created != nil {
_, _ = s.ToggleListItem(created.ID, true, user.ID, user.DisplayName)
}
}
}
vacationList, err := s.CreateList(familySpace.ID, "Packliste Südtirol-Trip", "🏔️", "vacation")
if err == nil && vacationList != nil {
_, _ = s.CreateListItem(vacationList.ID, "Wanderschuhe imprägnieren", "Ausrüstung", "", 0)
_, _ = s.CreateListItem(vacationList.ID, "Powerbank & Ladekabel", "Technik", "2x", 1)
_, _ = s.CreateListItem(vacationList.ID, "Vignette Österreich online kaufen", "Dokumente", "", 2)
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
}
}
// 3. Starter Workspace Document (Notion-Flair)
starterBlocks := []domain.DocumentBlock{
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran – ganz ohne Datenbank-Ballast."},
{ID: "blk_2", Type: "h2", Content: "Wie funktioniert der Workflow?"},
{ID: "blk_3", Type: "bullet", Content: "1. Capture First: Drücke jederzeit 'C' oder '/' und tippe oder diktiere deinen Gedanken in unter 3 Sekunden."},
{ID: "blk_4", Type: "bullet", Content: "2. Tinder-Style Triage: Wische Karten im Triage-Deck nach links (Verwerfen) oder rechts (Behalten / Aufgabe / Dokument)."},
{ID: "blk_5", Type: "bullet", Content: "3. Echtzeit-Listen: Teile deinen Space per Invite-Code mit Partner oder Familie. Abgehakte Einkäufe synchronisieren sich sofort via WebSocket."},
{ID: "blk_6", Type: "h2", Content: "Projekt-Checkliste: Homelab Upgrade"},
{ID: "blk_7", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
{ID: "blk_8", Type: "todo", Content: "Home Assistant Webhook für Rauchmelder & Luftfeuchtigkeit anbinden", Checked: false},
{ID: "blk_9", Type: "todo", Content: "PWA auf dem Smartphone-Homescreen installieren", Checked: false},
{ID: "blk_10", Type: "code", Content: "curl -X POST http://localhost:8080/api/v1/webhooks/ingest?token=DEIN_KEY \\\n -H 'Content-Type: application/json' \\\n -d '{\"title\": \"Waschmaschine im Keller ist fertig!\", \"priority\": \"high\", \"tags\": [\"smarthome\"]}'", Lang: "bash"},
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
{ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"},
{ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
}
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Homelab Notizen", "🚀", "", starterBlocks, []string{"homelab", "mindsift", "workflow"}, true)
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true)
return nil
}
// EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in
// EnsureDefaultUser is retained strictly for unit test convenience
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
s.initMu.Lock()
defer s.initMu.Unlock()
@@ -376,22 +367,39 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
if err != nil {
return nil, err
}
user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro", "admin")
user, err := s.CreateUser("test@mindsift.local", hash, "Admin", "admin")
if err != nil {
return s.GetFirstUser()
}
_ = s.SeedStarterContent(user)
return user, nil
}
// --- 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`
func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",")
res := make([]string, 0, len(parts))
seen := make(map[string]bool)
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
seen[p] = true
res = append(res, p)
}
}
return res
}
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
u := &domain.User{}
var isActiveInt int
var channelsRaw string
err := row.Scan(
&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.DiscordWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
)
if err != nil {
return nil, err
@@ -400,6 +408,10 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
if u.Role == "" {
u.Role = "member"
}
u.AlarmChannels = parseAlarmChannels(channelsRaw)
if u.AlarmEmail == "" {
u.AlarmEmail = u.Email
}
return u, nil
}
@@ -410,24 +422,28 @@ func (s *Store) CountUsers() (int, error) {
}
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
if role == "" {
if role != "admin" && role != "team_admin" && role != "member" {
role = "member"
}
cleanEmail := strings.ToLower(strings.TrimSpace(email))
u := &domain.User{
ID: domain.NewID("usr"),
Email: strings.ToLower(strings.TrimSpace(email)),
Email: cleanEmail,
PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
AlarmChannels: []string{},
AlarmEmail: cleanEmail,
DiscordWebhookURL: "",
CustomWebhookURL: "",
NotifyBeforeMinutes: 30,
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at)
VALUES (?, ?, ?, ?, ?, 1, '', 30, ?)`,
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.CreatedAt,
`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,
)
if err != nil {
return nil, err
@@ -437,8 +453,7 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users WHERE email = ?`,
`SELECT `+userSelectCols+` FROM users WHERE email = ?`,
strings.ToLower(strings.TrimSpace(email)),
)
return scanUser(row)
@@ -446,8 +461,7 @@ func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users WHERE id = ?`,
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
id,
)
return scanUser(row)
@@ -455,16 +469,14 @@ func (s *Store) GetUserByID(id string) (*domain.User, error) {
func (s *Store) GetFirstUser() (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users ORDER BY created_at ASC LIMIT 1`,
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
)
return scanUser(row)
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users ORDER BY created_at ASC`,
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
)
if err != nil {
return nil, err
@@ -479,7 +491,64 @@ func (s *Store) GetAllUsers() ([]domain.User, error) {
return users, nil
}
func (s *Store) UpdateUser(userID string, displayName, email, role *string, isActive *bool, discordWebhookURL *string, notifyBeforeMinutes *int) (*domain.User, error) {
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
viewer, err := s.GetUserByID(viewerID)
if err != nil {
return []domain.User{}, nil
}
sys, _ := s.GetSystemSettings()
mode := "teams_only"
if sys != nil && sys.SharingMode != "" {
mode = sys.SharingMode
}
if viewer.Role == "admin" || viewer.Role == "team_admin" || mode == "open" {
return s.GetAllUsers()
}
if mode == "strict_private" {
return []domain.User{*viewer}, nil
}
// teams_only: users sharing at least one team or organization with viewer
rows, err := s.db.Query(
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
SELECT tm2.user_id FROM team_members tm1
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
WHERE tm1.user_id = ?
)`,
viewerID, viewerID,
)
if err != nil {
return []domain.User{*viewer}, nil
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(
userID string,
displayName, email, role *string,
isActive *bool,
discordWebhookURL *string,
notifyBeforeMinutes *int,
) (*domain.User, error) {
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes)
}
func (s *Store) UpdateUserFull(
userID string,
displayName, email, role *string,
isActive *bool,
alarmChannels *[]string,
alarmEmail, discordWebhookURL, customWebhookURL *string,
notifyBeforeMinutes *int,
) (*domain.User, error) {
u, err := s.GetUserByID(userID)
if err != nil {
return nil, err
@@ -490,15 +559,24 @@ func (s *Store) UpdateUser(userID string, displayName, email, role *string, isAc
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "member") {
if role != nil && (*role == "admin" || *role == "team_admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
u.IsActive = *isActive
}
if alarmChannels != nil {
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
}
if alarmEmail != nil {
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
}
if discordWebhookURL != nil {
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
}
if customWebhookURL != nil {
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
}
if notifyBeforeMinutes != nil {
u.NotifyBeforeMinutes = *notifyBeforeMinutes
}
@@ -506,9 +584,10 @@ func (s *Store) UpdateUser(userID string, displayName, email, role *string, isAc
if u.IsActive {
activeInt = 1
}
channelsStr := strings.Join(u.AlarmChannels, ",")
_, err = s.db.Exec(
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, discord_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.DiscordWebhookURL, u.NotifyBeforeMinutes, userID,
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID,
)
if err != nil {
return nil, err
@@ -1591,18 +1670,24 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
userID, userID,
)
if err != nil {
return nil, err
return []domain.TaskList{}, err
}
defer rows.Close()
var list []domain.TaskList
list := make([]domain.TaskList, 0)
for rows.Next() {
var tl domain.TaskList
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
list = append(list, tl)
}
}
_ = rows.Close()
for i := range list {
_ = s.db.QueryRow(
`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`,
list[i].ID,
).Scan(&list[i].TasksCount)
}
return list, nil
}
@@ -1665,6 +1750,7 @@ func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization,
ID: domain.NewID("org"),
Name: name,
OwnerID: ownerID,
Teams: []domain.Team{},
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
@@ -1677,28 +1763,49 @@ func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization,
return org, nil
}
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
rows, err := s.db.Query(
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
FROM organizations o
LEFT JOIN teams t ON t.org_id = o.id
LEFT JOIN team_members tm ON tm.team_id = t.id
WHERE o.owner_id = ? OR tm.user_id = ?
ORDER BY o.created_at ASC`,
userID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
func (s *Store) DeleteOrganization(orgID string) error {
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
return err
}
var orgs []domain.Organization
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
viewer, _ := s.GetUserByID(userID)
var rows *sql.Rows
var err error
if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") {
rows, err = s.db.Query(
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
)
} else {
rows, err = s.db.Query(
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
FROM organizations o
LEFT JOIN teams t ON t.org_id = o.id
LEFT JOIN team_members tm ON tm.team_id = t.id
WHERE o.owner_id = ? OR tm.user_id = ?
ORDER BY o.created_at ASC`,
userID, userID,
)
}
if err != nil {
return []domain.Organization{}, err
}
orgs := make([]domain.Organization, 0)
for rows.Next() {
var o domain.Organization
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
o.Teams = []domain.Team{}
orgs = append(orgs, o)
}
}
_ = rows.Close()
for i := range orgs {
teams, _ := s.GetTeamsForOrg(orgs[i].ID)
orgs[i].Teams = teams
}
return orgs, nil
}
@@ -1712,6 +1819,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
OrgID: orgID,
Name: name,
Description: strings.TrimSpace(description),
Members: []domain.TeamMember{},
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
@@ -1724,24 +1832,34 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
return team, nil
}
func (s *Store) DeleteTeam(teamID string) error {
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
return err
}
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
orgID,
)
if err != nil {
return nil, err
return []domain.Team{}, err
}
defer rows.Close()
var teams []domain.Team
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members, _ = s.GetTeamMembers(t.ID)
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
@@ -1755,24 +1873,29 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
userID,
)
if err != nil {
return nil, err
return []domain.Team{}, err
}
defer rows.Close()
var teams []domain.Team
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members, _ = s.GetTeamMembers(t.ID)
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) AddTeamMember(teamID, userID, role string) error {
if role == "" {
role = "editor"
role = "member"
}
_, err := s.db.Exec(
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
@@ -1797,11 +1920,11 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
teamID,
)
if err != nil {
return nil, err
return []domain.TeamMember{}, err
}
defer rows.Close()
var members []domain.TeamMember
members := make([]domain.TeamMember, 0)
for rows.Next() {
var m domain.TeamMember
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
@@ -1811,13 +1934,118 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
return members, nil
}
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "team_admin" && role != "member" {
role = "member"
}
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
TeamID: strings.TrimSpace(teamID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
}
_, err := s.db.Exec(
`INSERT INTO user_invites (id, code, email, role, team_id, created_by, expires_at, used_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
)
if err != nil {
return nil, err
}
return inv, nil
}
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
rows, err := s.db.Query(
`SELECT i.id, i.code, i.email, i.role, i.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
FROM user_invites i
LEFT JOIN teams t ON t.id = i.team_id
ORDER BY i.created_at DESC`,
)
if err != nil {
return []domain.UserInvite{}, err
}
defer rows.Close()
invites := make([]domain.UserInvite, 0)
for rows.Next() {
var inv domain.UserInvite
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.TeamName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
invites = append(invites, inv)
}
}
return invites, nil
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
err := s.db.QueryRow(
`SELECT id, code, email, role, team_id, created_by, expires_at, used_by, created_at
FROM user_invites WHERE UPPER(code) = ?`,
code,
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
if inv.UsedBy != "" {
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
}
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
return err
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err
}
// --- SYSTEM SETTINGS ---
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls bool) {
cur, err := s.GetSystemSettings()
if err != nil || cur == nil {
return
}
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
cur.SMTPHost = strings.TrimSpace(host)
if strings.TrimSpace(port) != "" {
cur.SMTPPort = strings.TrimSpace(port)
}
cur.SMTPUser = strings.TrimSpace(user)
cur.SMTPPass = pass
cur.SMTPFrom = strings.TrimSpace(from)
cur.SMTPTLS = tls
_ = s.UpdateSystemSettings(cur)
}
}
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings := &domain.SystemSettings{
SharingMode: "teams_only",
AllowRegistration: true,
AllowRegistration: false,
InstanceName: "MindSift",
SMTPPort: "587",
SMTPTLS: true,
}
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
@@ -1836,6 +2064,20 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings.AllowRegistration = (v == "true" || v == "1")
case "instance_name":
settings.InstanceName = v
case "smtp_host":
settings.SMTPHost = v
case "smtp_port":
if v != "" {
settings.SMTPPort = v
}
case "smtp_user":
settings.SMTPUser = v
case "smtp_pass":
settings.SMTPPass = v
case "smtp_from":
settings.SMTPFrom = v
case "smtp_tls":
settings.SMTPTLS = (v != "false" && v != "0")
}
}
}
@@ -1847,12 +2089,39 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
if settings.AllowRegistration {
regStr = "true"
}
tlsStr := "false"
if settings.SMTPTLS {
tlsStr = "true"
}
if settings.SMTPPort == "" {
settings.SMTPPort = "587"
}
if settings.SharingMode == "" {
settings.SharingMode = "teams_only"
}
if settings.InstanceName == "" {
settings.InstanceName = "MindSift"
}
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
('sharing_mode', ?),
('allow_registration', ?),
('instance_name', ?)
('instance_name', ?),
('smtp_host', ?),
('smtp_port', ?),
('smtp_user', ?),
('smtp_pass', ?),
('smtp_from', ?),
('smtp_tls', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
settings.SharingMode, regStr, settings.InstanceName,
settings.SharingMode,
regStr,
settings.InstanceName,
strings.TrimSpace(settings.SMTPHost),
strings.TrimSpace(settings.SMTPPort),
strings.TrimSpace(settings.SMTPUser),
settings.SMTPPass,
strings.TrimSpace(settings.SMTPFrom),
tlsStr,
)
return err
}
@@ -1860,34 +2129,49 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
// --- ALARMS ---
type DueTaskAlarm struct {
TaskID string
UserID string
Title string
DueDate string
Priority string
DiscordWebhookURL string
TaskID string
UserID string
Title string
Notes string
DueDate string
Priority string
UserEmail string
AlarmChannels []string
AlarmEmail string
DiscordWebhookURL string
CustomWebhookURL string
NotifyBeforeMinutes int
}
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
rows, err := s.db.Query(
`SELECT t.id, t.user_id, t.content, t.due_date, t.priority, u.discord_webhook_url
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
FROM thoughts t
JOIN users u ON u.id = t.user_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0
AND t.alarm_sent = 0
AND t.due_date != ''
AND u.discord_webhook_url != ''`,
AND u.is_active = 1`,
)
if err != nil {
return nil, err
return []DueTaskAlarm{}, err
}
defer rows.Close()
var alarms []DueTaskAlarm
alarms := make([]DueTaskAlarm, 0)
for rows.Next() {
var a DueTaskAlarm
if err := rows.Scan(&a.TaskID, &a.UserID, &a.Title, &a.DueDate, &a.Priority, &a.DiscordWebhookURL); err == nil {
var channelsRaw string
if err := rows.Scan(
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
); err == nil {
a.AlarmChannels = parseAlarmChannels(channelsRaw)
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
alarms = append(alarms, a)
}
}
@@ -1898,3 +2182,4 @@ func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
return err
}