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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user