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
+4391
-1368
No files matched your search
+305
-91
@@ -1,23 +1,83 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
Brain,
|
||||
Settings,
|
||||
Shield,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
|
||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||
import { TriageDeck } from './features/triage/TriageDeck';
|
||||
import { TasksView } from './features/triage/TasksView';
|
||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SettingsModal } from './features/settings/SettingsModal';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
|
||||
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists';
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
| 'tasks'
|
||||
| 'workspace'
|
||||
| 'lists'
|
||||
| 'user_settings'
|
||||
| 'admin_settings';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
errorText: string;
|
||||
}
|
||||
|
||||
class ViewErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; onReset: () => void },
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; onReset: () => void }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, errorText: '' };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
|
||||
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-6 bg-panel border border-border text-xs space-y-3 max-w-lg mx-auto my-8">
|
||||
<h3 className="text-sm font-bold text-white">Ansicht konnte nicht geladen werden</h3>
|
||||
<p className="text-zinc-400">{this.state.errorText}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, errorText: '' });
|
||||
this.props.onReset();
|
||||
}}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Ansicht zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [bootLoading, setBootLoading] = useState(true);
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
||||
needsSetup: false,
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
inboxCount: 0,
|
||||
@@ -34,7 +94,9 @@ export const App: React.FC = () => {
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
// Profile Dropdown State
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const refreshAll = async () => {
|
||||
try {
|
||||
@@ -45,49 +107,85 @@ export const App: React.FC = () => {
|
||||
]);
|
||||
setUser(meRes.user);
|
||||
setStats(meRes.stats);
|
||||
setDeck(deckRes);
|
||||
setLists(listsRes);
|
||||
setDeck(Array.isArray(deckRes) ? deckRes : []);
|
||||
setLists(Array.isArray(listsRes) ? listsRes : []);
|
||||
} catch {
|
||||
// Fallback to quickStart session
|
||||
try {
|
||||
const qs = await api.quickStart();
|
||||
setUser(qs.user);
|
||||
const [st, dk, ls] = await Promise.all([
|
||||
api.getStats(),
|
||||
api.getTriageDeck(),
|
||||
api.getAllLists(),
|
||||
]);
|
||||
setStats(st);
|
||||
setDeck(dk);
|
||||
setLists(ls);
|
||||
} catch {
|
||||
// Offline or waiting for server
|
||||
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
||||
}
|
||||
};
|
||||
|
||||
const checkBootAndAuth = async () => {
|
||||
setBootLoading(true);
|
||||
try {
|
||||
const st = await api.getAuthStatus();
|
||||
setAuthStatus(st);
|
||||
|
||||
if (st.needsSetup) {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (getToken()) {
|
||||
await api.flushOfflineQueue();
|
||||
await refreshAll();
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
// Server temporarily unreachable
|
||||
} finally {
|
||||
setBootLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!getToken()) {
|
||||
try {
|
||||
await api.quickStart();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
checkBootAndAuth();
|
||||
|
||||
const onUnauthorized = async () => {
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
try {
|
||||
const st = await api.getAuthStatus();
|
||||
setAuthStatus(st);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await api.flushOfflineQueue();
|
||||
await refreshAll();
|
||||
};
|
||||
init();
|
||||
|
||||
const onOnline = async () => {
|
||||
const synced = await api.flushOfflineQueue();
|
||||
if (synced > 0) refreshAll();
|
||||
if (getToken()) {
|
||||
const synced = await api.flushOfflineQueue();
|
||||
if (synced > 0) refreshAll();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.addEventListener('online', onOnline);
|
||||
return () => window.removeEventListener('online', onOnline);
|
||||
return () => {
|
||||
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.removeEventListener('online', onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close profile dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowProfileMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAuthenticated = async (loggedInUser: User) => {
|
||||
setUser(loggedInUser);
|
||||
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
|
||||
setActiveTab('triage');
|
||||
await refreshAll();
|
||||
};
|
||||
|
||||
const handleCaptured = (
|
||||
thought: Thought,
|
||||
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
|
||||
@@ -110,7 +208,6 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
// Scroll smoothly to top and focus capture bar
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setCaptureFocusCount((c) => c + 1);
|
||||
};
|
||||
@@ -118,11 +215,43 @@ export const App: React.FC = () => {
|
||||
const handleLogout = async () => {
|
||||
triggerHaptic(20);
|
||||
clearToken();
|
||||
await api.quickStart();
|
||||
await refreshAll();
|
||||
setShowProfileModal(false);
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('triage');
|
||||
try {
|
||||
const st = await api.getAuthStatus();
|
||||
setAuthStatus(st);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (bootLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-zinc-400 flex items-center justify-center text-xs font-sans">
|
||||
Verbinde mit MindSift…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
||||
if (authStatus.needsSetup || !user) {
|
||||
return (
|
||||
<SetupAndAuthView
|
||||
authStatus={authStatus}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isStaff = user.role === 'admin' || user.role === 'team_admin';
|
||||
const roleLabel =
|
||||
user.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: user.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-white flex flex-col font-sans selection:bg-accent/30">
|
||||
{/* Top Header Bar */}
|
||||
@@ -141,7 +270,7 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm sm:text-base text-white tracking-tight leading-none">
|
||||
MindSift
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 mt-0.5 hidden sm:inline-block">
|
||||
Der Kopf frei in drei Sekunden
|
||||
@@ -158,21 +287,83 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileModal(true);
|
||||
setShowProfileMenu((prev) => !prev);
|
||||
}}
|
||||
className="h-8 pl-1 pr-3 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
|
||||
title="Profil & Einstellungen"
|
||||
className="h-8 pl-1 pr-2.5 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{user?.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<span className="max-w-[120px] truncate hidden sm:inline">{user?.displayName || 'Konto'}</span>
|
||||
<span className="max-w-[120px] truncate hidden sm:inline">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{showProfileMenu && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-60 bg-panel border border-border shadow-2xl z-50 divide-y divide-border text-xs">
|
||||
{/* User Info Header */}
|
||||
<div className="p-3 bg-canvas">
|
||||
<p className="font-bold text-white truncate">{user.displayName}</p>
|
||||
<p className="text-[11px] text-zinc-400 truncate">{user.email}</p>
|
||||
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border text-[10px] text-accent font-semibold">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('user_settings');
|
||||
}}
|
||||
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
||||
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 text-accent" />
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{isStaff && (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('admin_settings');
|
||||
}}
|
||||
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
||||
activeTab === 'admin_settings'
|
||||
? 'text-accent font-semibold'
|
||||
: 'text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
<span>Administration</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-red-400 hover:bg-red-950/30 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +380,13 @@ export const App: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>Triage</span>
|
||||
{stats.inboxCount > 0 && (
|
||||
<span className={activeTab === 'triage' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'triage'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -205,7 +402,13 @@ export const App: React.FC = () => {
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className={activeTab === 'tasks' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'tasks'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -220,7 +423,13 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Workspace</span>
|
||||
<span className={activeTab === 'workspace' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'workspace'
|
||||
? 'badge-accent'
|
||||
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
|
||||
}
|
||||
>
|
||||
{stats.docsCount}
|
||||
</span>
|
||||
</button>
|
||||
@@ -235,58 +444,63 @@ export const App: React.FC = () => {
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Geteilte Listen</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
|
||||
{/* Tab View Content */}
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
deck={deck}
|
||||
lists={lists}
|
||||
onDeckChange={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onFocusCapture={handleTriggerAddButton}
|
||||
/>
|
||||
)}
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
deck={deck}
|
||||
lists={lists}
|
||||
onDeckChange={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onFocusCapture={handleTriggerAddButton}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'workspace' && (
|
||||
<WorkspaceView
|
||||
initialDocId={selectedDocId}
|
||||
onStatsChange={refreshAll}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'workspace' && (
|
||||
<WorkspaceView
|
||||
initialDocId={selectedDocId}
|
||||
onStatsChange={refreshAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
)}
|
||||
</section>
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
)}
|
||||
|
||||
{activeTab === 'user_settings' && (
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
}}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin_settings' && isStaff && (
|
||||
<AdminSettingsView
|
||||
currentUser={user}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</ViewErrorBoundary>
|
||||
</main>
|
||||
|
||||
{/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */}
|
||||
{showProfileModal && (
|
||||
<SettingsModal
|
||||
currentUser={user}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
}}
|
||||
onLoggedOut={handleLogout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Shield,
|
||||
Lock,
|
||||
KeyRound,
|
||||
UserPlus,
|
||||
LogIn,
|
||||
Server,
|
||||
Share2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
onAuthenticated: (user: User) => void;
|
||||
}
|
||||
|
||||
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
authStatus,
|
||||
onAuthenticated,
|
||||
}) => {
|
||||
// --- Setup Wizard State ---
|
||||
const [setupName, setSetupName] = useState('');
|
||||
const [setupEmail, setSetupEmail] = useState('');
|
||||
const [setupPassword, setSetupPassword] = useState('');
|
||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>('teams_only');
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
const [createSampleData, setCreateSampleData] = useState(false);
|
||||
|
||||
// --- Login / Invite / Register State ---
|
||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCompleteSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const res = await api.completeSetup({
|
||||
email: setupEmail.trim(),
|
||||
password: setupPassword,
|
||||
displayName: setupName.trim(),
|
||||
instanceName: instanceName.trim() || 'MindSift',
|
||||
sharingMode,
|
||||
allowRegistration,
|
||||
createSampleData,
|
||||
});
|
||||
onAuthenticated(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler bei der Ersteinrichtung');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const res = await api.login(email.trim(), password);
|
||||
onAuthenticated(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Ungültige Anmeldedaten');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
||||
const res = await api.register(email.trim(), password, displayName.trim(), code);
|
||||
onAuthenticated(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FIRST-BOOT SERVER SETUP WIZARD
|
||||
// ============================================================================
|
||||
if (authStatus.needsSetup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
|
||||
<div className="w-full max-w-xl bg-panel border border-border shadow-2xl">
|
||||
{/* Top Brand Bar */}
|
||||
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white tracking-tight leading-none">
|
||||
MindSift Ersteinrichtung
|
||||
</h1>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">
|
||||
Initiales Administrator-Konto & Server-Konfiguration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-accent/15 border border-accent text-accent text-[10px] font-semibold uppercase tracking-wider">
|
||||
First Boot
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCompleteSetup} className="p-5 space-y-5 text-xs">
|
||||
<p className="text-zinc-300 leading-relaxed">
|
||||
Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in
|
||||
der Datenbank. Lege jetzt das erste <strong>Server-Admin-Konto</strong> an und bestimme
|
||||
die Sicherheitseinstellungen.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Admin Account */}
|
||||
<div className="p-3.5 bg-canvas border border-border space-y-3">
|
||||
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>1. Server-Administrator anlegen</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Dein Anzeigename *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={setupName}
|
||||
onChange={(e) => setSetupName(e.target.value)}
|
||||
placeholder="z.B. Admin / Dein Name"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">E-Mail-Adresse (Login) *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={setupEmail}
|
||||
onChange={(e) => setSetupEmail(e.target.value)}
|
||||
placeholder="admin@deinedomain.de"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Administrator-Passwort *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Server & Isolation */}
|
||||
<div className="p-3.5 bg-canvas border border-border space-y-3">
|
||||
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-accent" />
|
||||
<span>2. Instanz & Berechtigungen</span>
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Name der Instanz</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instanceName}
|
||||
onChange={(e) => setInstanceName(e.target.value)}
|
||||
placeholder="MindSift"
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1.5 flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3 text-accent" />
|
||||
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('strict_private')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'strict_private'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Strikt Privat</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">
|
||||
Keiner sieht andere Nutzer. Komplett isoliert.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('teams_only')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'teams_only'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Teams isoliert</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">
|
||||
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('open')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'open'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Alle sehen alle</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">
|
||||
Offene Zusammenarbeit auf dem gesamten Server.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<label className="flex items-center justify-between cursor-pointer select-none">
|
||||
<div>
|
||||
<span className="font-medium text-white block">
|
||||
Öffentliche Selbstregistrierung erlauben
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Wenn deaktiviert, können neue Nutzer nur per Einladungscode oder durch einen Admin angelegt werden.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRegistration}
|
||||
onChange={(e) => setAllowRegistration(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
|
||||
<div>
|
||||
<span className="font-medium text-white block">
|
||||
Start-Beispieldaten anlegen
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createSampleData}
|
||||
onChange={(e) => setCreateSampleData(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>{submitting ? 'Richte Server ein…' : 'Ersteinrichtung abschließen & Starten'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
|
||||
// ============================================================================
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
|
||||
<div className="w-full max-w-md bg-panel border border-border shadow-2xl">
|
||||
{/* Top Header */}
|
||||
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-white tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">
|
||||
Der Kopf frei in drei Sekunden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Lock className="w-4 h-4 text-zinc-500" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-border bg-canvas text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthTab('login');
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
||||
authTab === 'login'
|
||||
? 'bg-panel text-white border-b-2 border-b-accent'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
<span>Anmelden</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthTab('invite');
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
||||
authTab === 'invite'
|
||||
? 'bg-panel text-white border-b-2 border-b-accent'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
<span>Einladungscode</span>
|
||||
</button>
|
||||
|
||||
{authStatus.allowRegistration && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthTab('register');
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 transition-colors ${
|
||||
authTab === 'register'
|
||||
? 'bg-panel text-white border-b-2 border-b-accent'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>Registrieren</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<div className="p-5 text-xs">
|
||||
{error && (
|
||||
<div className="mb-4 p-2.5 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
<span>{submitting ? 'Melde an…' : 'Anmelden'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!authStatus.allowRegistration && (
|
||||
<p className="text-[11px] text-zinc-500 text-center pt-2 border-t border-border">
|
||||
Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthTab('invite')}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Einladungscode
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(authTab === 'invite' || authTab === 'register') && (
|
||||
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
||||
{authTab === 'invite' && (
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Einladungscode *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
|
||||
placeholder="MS-XXXXXXXX"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-white font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Anzeigename *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">E-Mail-Adresse *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Passwort *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{submitting
|
||||
? 'Konto wird erstellt…'
|
||||
: authTab === 'invite'
|
||||
? 'Einladung einlösen & Konto erstellen'
|
||||
: 'Konto registrieren'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,953 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
User as UserIcon,
|
||||
Shield,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Settings as SettingsIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Check,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Terminal,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, Organization, SystemSettings, Team, User } from '../../lib/types';
|
||||
import { api, clearToken, getToken, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface SettingsModalProps {
|
||||
currentUser: User | null;
|
||||
onClose: () => void;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onLoggedOut: () => void;
|
||||
}
|
||||
|
||||
type SettingsTab = 'profile' | 'alarms' | 'webhooks' | 'teams' | 'admin';
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
currentUser,
|
||||
onClose,
|
||||
onUserUpdated,
|
||||
onLoggedOut,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
|
||||
|
||||
// --- Profile State ---
|
||||
const [displayName, setDisplayName] = useState(currentUser?.displayName || '');
|
||||
const [email, setEmail] = useState(currentUser?.email || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
// --- Alarms / Discord State ---
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser?.discordWebhookUrl || '');
|
||||
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState(currentUser?.notifyBeforeMinutes || 30);
|
||||
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [testingDiscord, setTestingDiscord] = useState(false);
|
||||
|
||||
// --- Webhooks / API Keys State ---
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
// --- Organizations & Teams State ---
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [selectedOrgId, setSelectedOrgId] = useState('');
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [newTeamDesc, setNewTeamDesc] = useState('');
|
||||
|
||||
// --- Admin State ---
|
||||
const [adminUsers, setAdminUsers] = useState<User[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
allowRegistration: true,
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [newAdminUserEmail, setNewAdminUserEmail] = useState('');
|
||||
const [newAdminUserPassword, setNewAdminUserPassword] = useState('');
|
||||
const [newAdminUserDisplayName, setNewAdminUserDisplayName] = useState('');
|
||||
const [newAdminUserRole, setNewAdminUserRole] = useState<'member' | 'admin'>('member');
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
loadOrgsAndTeams();
|
||||
if (currentUser?.role === 'admin') {
|
||||
loadAdminData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await api.getAPIKeys();
|
||||
setApiKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrgsAndTeams = async () => {
|
||||
try {
|
||||
const [o, t] = await Promise.all([api.getOrganizations(), api.getUserTeams()]);
|
||||
setOrgs(o);
|
||||
setTeams(t);
|
||||
if (o.length > 0 && !selectedOrgId) {
|
||||
setSelectedOrgId(o[0].id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
const [u, s] = await Promise.all([api.adminGetUsers(), api.adminGetSettings()]);
|
||||
setAdminUsers(u);
|
||||
setSystemSettings(s);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// --- Profile Handlers ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const patch: any = { displayName, email };
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
}
|
||||
const updated = await api.updateSettings(patch);
|
||||
onUserUpdated(updated);
|
||||
setNewPassword('');
|
||||
setProfileMsg({ type: 'ok', text: 'Profil erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- Alarms Handlers ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAlarmMsg(null);
|
||||
try {
|
||||
const updated = await api.updateSettings({
|
||||
discordWebhookUrl: discordWebhookUrl.trim(),
|
||||
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
||||
});
|
||||
onUserUpdated(updated);
|
||||
setAlarmMsg({ type: 'ok', text: 'Discord Alarm-Einstellungen gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestDiscordWebhook = async () => {
|
||||
if (!discordWebhookUrl.trim()) {
|
||||
setAlarmMsg({ type: 'err', text: 'Bitte zuerst eine Discord Webhook URL eingeben.' });
|
||||
return;
|
||||
}
|
||||
setTestingDiscord(true);
|
||||
setAlarmMsg(null);
|
||||
try {
|
||||
const res = await fetch(discordWebhookUrl.trim(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: '🔔 **MindSift Test-Alarm**: Verbindung zu Discord erfolgreich hergestellt!',
|
||||
embeds: [
|
||||
{
|
||||
title: 'MindSift Aufgaben-Alarm eingerichtet',
|
||||
description: 'Du erhältst hier Benachrichtigungen, wenn Aufgaben fällig werden.',
|
||||
color: 3066993,
|
||||
footer: { text: 'MindSift System Test' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Discord antwortete mit Status ${res.status}`);
|
||||
}
|
||||
setAlarmMsg({ type: 'ok', text: '✅ Test-Nachricht erfolgreich an Discord gesendet!' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: `Fehler: ${err.message}` });
|
||||
} finally {
|
||||
setTestingDiscord(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Webhook / Key Handlers ---
|
||||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant / Script');
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
loadKeys();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAPIKey = async (id: string) => {
|
||||
if (!window.confirm('Schlüssel widerrufen?')) return;
|
||||
await api.deleteAPIKey(id);
|
||||
loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// --- Orgs & Teams Handlers ---
|
||||
const handleCreateOrg = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
try {
|
||||
const created = await api.createOrganization(newOrgName.trim());
|
||||
setOrgs((prev) => [...prev, created]);
|
||||
setSelectedOrgId(created.id);
|
||||
setNewOrgName('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrgId || !newTeamName.trim()) return;
|
||||
try {
|
||||
const created = await api.createOrgTeam(selectedOrgId, newTeamName.trim(), newTeamDesc.trim());
|
||||
setTeams((prev) => [...prev, created]);
|
||||
setNewTeamName('');
|
||||
setNewTeamDesc('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Admin Handlers ---
|
||||
const handleUpdateSystemSettings = async (patch: Partial<SystemSettings>) => {
|
||||
const next = { ...systemSettings, ...patch };
|
||||
setSystemSettings(next);
|
||||
try {
|
||||
await api.adminUpdateSettings(next);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.adminCreateUser({
|
||||
email: newAdminUserEmail,
|
||||
password: newAdminUserPassword,
|
||||
displayName: newAdminUserDisplayName || newAdminUserEmail.split('@')[0],
|
||||
role: newAdminUserRole,
|
||||
});
|
||||
setAdminUsers((prev) => [...prev, created]);
|
||||
setNewAdminUserEmail('');
|
||||
setNewAdminUserPassword('');
|
||||
setNewAdminUserDisplayName('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserActive = async (u: User) => {
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
|
||||
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserRole = async (u: User) => {
|
||||
const nextRole = u.role === 'admin' ? 'member' : 'admin';
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { role: nextRole });
|
||||
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmResetPassword = async (userId: string) => {
|
||||
if (!resetPwdVal.trim()) return;
|
||||
try {
|
||||
await api.adminResetPassword(userId, resetPwdVal.trim());
|
||||
alert('Passwort erfolgreich zurückgesetzt.');
|
||||
setResetPwdUserId(null);
|
||||
setResetPwdVal('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminDeleteUser = async (userId: string) => {
|
||||
if (!window.confirm('Benutzer unwiderruflich löschen? Alle Daten dieses Nutzers werden gelöscht.')) return;
|
||||
try {
|
||||
await api.adminDeleteUser(userId);
|
||||
setAdminUsers((prev) => prev.filter((u) => u.id !== userId));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const curlExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "Authorization: Bearer ${newlyCreatedKey || apiKeys[0]?.keyPrefix || 'DEIN_API_KEY'}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Server-Backup abgeschlossen #homelab #backup", "priority": "high", "target": "task"}'`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="w-full max-w-3xl bg-panel border border-border shadow-2xl flex flex-col max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-3 bg-canvas border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4 text-accent" />
|
||||
<h2 className="text-sm font-bold text-white tracking-tight">
|
||||
Konto-Einstellungen & Administration
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Navigation Tabs */}
|
||||
<div className="flex items-stretch border-b border-border bg-canvas overflow-x-auto no-scrollbar text-xs">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'profile' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Profil & Sicherheit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('alarms')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'alarms' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
<span>Alarme & Discord</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('webhooks')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'webhooks' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
<span>Webhooks & Export</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('teams')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'teams' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Organisationen & Teams</span>
|
||||
</button>
|
||||
|
||||
{currentUser?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'admin' ? 'bg-panel text-accent font-semibold border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Administration</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-4 overflow-y-auto flex-1 space-y-4 text-xs">
|
||||
{/* TAB 1: PROFIL & SICHERHEIT */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Benutzerprofil bearbeiten</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Passe deinen Anzeigenamen und dein Anmeldekennwort an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div
|
||||
className={`p-2 border text-xs ${
|
||||
profileMsg.type === 'ok'
|
||||
? 'bg-blue-900/20 border-accent text-blue-200'
|
||||
: 'bg-red-900/20 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Neues Passwort (leer lassen für keine Änderung)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoggedOut}
|
||||
className="text-red-400 hover:underline flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="btn-primary h-8 px-4 text-xs">
|
||||
Änderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: ALARME & DISCORD */}
|
||||
{activeTab === 'alarms' && (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Aufgaben-Alarme & Discord Benachrichtigungen</h3>
|
||||
<p className="text-zinc-400 text-xs leading-relaxed">
|
||||
Trage deine Discord Webhook URL ein, damit der MindSift Server dich automatisch erinnert, sobald Aufgaben fällig werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alarmMsg && (
|
||||
<div
|
||||
className={`p-2 border text-xs ${
|
||||
alarmMsg.type === 'ok'
|
||||
? 'bg-blue-900/20 border-accent text-blue-200'
|
||||
: 'bg-red-900/20 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{alarmMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveAlarms} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Discord Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={discordWebhookUrl}
|
||||
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/123456789/token..."
|
||||
className="w-full h-8 px-2.5 input-field text-white font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Vorwarnzeit vor Fälligkeit</label>
|
||||
<select
|
||||
value={notifyBeforeMinutes}
|
||||
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
|
||||
className="w-full h-8 px-2 input-field text-white bg-canvas"
|
||||
>
|
||||
<option value={0}>Direkt bei Fälligkeit</option>
|
||||
<option value={15}>15 Minuten vorher</option>
|
||||
<option value={30}>30 Minuten vorher</option>
|
||||
<option value={60}>1 Stunde vorher</option>
|
||||
<option value={1440}>1 Tag vorher</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestDiscordWebhook}
|
||||
disabled={testingDiscord || !discordWebhookUrl.trim()}
|
||||
className="btn-action h-8 px-3 text-xs disabled:opacity-40"
|
||||
>
|
||||
<span>{testingDiscord ? 'Sende Test…' : 'Webhook testen'}</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="btn-primary h-8 px-4 text-xs">
|
||||
Alarm-Einstellungen sichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: WEBHOOKS & EXPORT */}
|
||||
{activeTab === 'webhooks' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Private Webhook Keys & Daten-Export</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Erstelle API-Keys für eigene Bash-Skripte, Home Assistant oder Cronjobs, um Gedanken direkt per Curl zu erfassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key Creator */}
|
||||
<form onSubmit={handleCreateAPIKey} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Key Bezeichnung (z.B. Smart Home, Backup Skript)…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Key erstellen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-2.5 bg-accent/15 border border-accent/40 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-accent font-semibold">Neuer Key (jetzt kopieren):</p>
|
||||
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
|
||||
</div>
|
||||
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-7 px-2.5 text-xs">
|
||||
{copiedKey ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys list */}
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="p-3 text-center text-zinc-500 text-xs">Noch keine API-Schlüssel angelegt.</div>
|
||||
) : (
|
||||
apiKeys.map((k) => (
|
||||
<div key={k.id} className="p-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-white">{k.name}</p>
|
||||
<p className="text-[10px] text-zinc-500 font-mono">Präfix: {k.keyPrefix}••••••••</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAPIKey(k.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Key löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Curl Example */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-zinc-300 flex items-center gap-1">
|
||||
<Terminal className="w-3 h-3 text-accent" /> Curl Schnellbefehl für eigene Skripte:
|
||||
</span>
|
||||
<button onClick={() => handleCopy(curlExample)} className="text-zinc-400 hover:text-white text-[10px]">
|
||||
{copiedKey ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-2.5 bg-black/60 border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
|
||||
{curlExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Data Export Button */}
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">Vollständiger Daten-Export</h4>
|
||||
<p className="text-zinc-500 text-[11px]">Sichere alle Notizen, Aufgaben, Listen und Metadaten als JSON.</p>
|
||||
</div>
|
||||
<button onClick={handleExportData} className="btn-action h-8 px-3 text-xs flex items-center gap-1.5">
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: ORGANISATIONEN & TEAMS */}
|
||||
{activeTab === 'teams' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Organisationen & Teams gründen</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Erstelle Organisationen und unterteile sie in Teams, um Aufgabenlisten und Workspace-Dokumente gemeinsam zu bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Org Creator */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neue Organisation</h4>
|
||||
<form onSubmit={handleCreateOrg} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="Name der Organisation (z.B. Familie, Startup, Homelab Crew)…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs">
|
||||
Organisation anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Team Creator inside Org */}
|
||||
{orgs.length > 0 && (
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neues Team in Organisation</h4>
|
||||
<form onSubmit={handleCreateTeam} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedOrgId}
|
||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||
className="h-8 px-2 input-field text-white bg-panel"
|
||||
>
|
||||
{orgs.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder="Team Name (z.B. DevOps, Einkauf)…"
|
||||
required
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamDesc}
|
||||
onChange={(e) => setNewTeamDesc(e.target.value)}
|
||||
placeholder="Optionale Teambeschreibung…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs">
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Teams List */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-zinc-300">Deine Teams ({teams.length}):</h4>
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{teams.length === 0 ? (
|
||||
<div className="p-3 text-center text-zinc-500">Du bist noch keinem Team beigetreten.</div>
|
||||
) : (
|
||||
teams.map((t) => (
|
||||
<div key={t.id} className="p-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{t.name}</span>
|
||||
</p>
|
||||
{t.description && <p className="text-zinc-400 text-[11px]">{t.description}</p>}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 font-mono">ID: {t.id}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 5: ADMIN BEREICH */}
|
||||
{activeTab === 'admin' && currentUser?.role === 'admin' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Server-Administration & Benutzersteuerung</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Steuere die Instanz-Einstellungen, das Sharing-Verhalten und verwalte registrierte Benutzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Server Sharing Isolation Setting */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-3">
|
||||
<h4 className="font-semibold text-white flex items-center gap-1.5">
|
||||
<Share2 className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Sharing-Isolationsmodus</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'open' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'open'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Offen (Open)</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Alle Benutzer können Spaces finden und beitreten.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'teams_only' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'teams_only'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Teams isoliert</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Sharing ausschließlich innerhalb zugewiesener Teams.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'strict_private' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'strict_private'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Strikt Privat</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Vollständige Benutzer-Isolation; kein Teilen möglich.</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<span className="text-zinc-300">Öffentliche Registrierung erlauben:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleUpdateSystemSettings({ allowRegistration: !systemSettings.allowRegistration })
|
||||
}
|
||||
className={`px-3 py-1 text-xs border ${
|
||||
systemSettings.allowRegistration
|
||||
? 'bg-accent/20 border-accent text-accent'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{systemSettings.allowRegistration ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Creation by Admin */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neuen Benutzer anlegen</h4>
|
||||
<form onSubmit={handleAdminCreateUser} className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={newAdminUserEmail}
|
||||
onChange={(e) => setNewAdminUserEmail(e.target.value)}
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newAdminUserPassword}
|
||||
onChange={(e) => setNewAdminUserPassword(e.target.value)}
|
||||
placeholder="Initial-Passwort"
|
||||
required
|
||||
className="h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAdminUserDisplayName}
|
||||
onChange={(e) => setNewAdminUserDisplayName(e.target.value)}
|
||||
placeholder="Anzeigename"
|
||||
className="flex-1 h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<select
|
||||
value={newAdminUserRole}
|
||||
onChange={(e) => setNewAdminUserRole(e.target.value as any)}
|
||||
className="h-8 px-2 input-field text-white bg-panel"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Benutzer erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-zinc-300">Registrierte Benutzer ({adminUsers.length}):</h4>
|
||||
<div className="border border-border divide-y divide-border bg-canvas overflow-x-auto">
|
||||
{adminUsers.map((u) => (
|
||||
<div key={u.id} className="p-2.5 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">{u.displayName}</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.2 text-[10px] uppercase font-bold border ${
|
||||
u.role === 'admin'
|
||||
? 'bg-accent/20 border-accent/40 text-accent'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.isActive && (
|
||||
<span className="px-1.5 py-0.2 text-[10px] bg-red-900/30 border border-red-500/40 text-red-300">
|
||||
Deaktiviert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-zinc-500 text-[11px] font-mono">{u.email}</p>
|
||||
|
||||
{/* Reset password inline form */}
|
||||
{resetPwdUserId === u.id && (
|
||||
<div className="mt-2 flex items-center gap-1.5 bg-panel p-1.5 border border-border">
|
||||
<input
|
||||
type="password"
|
||||
value={resetPwdVal}
|
||||
onChange={(e) => setResetPwdVal(e.target.value)}
|
||||
placeholder="Neues Passwort…"
|
||||
className="h-6 px-2 input-field text-xs text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleConfirmResetPassword(u.id)}
|
||||
className="btn-primary h-6 px-2 text-[10px]"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetPwdUserId(null)}
|
||||
className="btn-action h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleToggleUserActive(u)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Aktiv / Deaktivieren"
|
||||
>
|
||||
{u.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleUserRole(u)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Rolle wechseln"
|
||||
>
|
||||
{u.role === 'admin' ? 'Zu Member' : 'Zu Admin'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResetPwdUserId(u.id);
|
||||
setResetPwdVal('');
|
||||
}}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Passwort zurücksetzen"
|
||||
>
|
||||
<Lock className="w-3 h-3 text-zinc-400" />
|
||||
</button>
|
||||
{u.id !== currentUser.id && (
|
||||
<button
|
||||
onClick={() => handleAdminDeleteUser(u.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Benutzer löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Globe,
|
||||
Send,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type UserSettingsSection = 'profile' | 'alarms' | 'webhooks';
|
||||
|
||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
currentUser,
|
||||
onUserUpdated,
|
||||
onBack,
|
||||
}) => {
|
||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||
|
||||
// --- Profile State ---
|
||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||
const [email, setEmail] = useState(currentUser.email || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
// --- Multi-Channel Alarms State ---
|
||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
||||
);
|
||||
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
|
||||
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
|
||||
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState<number>(
|
||||
currentUser.notifyBeforeMinutes ?? 30
|
||||
);
|
||||
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
||||
|
||||
// --- Webhooks & API Keys State ---
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
|
||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await api.getAPIKeys();
|
||||
setApiKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAlarmChannel = (ch: string) => {
|
||||
setAlarmChannels((prev) =>
|
||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||||
);
|
||||
};
|
||||
|
||||
// --- Handlers: Profile ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const patch: any = {
|
||||
displayName: displayName.trim(),
|
||||
email: email.trim(),
|
||||
};
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
}
|
||||
const updated = await api.updateSettings(patch);
|
||||
onUserUpdated(updated);
|
||||
setNewPassword('');
|
||||
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers: Alarms ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAlarmMsg(null);
|
||||
setSavingAlarms(true);
|
||||
try {
|
||||
const updated = await api.updateSettings({
|
||||
alarmChannels,
|
||||
alarmEmail: alarmEmail.trim(),
|
||||
discordWebhookUrl: discordWebhookUrl.trim(),
|
||||
customWebhookUrl: customWebhookUrl.trim(),
|
||||
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
||||
});
|
||||
onUserUpdated(updated);
|
||||
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
|
||||
} finally {
|
||||
setSavingAlarms(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestChannel = async (channel: 'email' | 'discord' | 'webhook') => {
|
||||
setAlarmMsg(null);
|
||||
let target = '';
|
||||
if (channel === 'email') {
|
||||
target = (alarmEmail || currentUser.email || '').trim();
|
||||
if (!target) {
|
||||
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Empfänger-E-Mail-Adresse an.' });
|
||||
return;
|
||||
}
|
||||
} else if (channel === 'discord') {
|
||||
target = discordWebhookUrl.trim();
|
||||
if (!target) {
|
||||
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Discord Webhook URL ein.' });
|
||||
return;
|
||||
}
|
||||
} else if (channel === 'webhook') {
|
||||
target = customWebhookUrl.trim();
|
||||
if (!target) {
|
||||
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine eigene Webhook URL ein.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTestingChannel(channel);
|
||||
try {
|
||||
await api.testAlarmChannel(channel, target);
|
||||
const label =
|
||||
channel === 'email' ? 'E-Mail' : channel === 'discord' ? 'Discord' : 'Eigener Webhook';
|
||||
setAlarmMsg({
|
||||
type: 'ok',
|
||||
text: `Test-Alarm über "${label}" wurde erfolgreich vom Server versendet!`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({
|
||||
type: 'err',
|
||||
text: `Test fehlgeschlagen: ${err.message || 'Unbekannter Fehler'}`,
|
||||
});
|
||||
} finally {
|
||||
setTestingChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers: Webhooks & Export ---
|
||||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
loadKeys();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAPIKey = async (id: string) => {
|
||||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||
await api.deleteAPIKey(id);
|
||||
loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedLabel(label);
|
||||
setTimeout(() => setCopiedLabel(null), 2000);
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const activeKeyPlaceholder = newlyCreatedKey || apiKeys[0]?.keyPrefix + '••••••••' || 'DEIN_API_KEY';
|
||||
|
||||
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
|
||||
|
||||
const roleBadgeLabel =
|
||||
currentUser.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: currentUser.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* Top Page Header */}
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white tracking-tight">Kontoeinstellungen</h1>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Persönliches Profil, Aufgaben-Alarme, Private Webhooks & Daten-Backup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-zinc-300 font-medium">
|
||||
Rolle: <strong className="text-accent">{roleBadgeLabel}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main 2-Column Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
|
||||
{/* Left Navigation Sidebar */}
|
||||
<aside className="bg-panel border border-border divide-y divide-border">
|
||||
<button
|
||||
onClick={() => setSection('profile')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'profile'
|
||||
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Profil & Sicherheit</p>
|
||||
<p className="text-[10px] text-zinc-500">Name, E-Mail & Passwort</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('alarms')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'alarms'
|
||||
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Alarme</p>
|
||||
<p className="text-[10px] text-zinc-500">E-Mail, Discord & Webhooks</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('webhooks')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'webhooks'
|
||||
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Private Webhooks & Export</p>
|
||||
<p className="text-[10px] text-zinc-500">API-Keys, Curl & JSON-Backup</p>
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="md:col-span-3 bg-panel border border-border p-5 text-xs">
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 1: PROFIL & SICHERHEIT */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'profile' && (
|
||||
<div className="space-y-5 max-w-2xl">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-white">Profil & Anmeldedaten</h2>
|
||||
<p className="text-zinc-400 mt-0.5">
|
||||
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div
|
||||
className={`p-3 border flex items-center gap-2 ${
|
||||
profileMsg.type === 'ok'
|
||||
? 'bg-blue-950/30 border-accent text-blue-200'
|
||||
: 'bg-red-950/30 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{profileMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
)}
|
||||
<span>{profileMsg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-zinc-300 font-medium mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 px-3 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-300 font-medium mb-1">E-Mail-Adresse (Login)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 px-3 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-300 font-medium mb-1">
|
||||
Neues Passwort (optional — leer lassen für keine Änderung)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
className="w-full h-9 px-3 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||
>
|
||||
{savingProfile ? 'Speichere…' : 'Profil speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'alarms' && (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-white">Aufgaben-Alarme & Benachrichtigungsziele</h2>
|
||||
<p className="text-zinc-400 mt-0.5">
|
||||
Wähle aus, über welche Kanäle du benachrichtigt werden möchtest, sobald eine Aufgabe
|
||||
mit Fälligkeitsdatum ansteht. Mehrere Ziele können gleichzeitig aktiv sein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alarmMsg && (
|
||||
<div
|
||||
className={`p-3 border flex items-center gap-2 ${
|
||||
alarmMsg.type === 'ok'
|
||||
? 'bg-blue-950/30 border-accent text-blue-200'
|
||||
: 'bg-red-950/30 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{alarmMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
)}
|
||||
<span>{alarmMsg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveAlarms} className="space-y-4">
|
||||
{/* Lead Time Selector */}
|
||||
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="font-semibold text-white block">
|
||||
Vorwarnzeit vor Fälligkeit
|
||||
</label>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen?
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={notifyBeforeMinutes}
|
||||
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
|
||||
className="h-9 px-3 input-field text-white bg-panel min-w-[200px]"
|
||||
>
|
||||
<option value={0}>Pünktlich bei Fälligkeit (0 Min.)</option>
|
||||
<option value={15}>15 Minuten vorher</option>
|
||||
<option value={30}>30 Minuten vorher</option>
|
||||
<option value={60}>1 Stunde vorher</option>
|
||||
<option value={120}>2 Stunden vorher</option>
|
||||
<option value={1440}>1 Tag vorher (24 Std.)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Channel 1: E-Mail */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 ${
|
||||
alarmChannels.includes('email')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('email')}
|
||||
onChange={() => toggleAlarmChannel('email')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<Mail className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-white block">Ziel: E-Mail</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('email')}
|
||||
disabled={testingChannel === 'email'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-6">
|
||||
<label className="block text-zinc-400 mb-1">
|
||||
Abweichende Empfänger-E-Mail (optional, Standard: {currentUser.email})
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={alarmEmail}
|
||||
onChange={(e) => setAlarmEmail(e.target.value)}
|
||||
placeholder={currentUser.email}
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel 2: Discord Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 ${
|
||||
alarmChannels.includes('discord')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('discord')}
|
||||
onChange={() => toggleAlarmChannel('discord')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<MessageSquare className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-white block">Ziel: Discord Webhook</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen Discord-Kanal.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('discord')}
|
||||
disabled={testingChannel === 'discord'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-6">
|
||||
<label className="block text-zinc-400 mb-1">Discord Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={discordWebhookUrl}
|
||||
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/123456789/token..."
|
||||
className="w-full h-8 px-2.5 input-field text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel 3: Custom HTTP Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 ${
|
||||
alarmChannels.includes('webhook')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('webhook')}
|
||||
onChange={() => toggleAlarmChannel('webhook')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-white block">
|
||||
Ziel: Eigener Webhook (HTTP POST JSON)
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder Automatisierungen.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('webhook')}
|
||||
disabled={testingChannel === 'webhook'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-6">
|
||||
<label className="block text-zinc-400 mb-1">Ziel-URL (empfängt JSON-Payload per POST)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={customWebhookUrl}
|
||||
onChange={(e) => setCustomWebhookUrl(e.target.value)}
|
||||
placeholder="https://dein-server.local/webhook/mindsift-alarm"
|
||||
className="w-full h-8 px-2.5 input-field text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingAlarms}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||
>
|
||||
{savingAlarms ? 'Speichere…' : 'Alarm-Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: PRIVATE WEBHOOKS & EXPORT */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'webhooks' && (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-white">Private Webhooks (Eingehend) & Daten-Export</h2>
|
||||
<p className="text-zinc-400 mt-0.5">
|
||||
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring oder
|
||||
Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create API Key */}
|
||||
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
|
||||
className="flex-1 h-9 px-3 input-field text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuen API-Key erzeugen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-3 bg-accent/15 border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-accent font-bold">
|
||||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im Klartext angezeigt):
|
||||
</p>
|
||||
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(newlyCreatedKey, 'key')}
|
||||
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Keys List */}
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="p-4 text-center text-zinc-500">
|
||||
Noch keine persönlichen API-Schlüssel vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
apiKeys.map((k) => (
|
||||
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-white">{k.name}</p>
|
||||
<p className="text-[11px] text-zinc-500 font-mono">
|
||||
Präfix: {k.keyPrefix}•••••••• · Erstellt: {new Date(k.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAPIKey(k.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Schlüssel widerrufen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Curl Example */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-zinc-300 flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Full Data Backup */}
|
||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-white">Vollständiger Daten-Export (JSON Backup)</h3>
|
||||
<p className="text-zinc-400 text-[11px]">
|
||||
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable JSON-Datei herunter.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -60,15 +60,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([
|
||||
api.getTasks(selectedListId === 'all' ? undefined : selectedListId),
|
||||
api.getTaskLists(),
|
||||
api.getSpaces(),
|
||||
api.getThoughts('discarded'),
|
||||
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
|
||||
api.getTaskLists().catch(() => []),
|
||||
api.getSpaces().catch(() => []),
|
||||
api.getThoughts('discarded').catch(() => []),
|
||||
]);
|
||||
setTasks(allTasks);
|
||||
setTaskLists(allLists);
|
||||
setSpaces(allSpaces);
|
||||
setDiscarded(allDiscarded);
|
||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||
setSpaces(Array.isArray(allSpaces) ? allSpaces : []);
|
||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||
} catch {
|
||||
// Prevent unhandled promise rejection from crashing view
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -190,14 +192,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const openTasks = tasks.filter((t) => !t.isCompleted);
|
||||
const doneTasks = tasks.filter((t) => t.isCompleted);
|
||||
const safeTasks = Array.isArray(tasks) ? tasks : [];
|
||||
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
||||
|
||||
const todoKanban = tasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
|
||||
const inProgressKanban = tasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
|
||||
const doneKanban = tasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
|
||||
const openTasks = safeTasks.filter((t) => !t.isCompleted);
|
||||
const doneTasks = safeTasks.filter((t) => t.isCompleted);
|
||||
|
||||
const currentSelectedList = taskLists.find((l) => l.id === selectedListId);
|
||||
const todoKanban = safeTasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
|
||||
const inProgressKanban = safeTasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
|
||||
const doneKanban = safeTasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
|
||||
|
||||
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
@@ -216,7 +221,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
Alle Aufgaben
|
||||
</button>
|
||||
|
||||
{taskLists.map((tl) => (
|
||||
{safeTaskLists.map((tl) => (
|
||||
<div key={tl.id} className="relative group flex items-center">
|
||||
<button
|
||||
onClick={() => setSelectedListId(tl.id)}
|
||||
|
||||
+198
-55
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
APIKey,
|
||||
AuthStatus,
|
||||
DashboardStats,
|
||||
Document,
|
||||
DocumentBlock,
|
||||
@@ -10,10 +11,10 @@ import {
|
||||
Space,
|
||||
SystemSettings,
|
||||
Team,
|
||||
TeamMember,
|
||||
TaskList,
|
||||
Thought,
|
||||
User,
|
||||
UserInvite,
|
||||
} from './types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -41,7 +42,11 @@ export function triggerHaptic(pattern: number | number[] = 15): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}, isRetry = false): Promise<T> {
|
||||
function ensureArray<T>(val: T[] | null | undefined): T[] {
|
||||
return Array.isArray(val) ? val : [];
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -54,7 +59,7 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -68,23 +73,15 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (res.status === 401 && !isRetry && !path.includes('/auth/login') && !path.includes('/auth/quickstart')) {
|
||||
try {
|
||||
const qsRes = await fetch('/api/v1/auth/quickstart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (qsRes.ok) {
|
||||
const qsData = await qsRes.json();
|
||||
if (qsData.token) {
|
||||
setToken(qsData.token);
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to standard error handling
|
||||
}
|
||||
if (
|
||||
res.status === 401 &&
|
||||
!path.includes('/auth/login') &&
|
||||
!path.includes('/auth/register') &&
|
||||
!path.includes('/auth/setup') &&
|
||||
!path.includes('/auth/status')
|
||||
) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -102,10 +99,23 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// --- AUTH & SETTINGS ---
|
||||
async quickStart(): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/quickstart', {
|
||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
},
|
||||
|
||||
async completeSetup(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
instanceName?: string;
|
||||
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
||||
allowRegistration?: boolean;
|
||||
createSampleData?: boolean;
|
||||
}): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setToken(res.token);
|
||||
return res;
|
||||
@@ -120,10 +130,15 @@ export const api = {
|
||||
return res;
|
||||
},
|
||||
|
||||
async register(email: string, password: string, displayName: string): Promise<{ token: string; user: User }> {
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
inviteCode?: string
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
||||
});
|
||||
setToken(res.token);
|
||||
return res;
|
||||
@@ -137,7 +152,10 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/auth/settings', {
|
||||
@@ -146,10 +164,25 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async testAlarmChannel(
|
||||
channel: 'email' | 'discord' | 'webhook',
|
||||
target?: string
|
||||
): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/auth/alarms/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel, target: target || '' }),
|
||||
});
|
||||
},
|
||||
|
||||
async getStats(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('/api/v1/stats');
|
||||
},
|
||||
|
||||
async getUsersDirectory(): Promise<User[]> {
|
||||
const res = await request<User[]>('/api/v1/users/directory');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- INSTANT CAPTURE & OFFLINE SYNC ---
|
||||
async captureThought(
|
||||
content: string,
|
||||
@@ -176,7 +209,7 @@ export const api = {
|
||||
}),
|
||||
});
|
||||
return thought;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Queue locally if offline
|
||||
const queue = getOfflineQueue();
|
||||
const offlineThought: Thought = {
|
||||
@@ -229,11 +262,13 @@ export const api = {
|
||||
|
||||
// --- THOUGHTS, TRIAGE & TASKS ---
|
||||
async getTriageDeck(): Promise<Thought[]> {
|
||||
return request<Thought[]>('/api/v1/triage/deck');
|
||||
const res = await request<Thought[]>('/api/v1/triage/deck');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getThoughts(status = 'all'): Promise<Thought[]> {
|
||||
return request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
||||
const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async triageAction(
|
||||
@@ -260,7 +295,8 @@ export const api = {
|
||||
|
||||
async getTasks(taskListId?: string): Promise<Thought[]> {
|
||||
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
|
||||
return request<Thought[]>(`/api/v1/tasks${query}`);
|
||||
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async updateThought(
|
||||
@@ -291,7 +327,8 @@ export const api = {
|
||||
|
||||
// --- TASK LISTS ---
|
||||
async getTaskLists(): Promise<TaskList[]> {
|
||||
return request<TaskList[]>('/api/v1/task-lists');
|
||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createTaskList(payload: {
|
||||
@@ -327,11 +364,21 @@ export const api = {
|
||||
|
||||
// --- WORKSPACE DOCUMENTS ---
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
return request<Document[]>('/api/v1/documents');
|
||||
const res = await request<Document[]>('/api/v1/documents');
|
||||
return ensureArray(res).map((d) => ({
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
}));
|
||||
},
|
||||
|
||||
async getDocument(id: string): Promise<Document> {
|
||||
return request<Document>(`/api/v1/documents/${id}`);
|
||||
const d = await request<Document>(`/api/v1/documents/${id}`);
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async createDocument(payload: {
|
||||
@@ -342,10 +389,15 @@ export const api = {
|
||||
tags?: string[];
|
||||
isPinned?: boolean;
|
||||
}): Promise<Document> {
|
||||
return request<Document>('/api/v1/documents', {
|
||||
const d = await request<Document>('/api/v1/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async updateDocument(
|
||||
@@ -359,10 +411,15 @@ export const api = {
|
||||
isPinned?: boolean;
|
||||
}
|
||||
): Promise<Document> {
|
||||
return request<Document>(`/api/v1/documents/${id}`, {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
@@ -371,7 +428,11 @@ export const api = {
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
return request<Space[]>('/api/v1/spaces');
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
return ensureArray(res).map((s) => ({
|
||||
...s,
|
||||
members: ensureArray(s.members),
|
||||
}));
|
||||
},
|
||||
|
||||
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||
@@ -389,18 +450,30 @@ export const api = {
|
||||
},
|
||||
|
||||
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
||||
return request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
}));
|
||||
},
|
||||
|
||||
async getAllLists(): Promise<SharedList[]> {
|
||||
return request<SharedList[]>('/api/v1/lists');
|
||||
const res = await request<SharedList[]>('/api/v1/lists');
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
}));
|
||||
},
|
||||
|
||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
return request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, icon, listType }),
|
||||
});
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteList(listId: string): Promise<void> {
|
||||
@@ -433,7 +506,14 @@ export const api = {
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
return request<Organization[]>('/api/v1/organizations');
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
return ensureArray(res).map((o) => ({
|
||||
...o,
|
||||
teams: ensureArray(o.teams).map((t) => ({
|
||||
...t,
|
||||
members: ensureArray(t.members),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
async createOrganization(name: string): Promise<Organization> {
|
||||
@@ -443,8 +523,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getOrgTeams(orgId: string): Promise<Team[]> {
|
||||
return request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
||||
@@ -454,14 +539,22 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getUserTeams(): Promise<Team[]> {
|
||||
return request<Team[]>('/api/v1/teams');
|
||||
async deleteTeam(teamId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async addTeamMember(teamId: string, userId: string, role = 'editor'): Promise<void> {
|
||||
async getUserTeams(): Promise<Team[]> {
|
||||
const res = await request<Team[]>('/api/v1/teams');
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async addTeamMember(
|
||||
teamId: string,
|
||||
payload: { userId?: string; email?: string; role: string }
|
||||
): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, role }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -469,12 +562,18 @@ export const api = {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- ADMIN PANEL ---
|
||||
// --- ADMIN & STAFF PANEL ---
|
||||
async adminGetUsers(): Promise<User[]> {
|
||||
return request<User[]>('/api/v1/admin/users');
|
||||
const res = await request<User[]>('/api/v1/admin/users');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async adminCreateUser(payload: { email: string; password: string; displayName: string; role: string }): Promise<User> {
|
||||
async adminCreateUser(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -483,7 +582,13 @@ export const api = {
|
||||
|
||||
async adminUpdateUser(
|
||||
id: string,
|
||||
patch: { displayName?: string; email?: string; role?: string; isActive?: boolean; discordWebhookUrl?: string }
|
||||
patch: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
discordWebhookUrl?: string;
|
||||
}
|
||||
): Promise<User> {
|
||||
return request<User>(`/api/v1/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -502,6 +607,26 @@ export const api = {
|
||||
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminGetInvites(): Promise<UserInvite[]> {
|
||||
const res = await request<UserInvite[]>('/api/v1/admin/invites');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async adminCreateInvite(payload: {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: string;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteInvite(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminGetSettings(): Promise<SystemSettings> {
|
||||
return request<SystemSettings>('/api/v1/admin/settings');
|
||||
},
|
||||
@@ -513,9 +638,17 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ toEmail }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
return request<APIKey[]>('/api/v1/keys');
|
||||
const res = await request<APIKey[]>('/api/v1/keys');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createAPIKey(name: string): Promise<APIKey> {
|
||||
@@ -529,15 +662,25 @@ export const api = {
|
||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async testWebhookIngest(token: string, title: string, priority: PriorityLevel, tags: string[]): Promise<{ status: string; thought: Thought }> {
|
||||
return request<{ status: string; thought: Thought }>(`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, priority, tags }),
|
||||
});
|
||||
async testWebhookIngest(
|
||||
token: string,
|
||||
title: string,
|
||||
priority: PriorityLevel,
|
||||
tags: string[]
|
||||
): Promise<{ status: string; thought: Thought }> {
|
||||
return request<{ status: string; thought: Thought }>(
|
||||
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, priority, tags }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(`/api/v1/search?q=${encodeURIComponent(q)}`);
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
|
||||
async exportAll(): Promise<any> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user