import { useState, useEffect } from 'react' import { authApi, ocrApi, type User, type OcrStatus } from '../api/client' import { applyTheme } from '../theme' import { useAppSettings } from '../hooks/useAppSettings' import AppPasswords from '../components/AppPasswords' import PageHeader from '../components/PageHeader' import Tabs from '../components/Tabs' interface Props { user: User /** Nach dem Speichern aufrufen, damit Seitenleiste und Kopfzeile den * neuen Anzeigenamen übernehmen – sonst bliebe der alte bis zum Neuladen. */ onUserChanged?: () => Promise } function formatBytes(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB` if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB` return `${(n / 1024 ** 3).toFixed(1)} GB` } function Card({ children, title }: { children: React.ReactNode; title: string }) { return (

{title}

{children}
) } /** * Der Stand der Texterkennung – für die **eigenen** Dateien. * * Bis zum 23.08.2026 sah diese Zahl nur der Administrator. Das ist die falsche * Person: Wissen muss es, wer gerade eine Rechnung hochgeladen hat und sie * nicht findet. „Wird noch gelesen" und „ist nicht drin" sehen in einer leeren * Trefferliste identisch aus. */ function IndexingCard() { const [status, setStatus] = useState(null) useEffect(() => { let alive = true let timer: ReturnType async function tick() { try { const s = await ocrApi.myStatus() if (!alive) return setStatus(s) // Nur nachfragen, solange sich etwas bewegt – steht die Warteschlange, // ändert sich die Zahl erst beim nächsten Upload. if ((s.pending ?? 0) + (s.running ?? 0) > 0) timer = setTimeout(tick, 5000) } catch { if (alive) setStatus(null) } } tick() return () => { alive = false; clearTimeout(timer) } }, []) if (!status) return null const pending = (status.pending ?? 0) + (status.running ?? 0) const indexed = status.indexed ?? 0 const bad = status.failed ?? 0 let text: string if (!status.enabled) { text = 'Turned off on this server — the contents of your documents are not searchable, only their names.' } else if (pending > 0) { text = `${indexed} read so far, ${pending} still to go. A scanned page takes about a minute — search will find them once they are done.` } else if (indexed === 0) { text = 'Nothing read yet. PDFs and images are read automatically after upload, so their contents become searchable.' } else { text = `${indexed} ${indexed === 1 ? 'document is' : 'documents are'} searchable by their contents — not just by file name.` } if (bad > 0) text += ` ${bad} could not be read (damaged or password-protected).` return (
0 ? 'var(--color-warning)' : 'var(--color-success)', }} />

{text}

) } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
) } function Input({ value, onChange, type = 'text', placeholder, disabled }: { value: string; onChange?: (v: string) => void; type?: string; placeholder?: string; disabled?: boolean }) { return ( onChange?.(e.target.value)} placeholder={placeholder} disabled={disabled} style={{ padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)', background: disabled ? 'var(--color-surface-2)' : 'var(--color-surface-2)', color: disabled ? 'var(--color-text-muted)' : 'var(--color-text)', fontSize: 13, outline: 'none', opacity: disabled ? 0.7 : 1, }} /> ) } function SaveBtn({ onClick, loading, saved, disabled }: { onClick: () => void; loading: boolean; saved: boolean; disabled?: boolean }) { return (
{saved && ✓ Saved}
) } type Tab = 'account' | 'access' | 'appearance' | 'storage' /** * Reiter statt vier Abschnitte untereinander – dieselbe Aufteilung wie im * Admin-Bereich, über dieselbe Komponente. * * Sortiert nach dem, was man tatsächlich aufsucht: erst das eigene Konto, dann * der Zugang für Geräte, dann das Aussehen. Der Speicher steht zuletzt; er ist * eine Anzeige, kein Bedienelement, und derselbe Balken steht ohnehin unten in * der Seitenleiste. Vorher stand er ganz oben und war das Erste, was man sah. */ export default function ProfilePage({ user, onUserChanged }: Props) { const [tab, setTab] = useState('account') const tabs: { id: Tab; label: string }[] = [ { id: 'account', label: 'Account' }, { id: 'access', label: 'Access' }, { id: 'appearance', label: 'Appearance' }, { id: 'storage', label: 'Storage' }, ] // Profile const [displayName, setDisplayName] = useState(user.display_name ?? '') const [profileLoading, setProfileLoading] = useState(false) const [profileSaved, setProfileSaved] = useState(false) // Password const [currentPw, setCurrentPw] = useState('') const [newPw, setNewPw] = useState('') const [confirmPw, setConfirmPw] = useState('') const [pwLoading, setPwLoading] = useState(false) const [pwSaved, setPwSaved] = useState(false) const [pwError, setPwError] = useState('') // Persönliche Palette. Leer heisst: die globale Einstellung des // Administrators gilt – der Farbwähler zeigt dann deren Wert als Ausgangspunkt. const { globalColors, refresh: refreshSettings } = useAppSettings() const [primary, setPrimary] = useState(user.primary_color || globalColors.primary || '#6366f1') const [accent, setAccent] = useState(user.accent_color || globalColors.accent || '#8b5cf6') const [useCustom, setUseCustom] = useState(user.use_custom_theme) const [themeLoading, setThemeLoading] = useState(false) const [themeSaved, setThemeSaved] = useState(false) // Vorschau: die Wahl wirkt sofort auf die ganze Oberfläche. Wird der Bereich // ohne Speichern verlassen, stellt der Abgleich den gesicherten Stand her. function preview(p: string, a: string) { if (useCustom) applyTheme(p, a) } // Umschalten wirkt sofort und speichert – die Farben bleiben dabei erhalten, // man kann also beliebig hin und her wechseln. async function toggleCustom(enabled: boolean) { setUseCustom(enabled) applyTheme( enabled ? primary : globalColors.primary, enabled ? accent : globalColors.accent, ) await authApi.setUseCustomTheme(enabled).catch(() => {}) await onUserChanged?.() } useEffect(() => { return () => { void refreshSettings() applyTheme( user.use_custom_theme ? (user.primary_color || globalColors.primary) : globalColors.primary, user.use_custom_theme ? (user.accent_color || globalColors.accent) : globalColors.accent, ) } }, []) // eslint-disable-line react-hooks/exhaustive-deps async function saveProfile() { setProfileLoading(true) await authApi.updateProfile(displayName).catch(() => {}) await onUserChanged?.() setProfileLoading(false) setProfileSaved(true) setTimeout(() => setProfileSaved(false), 2000) } async function changePassword() { setPwError('') if (newPw !== confirmPw) { setPwError('Passwords do not match'); return } if (newPw.length < 8) { setPwError('At least 8 characters'); return } setPwLoading(true) try { await authApi.changePassword(currentPw, newPw) setCurrentPw(''); setNewPw(''); setConfirmPw('') setPwSaved(true) setTimeout(() => setPwSaved(false), 2000) } catch { setPwError('Current password is incorrect') } finally { setPwLoading(false) } } async function saveTheme() { setThemeLoading(true) const result = await authApi.updateColors(primary, accent).catch(() => null) // Wer Farben speichert, will sie auch sehen. if (result && !useCustom) { setUseCustom(true) await authApi.setUseCustomTheme(true).catch(() => {}) applyTheme(primary, accent) } setThemeLoading(false) if (result) { // Nutzer neu laden, damit die Palette in der gesamten Anwendung greift. await onUserChanged?.() setThemeSaved(true) setTimeout(() => setThemeSaved(false), 2000) } } const usedPct = user.quota_bytes > 0 ? Math.min((user.used_bytes / user.quota_bytes) * 100, 100) : 0 return (
} title="Settings" meta="Your account, devices and appearance" />
{tab === 'account' && (<> {/* Passwortänderung nur für Konten, die hier auch ein Passwort haben. Wer sich über den Identitätsanbieter anmeldet, hat keines – das Formular könnte nur fehlschlagen, und zwar mit „current password incorrect", was nach einem Tippfehler aussieht statt nach dem wahren Grund. Der Server weist den Aufruf zusätzlich ab; das hier ist die Erklärung, nicht die Absicherung. */} {user.is_oidc ? (

You sign in through your identity provider, so this account has no password here. Change it where you sign in.

) : ( {pwError &&

{pwError}

}
)} )} {tab === 'access' && (<> {/* App-Passwörter sind der einzige Weg, ein Gerät anzubinden – das Kontopasswort wird an /dav/ abgelehnt. Der Hinweis gehört deshalb hierher und nicht in die Dokumentation. */}

For WebDAV and sync clients. Your account password is rejected there on purpose — it is also the admin login. Each app password can be revoked on its own without locking out your other devices.

)} {tab === 'appearance' && (<> {/* Schalter zuerst: er bestimmt, ob die Farbwahl darunter überhaupt greift. Die Werte bleiben in beiden Stellungen gespeichert. */}
Use my own colours
{useCustom ? 'Your palette is active. Turn off to use the server theme.' : 'Using the server theme. Your colours stay saved.'}
{/* Ausgegraut statt versteckt: man sieht, welche Farben hinterlegt sind, auch während das Server-Thema aktiv ist. */}
{ setPrimary(e.target.value); preview(e.target.value, accent) }} style={{ width: 40, height: 36, borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2 }} /> {primary}
{ setAccent(e.target.value); preview(primary, e.target.value) }} style={{ width: 40, height: 36, borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2 }} /> {accent}
)} {tab === 'storage' && (<> {/* Reine Anzeige – ändern lässt sich das Kontingent nur im Admin-Bereich. Der Kartentitel wiederholt die Abschnittsüberschrift deshalb nicht, sondern sagt, was in der Karte steht. */}
{formatBytes(user.used_bytes)} used {formatBytes(user.quota_bytes)} total
90 ? 'var(--color-danger)' : 'var(--color-primary)', transition: 'width 0.3s', }} />
)}
) }