feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+429
View File
@@ -0,0 +1,429 @@
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<void>
}
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 (
<div style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24, marginBottom: 20,
}}>
<h3 style={{ margin: '0 0 18px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
{children}
</div>
)
}
/**
* 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<OcrStatus | null>(null)
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
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 (
<Card title="Document search">
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: !status.enabled
? 'var(--color-text-muted)'
: pending > 0 ? 'var(--color-warning)' : 'var(--color-success)',
}} />
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
{text}
</p>
</div>
</Card>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 14 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>{label}</label>
{children}
</div>
)
}
function Input({ value, onChange, type = 'text', placeholder, disabled }: {
value: string; onChange?: (v: string) => void; type?: string; placeholder?: string; disabled?: boolean
}) {
return (
<input
type={type}
value={value}
onChange={e => 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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<button
onClick={onClick}
disabled={loading || disabled}
style={{
padding: '8px 20px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600, cursor: loading || disabled ? 'default' : 'pointer',
opacity: loading || disabled ? 0.7 : 1,
}}
>
{loading ? 'Saving…' : 'Save'}
</button>
{saved && <span style={{ fontSize: 12, color: '#22c55e', fontWeight: 600 }}>✓ Saved</span>}
</div>
)
}
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<Tab>('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 (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<PageHeader
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a8 8 0 0116 0v1" />
</svg>
}
title="Settings"
meta="Your account, devices and appearance"
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
<div style={{ maxWidth: 560 }}>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === 'account' && (<>
<Card title="Profile">
<Field label="Username">
<Input value={user.username} disabled />
</Field>
<Field label="Display name">
<Input value={displayName} onChange={setDisplayName} placeholder="Your name" />
</Field>
<SaveBtn onClick={saveProfile} loading={profileLoading} saved={profileSaved} />
</Card>
{/* 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 ? (
<Card title="Password">
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
You sign in through your identity provider, so this account has no
password here. Change it where you sign in.
</p>
</Card>
) : (
<Card title="Change password">
<Field label="Current password">
<Input type="password" value={currentPw} onChange={setCurrentPw} />
</Field>
<Field label="New password">
<Input type="password" value={newPw} onChange={setNewPw} placeholder="At least 8 characters" />
</Field>
<Field label="Confirm new password">
<Input type="password" value={confirmPw} onChange={setConfirmPw} />
</Field>
{pwError && <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--color-danger)' }}>{pwError}</p>}
<SaveBtn onClick={changePassword} loading={pwLoading} saved={pwSaved} disabled={!currentPw || !newPw || !confirmPw} />
</Card>
)}
</>)}
{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. */}
<Card title="App passwords">
<p style={{ margin: '0 0 16px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
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.
</p>
<AppPasswords username={user.username} />
</Card>
</>)}
{tab === 'appearance' && (<>
<Card title="Colours">
{/* Schalter zuerst: er bestimmt, ob die Farbwahl darunter überhaupt
greift. Die Werte bleiben in beiden Stellungen gespeichert. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', marginBottom: 16, borderRadius: 10,
background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Use my own colours
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{useCustom
? 'Your palette is active. Turn off to use the server theme.'
: 'Using the server theme. Your colours stay saved.'}
</div>
</div>
<button
role="switch"
aria-checked={useCustom}
onClick={() => toggleCustom(!useCustom)}
style={{
width: 42, height: 24, borderRadius: 999, flexShrink: 0,
border: 'none', cursor: 'pointer', padding: 3,
background: useCustom ? 'var(--color-primary)' : 'var(--color-border)',
display: 'flex', justifyContent: useCustom ? 'flex-end' : 'flex-start',
transition: 'background 0.15s',
}}
>
<span style={{
width: 18, height: 18, borderRadius: '50%', background: '#fff',
display: 'block', transition: 'transform 0.15s',
}} />
</button>
</div>
{/* Ausgegraut statt versteckt: man sieht, welche Farben hinterlegt
sind, auch während das Server-Thema aktiv ist. */}
<div style={{ opacity: useCustom ? 1 : 0.45, pointerEvents: useCustom ? 'auto' : 'none' }}>
<Field label="Primary colour">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={primary}
onChange={e => { 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 }}
/>
<span style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--color-text)' }}>{primary}</span>
</div>
</Field>
<Field label="Accent colour">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={accent}
onChange={e => { 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 }}
/>
<span style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--color-text)' }}>{accent}</span>
</div>
</Field>
<SaveBtn onClick={saveTheme} loading={themeLoading} saved={themeSaved} />
</div>
</Card>
</>)}
{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. */}
<Card title="Space used">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 13, color: 'var(--color-text)' }}>{formatBytes(user.used_bytes)} used</span>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{formatBytes(user.quota_bytes)} total</span>
</div>
<div style={{ height: 8, background: 'var(--color-surface-2)', borderRadius: 999 }}>
<div style={{
width: `${usedPct}%`, height: '100%', borderRadius: 999,
background: usedPct > 90 ? 'var(--color-danger)' : 'var(--color-primary)',
transition: 'width 0.3s',
}} />
</div>
</Card>
<IndexingCard />
</>)}
</div>
</div>
</div>
)
}