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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4318
-1295
No files matched your search
+9
-1
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
+267
-53
@@ -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()) {
|
||||
checkBootAndAuth();
|
||||
|
||||
const onUnauthorized = async () => {
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
try {
|
||||
await api.quickStart();
|
||||
const st = await api.getAuthStatus();
|
||||
setAuthStatus(st);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await api.flushOfflineQueue();
|
||||
await refreshAll();
|
||||
};
|
||||
init();
|
||||
|
||||
const onOnline = async () => {
|
||||
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,14 +444,13 @@ 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 */}
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
@@ -272,21 +480,27 @@ export const App: React.FC = () => {
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */}
|
||||
{showProfileModal && (
|
||||
<SettingsModal
|
||||
{activeTab === 'user_settings' && (
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
}}
|
||||
onLoggedOut={handleLogout}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin_settings' && isStaff && (
|
||||
<AdminSettingsView
|
||||
currentUser={user}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</ViewErrorBoundary>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
|
||||
+196
-53
@@ -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)}`, {
|
||||
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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+596
-75
@@ -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,35 +202,48 @@ 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
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID)))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check X-API-Key Header
|
||||
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) {
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
count, _ := s.store.CountUsers()
|
||||
if count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
|
||||
return
|
||||
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,7 +1101,7 @@ 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())
|
||||
@@ -992,7 +1109,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
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 {
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
if a.DueDate > today {
|
||||
continue
|
||||
}
|
||||
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()
|
||||
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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,13 +45,29 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | member
|
||||
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
|
||||
|
||||
const (
|
||||
@@ -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,7 +131,7 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -122,6 +139,13 @@ type SystemSettings struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
+388
-103
@@ -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,8 +1763,22 @@ func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization,
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOrganization(orgID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
|
||||
rows, err := s.db.Query(
|
||||
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
|
||||
@@ -1687,18 +1787,25 @@ func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, e
|
||||
ORDER BY o.created_at ASC`,
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if err != nil {
|
||||
return []domain.Organization{}, err
|
||||
}
|
||||
|
||||
var orgs []domain.Organization
|
||||
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
|
||||
}
|
||||
@@ -1863,31 +2132,46 @@ type DueTaskAlarm struct {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user