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

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

No files matched your search

+305 -91
View File
@@ -1,23 +1,83 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
FileText,
ShoppingCart,
Brain,
Settings,
Shield,
LogOut,
ChevronDown,
RotateCcw,
} from 'lucide-react';
import { DashboardStats, SharedList, Thought, User } from './lib/types';
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SettingsModal } from './features/settings/SettingsModal';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists';
type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'user_settings'
| 'admin_settings';
interface ErrorBoundaryState {
hasError: boolean;
errorText: string;
}
class ViewErrorBoundary extends React.Component<
{ children: React.ReactNode; onReset: () => void },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; onReset: () => void }) {
super(props);
this.state = { hasError: false, errorText: '' };
}
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
}
render() {
if (this.state.hasError) {
return (
<div className="p-6 bg-panel border border-border text-xs space-y-3 max-w-lg mx-auto my-8">
<h3 className="text-sm font-bold text-white">Ansicht konnte nicht geladen werden</h3>
<p className="text-zinc-400">{this.state.errorText}</p>
<button
onClick={() => {
this.setState({ hasError: false, errorText: '' });
this.props.onReset();
}}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Ansicht zurücksetzen</span>
</button>
</div>
);
}
return this.props.children;
}
}
export const App: React.FC = () => {
const [bootLoading, setBootLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus>({
needsSetup: false,
allowRegistration: false,
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
@@ -34,7 +94,9 @@ export const App: React.FC = () => {
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
const [showProfileModal, setShowProfileModal] = useState(false);
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const refreshAll = async () => {
try {
@@ -45,49 +107,85 @@ export const App: React.FC = () => {
]);
setUser(meRes.user);
setStats(meRes.stats);
setDeck(deckRes);
setLists(listsRes);
setDeck(Array.isArray(deckRes) ? deckRes : []);
setLists(Array.isArray(listsRes) ? listsRes : []);
} catch {
// Fallback to quickStart session
try {
const qs = await api.quickStart();
setUser(qs.user);
const [st, dk, ls] = await Promise.all([
api.getStats(),
api.getTriageDeck(),
api.getAllLists(),
]);
setStats(st);
setDeck(dk);
setLists(ls);
} catch {
// Offline or waiting for server
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
}
};
const checkBootAndAuth = async () => {
setBootLoading(true);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
if (st.needsSetup) {
clearToken();
setUser(null);
return;
}
if (getToken()) {
await api.flushOfflineQueue();
await refreshAll();
} else {
setUser(null);
}
} catch {
// Server temporarily unreachable
} finally {
setBootLoading(false);
}
};
useEffect(() => {
const init = async () => {
if (!getToken()) {
try {
await api.quickStart();
} catch {
// ignore
}
checkBootAndAuth();
const onUnauthorized = async () => {
setUser(null);
setShowProfileMenu(false);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
await api.flushOfflineQueue();
await refreshAll();
};
init();
const onOnline = async () => {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
if (getToken()) {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
}
};
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
return () => {
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
}, []);
// Close profile dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProfileMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAuthenticated = async (loggedInUser: User) => {
setUser(loggedInUser);
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
setActiveTab('triage');
await refreshAll();
};
const handleCaptured = (
thought: Thought,
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
@@ -110,7 +208,6 @@ export const App: React.FC = () => {
const handleTriggerAddButton = () => {
triggerHaptic(20);
// Scroll smoothly to top and focus capture bar
window.scrollTo({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
};
@@ -118,11 +215,43 @@ export const App: React.FC = () => {
const handleLogout = async () => {
triggerHaptic(20);
clearToken();
await api.quickStart();
await refreshAll();
setShowProfileModal(false);
setUser(null);
setShowProfileMenu(false);
setActiveTab('triage');
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
} catch {
// ignore
}
};
if (bootLoading) {
return (
<div className="min-h-screen bg-canvas text-zinc-400 flex items-center justify-center text-xs font-sans">
Verbinde mit MindSift…
</div>
);
}
// Show Setup Wizard or Login Screen if needsSetup or not logged in
if (authStatus.needsSetup || !user) {
return (
<SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
/>
);
}
const isStaff = user.role === 'admin' || user.role === 'team_admin';
const roleLabel =
user.role === 'admin'
? 'Server-Admin'
: user.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="min-h-screen bg-canvas text-white flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar */}
@@ -141,7 +270,7 @@ export const App: React.FC = () => {
</div>
<div className="flex flex-col">
<span className="font-bold text-sm sm:text-base text-white tracking-tight leading-none">
MindSift
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[11px] text-zinc-400 mt-0.5 hidden sm:inline-block">
Der Kopf frei in drei Sekunden
@@ -158,21 +287,83 @@ export const App: React.FC = () => {
/>
</div>
{/* User Profile Pill */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* User Profile Pill & Dropdown Menu */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
<button
onClick={() => {
triggerHaptic(10);
setShowProfileModal(true);
setShowProfileMenu((prev) => !prev);
}}
className="h-8 pl-1 pr-3 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
title="Profil & Einstellungen"
className="h-8 pl-1 pr-2.5 rounded-full bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-white flex items-center gap-2 transition-colors"
title="Benutzermenü öffnen"
>
<div className="w-6 h-6 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center">
{user?.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<span className="max-w-[120px] truncate hidden sm:inline">{user?.displayName || 'Konto'}</span>
<span className="max-w-[120px] truncate hidden sm:inline">
{user.displayName}
</span>
<ChevronDown className="w-3.5 h-3.5 text-zinc-400" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-60 bg-panel border border-border shadow-2xl z-50 divide-y divide-border text-xs">
{/* User Info Header */}
<div className="p-3 bg-canvas">
<p className="font-bold text-white truncate">{user.displayName}</p>
<p className="text-[11px] text-zinc-400 truncate">{user.email}</p>
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('user_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-zinc-200'
}`}
>
<Settings className="w-4 h-4 text-accent" />
<span>Kontoeinstellungen</span>
</button>
{isStaff && (
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('admin_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'admin_settings'
? 'text-accent font-semibold'
: 'text-zinc-200'
}`}
>
<Shield className="w-4 h-4 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Logout */}
<div className="py-1">
<button
onClick={handleLogout}
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-red-400 hover:bg-red-950/30 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Abmelden</span>
</button>
</div>
</div>
)}
</div>
</div>
@@ -189,7 +380,13 @@ export const App: React.FC = () => {
<Layers className="w-4 h-4" />
<span>Triage</span>
{stats.inboxCount > 0 && (
<span className={activeTab === 'triage' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'triage'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.inboxCount}
</span>
)}
@@ -205,7 +402,13 @@ export const App: React.FC = () => {
<CheckSquare className="w-4 h-4" />
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className={activeTab === 'tasks' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'tasks'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.tasksOpenCount}
</span>
)}
@@ -220,7 +423,13 @@ export const App: React.FC = () => {
>
<FileText className="w-4 h-4" />
<span>Workspace</span>
<span className={activeTab === 'workspace' ? 'badge-accent' : 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'}>
<span
className={
activeTab === 'workspace'
? 'badge-accent'
: 'px-1.5 py-0.5 text-[10px] bg-canvas text-zinc-400 border border-border'
}
>
{stats.docsCount}
</span>
</button>
@@ -235,58 +444,63 @@ export const App: React.FC = () => {
<ShoppingCart className="w-4 h-4" />
<span>Geteilte Listen</span>
</button>
</div>
</nav>
</header>
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
{/* Tab View Content */}
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
/>
)}
{activeTab === 'tasks' && (
<TasksView
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/>
)}
{activeTab === 'tasks' && (
<TasksView
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
)}
</section>
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
)}
{activeTab === 'user_settings' && (
<UserSettingsView
currentUser={user}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onBack={() => setActiveTab('triage')}
/>
)}
{activeTab === 'admin_settings' && isStaff && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
/>
)}
</section>
</ViewErrorBoundary>
</main>
{/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */}
{showProfileModal && (
<SettingsModal
currentUser={user}
onClose={() => setShowProfileModal(false)}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onLoggedOut={handleLogout}
/>
)}
</div>
);
};