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

+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect } from 'react'
import { adminApi, type AdminUser } from '../api/client'
import PageHeader from '../components/PageHeader'
import Tabs from '../components/Tabs'
type Tab = 'users' | 'groups' | 'theming' | 'audit'
import UsersTab from './admin/UsersTab'
import GroupsTab from './admin/GroupsTab'
import ThemingTab from './admin/ThemingTab'
import AuditTab from './admin/AuditTab'
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('users')
const [allUsers, setAllUsers] = useState<AdminUser[]>([])
useEffect(() => {
adminApi.listUsers().then(data => setAllUsers(data ?? [])).catch(() => {})
}, [])
const tabs: { id: Tab; label: string }[] = [
{ id: 'users', label: 'Users' },
{ id: 'groups', label: 'Groups' },
{ id: 'theming', label: 'Theming' },
// „Activity" statt „Audit log": Der Reiter zeigt nicht mehr nur das
// Protokoll, sondern davor auch den Stand der Texterkennung und die
// Speicherbereinigung – alles, was im Hintergrund läuft. Die Kennung
// bleibt 'audit', sie steht in keiner Adresse und muss nicht wandern.
{ id: 'audit', label: 'Activity' },
]
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">
<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z" />
<path d="M9.5 12.5l1.8 1.8 3.4-3.6" />
</svg>
}
title="Admin panel"
meta="Users, groups, appearance and activity"
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab allUsers={allUsers} />}
{tab === 'theming' && <ThemingTab />}
{tab === 'audit' && <AuditTab />}
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
import { useState, useEffect, type FormEvent } from 'react'
import { APIError, publicApi, type PublicConfig } from '../api/client'
interface Props {
onLogin: (username: string, password: string) => Promise<void>
}
export default function LoginPage({ onLogin }: Props) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<PublicConfig>({ oidc_enabled: false, cloud_name: 'Aether', external_apps: [] })
useEffect(() => {
// Show OIDC error from redirect if present
const params = new URLSearchParams(window.location.search)
if (params.get('error')) setError('SSO sign-in failed. Please sign in locally.')
publicApi.config().then(setConfig).catch(() => {})
}, [])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await onLogin(username, password)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo / branding */}
<div className="text-center mb-8">
{/* Eigenes Logo ersetzt die Standardwolke, wenn eines hinterlegt ist.
Kein farbiger Kasten dahinter: Ein Logo bringt seinen eigenen
Hintergrund mit, und ein Kachelrahmen drumherum sähe nach Versehen
aus. */}
{config.logo_url ? (
<img
src={config.logo_url}
alt={config.cloud_name}
className="mx-auto mb-4 object-contain"
style={{ maxHeight: 64, maxWidth: 200 }}
/>
) : (
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
style={{ background: 'var(--color-primary)' }}>
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
)}
<h1 className="text-2xl font-semibold" style={{ color: 'var(--color-text)' }}>{config.cloud_name}</h1>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>Sign in</p>
</div>
{/* OIDC login */}
{config.oidc_enabled && (
<a
href="/api/v1/auth/oidc/login"
className="flex items-center justify-center gap-3 w-full rounded-xl px-4 py-3 text-sm font-semibold mb-4 border transition-all"
style={{
background: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
textDecoration: 'none',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--color-border)')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4l3 3" />
</svg>
Sign in with SSO / OIDC
</a>
)}
{config.oidc_enabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--color-border)' }} />
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>or</span>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--color-border)' }} />
</div>
)}
<form onSubmit={handleSubmit}
className="rounded-xl p-6 space-y-4 border"
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
{error && (
<div className="rounded-lg px-4 py-3 text-sm"
style={{ background: '#7f1d1d33', color: '#fca5a5', border: '1px solid #7f1d1d' }}>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-muted)' }}>
Username
</label>
<input
type="text"
autoComplete="username"
required
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none transition-all"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-muted)' }}>
Password
</label>
<input
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none transition-all"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg py-2.5 text-sm font-semibold transition-opacity disabled:opacity-60"
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{/* WebDAV hint */}
<p style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-muted)', marginTop: 16 }}>
WebDAV: <code style={{ fontFamily: 'monospace' }}>/dav/</code>
</p>
</div>
</div>
)
}
+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>
)
}
+177
View File
@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react'
import { sharesApi, type PublicShareInfo } from '../api/client'
interface Props {
token: string
}
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`
}
export default function SharePage({ token }: Props) {
const [info, setInfo] = useState<PublicShareInfo | null>(null)
const [error, setError] = useState('')
const [password, setPassword] = useState('')
const [needsPassword, setNeedsPassword] = useState(false)
const [downloading, setDownloading] = useState(false)
const [wrongPassword, setWrongPassword] = useState(false)
useEffect(() => {
sharesApi.getInfo(token)
.then(data => {
setInfo(data)
setNeedsPassword(data.link.has_password)
})
.catch(() => setError('This link is not valid or has expired.'))
}, [token])
async function download() {
setDownloading(true)
setWrongPassword(false)
try {
const res = await sharesApi.download(token, needsPassword ? password : undefined)
if (res.status === 401) {
setWrongPassword(true)
setDownloading(false)
return
}
if (!res.ok) {
setError('Download failed.')
setDownloading(false)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = info?.file_name ?? 'download'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Download failed.')
}
setDownloading(false)
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-background)',
padding: 24,
}}>
{/* Brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 40 }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
<span style={{ fontWeight: 700, fontSize: 18, color: 'var(--color-text)' }}>Aether</span>
</div>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: 32,
width: 420,
maxWidth: '100%',
textAlign: 'center',
}}>
{error ? (
<>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔗</div>
<h2 style={{ margin: '0 0 8px', fontSize: 18, color: 'var(--color-text)' }}>Link not found</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>{error}</p>
</>
) : !info ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
) : (
<>
{/* File icon */}
<div style={{ fontSize: 52, marginBottom: 16 }}>
{info.mime_type.startsWith('image/') ? '🖼' :
info.mime_type.startsWith('video/') ? '🎬' :
info.mime_type.startsWith('audio/') ? '🎵' :
info.mime_type.includes('pdf') ? '📄' :
info.mime_type.includes('zip') ? '📦' : '📁'}
</div>
<h2 style={{ margin: '0 0 6px', fontSize: 18, fontWeight: 600, color: 'var(--color-text)', wordBreak: 'break-word' }}>
{info.file_name}
</h2>
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
{formatBytes(info.file_size)}
{info.link.expires_at && (
<span> · Expires {new Date(info.link.expires_at).toLocaleDateString()}</span>
)}
</p>
{needsPassword && (
<div style={{ marginBottom: 16 }}>
<input
type="password"
placeholder="Enter password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && download()}
style={{
width: '100%', padding: '10px 14px', borderRadius: 8,
border: `1px solid ${wrongPassword ? 'var(--color-danger)' : 'var(--color-border)'}`,
background: 'var(--color-surface-2)',
color: 'var(--color-text)', fontSize: 14, outline: 'none',
boxSizing: 'border-box',
}}
/>
{wrongPassword && (
<p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>
Incorrect password
</p>
)}
</div>
)}
<button
onClick={download}
disabled={downloading || (needsPassword && !password)}
style={{
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none',
background: 'var(--color-primary)', color: '#fff',
fontSize: 15, fontWeight: 600, cursor: downloading ? 'wait' : 'pointer',
opacity: (downloading || (needsPassword && !password)) ? 0.7 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{downloading ? 'Downloading…' : 'Download'}
</button>
<p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
Shared via Aether · Downloaded {info.link.download_count}×
</p>
</>
)}
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from 'react'
import { sharesApi, type ShareLink } from '../api/client'
import ConfirmDialog from '../components/ConfirmDialog'
import PageHeader from '../components/PageHeader'
function shareURL(token: string): string {
return `${window.location.origin}/s/${token}`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export default function SharedPage() {
const [links, setLinks] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<ShareLink | null>(null)
useEffect(() => {
sharesApi.list()
.then(data => setLinks(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
async function copy(token: string) {
await navigator.clipboard.writeText(shareURL(token))
setCopied(token)
setTimeout(() => setCopied(null), 2000)
}
async function deleteLink(link: ShareLink) {
await sharesApi.delete(link.id)
setLinks(l => l.filter(x => x.id !== link.id))
setConfirmDelete(null)
}
const now = new Date()
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">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
}
title="Shared links"
meta={links.length > 0 ? `${links.length} link${links.length !== 1 ? 's' : ''}` : undefined}
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)}
{!loading && links.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>🔗</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>No shared links yet</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: 12, margin: 0 }}>Select a file and click "Share" to create a link</p>
</div>
)}
{!loading && links.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{links.map(link => {
const expired = link.expires_at ? new Date(link.expires_at) < now : false
return (
<div key={link.id} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px',
background: 'var(--color-surface)',
border: `1px solid ${expired ? 'var(--color-danger)' : 'var(--color-border)'}`,
borderRadius: 10,
opacity: expired ? 0.6 : 1,
}}>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
background: expired ? 'rgba(239,68,68,0.1)' : 'rgba(var(--color-primary-rgb), 0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke={expired ? 'var(--color-danger)' : 'var(--color-primary)'} strokeWidth="2">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
</svg>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{
margin: 0, fontSize: 12, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: 'monospace',
}}>
{shareURL(link.token)}
</p>
<div style={{ marginTop: 4, display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 11, color: 'var(--color-text-muted)' }}>
<span>Created {formatDate(link.created_at)}</span>
{link.has_password && <span>🔒 Password protected</span>}
{link.expires_at && (
<span style={{ color: expired ? 'var(--color-danger)' : 'inherit' }}>
{expired ? 'Expired' : 'Expires'} {formatDateTime(link.expires_at)}
</span>
)}
<span>{link.download_count}× downloaded</span>
</div>
</div>
{/* Actions */}
<button
onClick={() => copy(link.token)}
title="Copy link"
style={{
padding: '5px 12px', borderRadius: 6, flexShrink: 0,
border: '1px solid var(--color-border)',
background: copied === link.token ? 'rgba(34,197,94,0.1)' : 'transparent',
color: copied === link.token ? '#22c55e' : 'var(--color-text)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
{copied === link.token ? '✓ Copied' : 'Copy link'}
</button>
<button
onClick={() => setConfirmDelete(link)}
title="Delete link"
style={{
padding: '5px 8px', borderRadius: 6, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)',
cursor: 'pointer', display: 'flex', alignItems: 'center',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete share link"
message="The link will stop working immediately. The file itself is not affected."
confirmLabel="Delete link"
danger
onConfirm={() => deleteLink(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react'
import { aclApi, filesApi, type SharedResource } from '../api/client'
import { formatBytes } from './FileBrowser'
import PageHeader from '../components/PageHeader'
import { FolderGlyph } from '../components/icons'
interface Props {
/** Öffnet einen freigegebenen Ordner in der Dateiansicht. */
onOpenFolder: (folderId: string, folderName: string) => void
}
/** „Geteilt mit mir": alles, worauf man Zugriff bekommen hat, ohne es zu
* besitzen. Die Daten liegen weiterhin beim Eigentümer – hier wird nur
* darauf verwiesen. */
export default function SharedWithMePage({ onOpenFolder }: Props) {
const [items, setItems] = useState<SharedResource[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
aclApi.sharedWithMe()
.then(d => setItems(d ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
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">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<circle cx="12" cy="13" r="2" />
<path d="M8.5 18a3.5 3.5 0 017 0" />
</svg>
}
title="Shared with me"
meta="Files and folders other people gave you access to"
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : items.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', borderRadius: 12,
border: '1px dashed var(--color-border)',
}}>
<p style={{ margin: 0, fontSize: 14, color: 'var(--color-text)' }}>Nothing shared with you yet</p>
<p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
When someone shares a file or folder, it shows up here.
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 760 }}>
{items.map(item => (
<div
key={`${item.type}-${item.id}`}
onClick={() => item.type === 'folder' && onOpenFolder(item.id, item.name)}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '13px 16px', borderRadius: 11,
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
cursor: item.type === 'folder' ? 'pointer' : 'default',
}}
onMouseEnter={e => { if (item.type === 'folder') e.currentTarget.style.borderColor = 'var(--color-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)' }}
>
<span style={{ flexShrink: 0, display: 'flex' }}>
{item.type === 'folder' ? (
<FolderGlyph size={24} />
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.8">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
Shared by {item.owner_name}
{/* Ohne diesen Hinweis wundert man sich, woher eine Freigabe
kommt, die man nie persönlich bekommen hat. */}
{item.via_group && ` via ${item.via_group}`}
{item.type === 'file' && ` · ${formatBytes(item.size)}`}
</div>
</div>
<span style={{
flexShrink: 0, padding: '3px 10px', borderRadius: 999,
fontSize: 11, fontWeight: 600,
background: item.permission === 'write'
? 'rgba(var(--color-primary-rgb), 0.12)'
: 'var(--color-surface-2)',
color: item.permission === 'write' ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{item.permission === 'write' ? 'Can edit' : 'View only'}
</span>
{item.type === 'file' && (
<a
href={filesApi.download(item.id)}
download={item.name}
onClick={e => e.stopPropagation()}
style={{
flexShrink: 0, padding: '5px 12px', borderRadius: 7,
border: '1px solid var(--color-border)',
color: 'var(--color-text)', fontSize: 12, textDecoration: 'none',
}}
>
Download
</a>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useState } from 'react'
import { tagsApi, correspondentsApi, filesApi, downloadZip, type Correspondent, type Tag, type TaggedFile } from '../api/client'
import PageHeader from '../components/PageHeader'
import { FileTypeIcon } from '../components/icons'
import TagChip, { tagColor } from '../components/TagChip'
import { formatBytes } from './FileBrowser'
/**
* Alles, was ein Etikett trägt – über Ordnergrenzen hinweg.
*
* Das ist die Ansicht, für die Etiketten gebaut werden. Die Suche beantwortet
* „wo steht dieses Wort"; hier geht es um „zeig mir alles, was ich als
* absetzbar markiert habe" – eine Frage, die kein Volltext beantworten kann,
* weil „absetzbar" in keiner Rechnung steht.
*
* Sortiert nach Belegdatum, nicht nach Namen: Wer im März alle Belege des
* Vorjahres zusammensucht, will sie in zeitlicher Ordnung.
*/
interface Props {
tagId?: string
/**
* Absender statt Etikett. Dieselbe Seite, weil es dieselbe Frage in anderer
* Richtung ist: „zeig mir alles mit diesem Merkmal, quer über die Ordner".
* Eine zweite, fast gleiche Seite liefe mit der Zeit auseinander – genau
* das war bei den Kopfleisten schon einmal passiert.
*/
correspondentId?: string
onOpenFolder: (folderId: string | null, folderName: string) => void
}
export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props) {
const [tag, setTag] = useState<Tag | null>(null)
const [correspondent, setCorrespondent] = useState<Correspondent | null>(null)
const [files, setFiles] = useState<TaggedFile[]>([])
const [loading, setLoading] = useState(true)
const [missing, setMissing] = useState(false)
/** Was gerade gefiltert wird – Etikett oder Absender. */
const subject = correspondentId ? correspondent : tag
const id = correspondentId ?? tagId
const isFrom = !!correspondentId
useEffect(() => {
if (!id) { setLoading(false); return }
let alive = true
setLoading(true); setMissing(false)
setTag(null); setCorrespondent(null)
const load = isFrom
? Promise.all([correspondentsApi.list(), correspondentsApi.files(id)])
.then(([all, hits]) => {
const found = all.find(c => c.id === id) ?? null
return [found, hits] as const
})
: Promise.all([tagsApi.list(), tagsApi.files(id)])
.then(([all, hits]) => {
const found = all.find(t => t.id === id) ?? null
return [found, hits] as const
})
load
.then(([found, hits]) => {
if (!alive) return
if (isFrom) setCorrespondent(found as Correspondent | null)
else setTag(found as Tag | null)
setMissing(!found)
setFiles(hits)
})
.catch(() => { if (alive) setMissing(true) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [id, isFrom])
const total = files.reduce((sum, f) => sum + f.size, 0)
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<PageHeader
icon={isFrom ? <CorrespondentGlyph /> : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v6.2a2 2 0 00.6 1.4l6.8 6.8a2 2 0 002.8 0l6.2-6.2a2 2 0 000-2.8l-6.8-6.8A2 2 0 0011.2 5H5a2 2 0 00-2 2z" />
<circle cx="7.5" cy="9.5" r="1.5" fill="currentColor" stroke="none" />
</svg>
)}
title={subject?.name ?? (isFrom ? 'From' : 'Tags')}
meta={
loading ? '' :
missing ? '' :
`${files.length} ${files.length === 1 ? 'file' : 'files'} · ${formatBytes(total)}`
}
>
{/* Der Zweck der ganzen Kette: filtern, alles holen, weitergeben.
„Alle Belege 2026 an den Steuerberater" ist genau ein Klick. */}
{subject && files.length > 0 && (
<button
onClick={() => downloadZip({ fileIds: files.map(f => f.id), name: subject.name })}
style={{
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download all as ZIP
</button>
)}
</PageHeader>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>
)}
{!loading && !id && (
<Empty text={isFrom
? 'Pick a sender in the sidebar to see everything from them.'
: 'Pick a tag in the sidebar to see everything that carries it.'} />
)}
{!loading && id && missing && (
<Empty text={isFrom ? 'This sender no longer exists.' : 'This tag no longer exists.'} />
)}
{!loading && subject && files.length === 0 && (
<Empty text={isFrom
? `Nothing is from “${subject.name}” yet. Right-click a file and choose “Organise…”.`
: `Nothing is tagged “${subject.name}” yet. Right-click a file and choose “Organise…”.`} />
)}
{!loading && subject && files.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{files.map(f => (
<button
key={f.id}
onDoubleClick={() => onOpenFolder(f.folder_id, f.folder_name)}
onClick={() => onOpenFolder(f.folder_id, f.folder_name)}
title={`Open ${f.folder_name || 'Home'}`}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%',
padding: '9px 12px', borderRadius: 8, border: 'none',
background: 'transparent', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{/* Vorschaubild, wo es eins gibt – sonst das Typsymbol. In
einer Liste reicht die kleine Fassung; die großen Cover
gehören zu den Kacheln. */}
{f.has_thumbnail ? (
<img src={filesApi.thumbnail(f.id)} alt="" style={{
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
}} />
) : (
<span style={{ display: 'flex', flexShrink: 0 }}>
<FileTypeIcon mime={f.mime_type} name={f.name} size={22} />
</span>
)}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: 'block', fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{f.name}
</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-text-muted)' }}>
in {f.folder_name || 'Home'}
</span>
</span>
{/* Das Belegdatum steht rechts, weil danach sortiert wird – wer
eine Liste nach einer Spalte sortiert, sucht diese Spalte. */}
<span style={{
width: 96, textAlign: 'right', fontSize: 12, flexShrink: 0,
color: f.document_date ? 'var(--color-text)' : 'var(--color-text-muted)',
}}>
{f.document_date ? f.document_date.slice(0, 10) : '—'}
</span>
<span style={{ width: 76, textAlign: 'right', fontSize: 12, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatBytes(f.size)}
</span>
</button>
))}
</div>
)}
{!isFrom && tag?.group_id && (
<p style={{ marginTop: 20, fontSize: 11, color: 'var(--color-text-muted)' }}>
<TagChip tag={tag} size="sm" /> is a group tag — everyone in{' '}
<strong style={{ color: tagColor(tag) }}>{tag.group_name}</strong> sees it.
</p>
)}
</div>
</div>
)
}
/** Ein Umschlag – „von wem". Bewusst nicht dasselbe Zeichen wie beim Etikett:
* Es sind zwei verschiedene Fragen, und die Kopfzeile soll das zeigen. */
function CorrespondentGlyph() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2" />
<polyline points="2.5 6.5 12 13 21.5 6.5" />
</svg>
)
}
function Empty({ text }: { text: string }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '60%', color: 'var(--color-text-muted)', fontSize: 13,
textAlign: 'center', padding: 20,
}}>
{text}
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react'
import { trashApi, filesApi, type TrashItem } from '../api/client'
import { formatBytes } from './FileBrowser'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
interface Props {
/** Wiederherstellen verändert den belegten Speicher – Balken nachziehen. */
onUserChanged?: () => Promise<void>
}
export default function TrashPage({ onUserChanged }: Props) {
const [items, setItems] = useState<TrashItem[]>([])
const [loading, setLoading] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<TrashItem | null>(null)
const [confirmEmpty, setConfirmEmpty] = useState(false)
const [emptying, setEmptying] = useState(false)
useEffect(() => {
trashApi.list()
.then(data => setItems(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
async function restore(file: TrashItem) {
await trashApi.restore(file.id)
setItems(i => i.filter(x => x.id !== file.id))
await onUserChanged?.()
}
async function emptyAll() {
setEmptying(true)
try {
await trashApi.emptyAll()
setItems([])
await onUserChanged?.()
} catch { /* Liste bleibt stehen, damit der Fehlschlag sichtbar ist */ }
setEmptying(false)
setConfirmEmpty(false)
}
async function deletePermanently(file: TrashItem) {
await trashApi.deletePermanently(file.id)
setItems(i => i.filter(x => x.id !== file.id))
setConfirmDelete(null)
}
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">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
}
title="Trash"
meta={items.length > 0 ? `${items.length} item${items.length !== 1 ? 's' : ''}` : undefined}
>
{items.length > 0 && (
<button
onClick={() => setConfirmEmpty(true)}
disabled={emptying}
style={{
padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 500,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', cursor: emptying ? 'default' : 'pointer',
}}
>
{emptying ? 'Emptying…' : 'Empty trash'}
</button>
)}
</PageHeader>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)}
{!loading && items.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>🗑</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>Trash is empty</p>
</div>
)}
{!loading && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(file => (
<div key={file.id} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 14px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10,
}}>
{/* Thumbnail or icon */}
<div style={{
width: 44, height: 44, borderRadius: 8, flexShrink: 0,
overflow: 'hidden', background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{file.has_thumbnail ? (
<img
src={filesApi.thumbnail(file.id)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
) : (
<span style={{ fontSize: 20 }}>{mimeEmoji(file.mime_type)}</span>
)}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</p>
<p style={{ margin: '3px 0 0', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span>{formatBytes(file.size)}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="var(--color-warning)" stroke="none">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
{file.folder_path ? `Home / ${file.folder_path}` : 'Home'}
</span>
</p>
</div>
{/* Actions */}
<button
onClick={() => restore(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Restore
</button>
<button
onClick={() => setConfirmDelete(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-danger)',
background: 'transparent', color: 'var(--color-danger)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Delete forever
</button>
</div>
))}
</div>
)}
</div>
{confirmDelete && (
<ConfirmDialog
title="Permanently delete"
message={`"${confirmDelete.name}" permanently delete? This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={() => deletePermanently(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
{confirmEmpty && (
<ConfirmDialog
title="Empty trash"
message={`${items.length} ${items.length === 1 ? 'file' : 'files'} will be deleted permanently, including all revisions. This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={emptyAll}
onCancel={() => setConfirmEmpty(false)}
/>
)}
</div>
)
}
function mimeEmoji(mime: string): string {
if (mime.startsWith('image/')) return '🖼'
if (mime.startsWith('video/')) return '🎬'
if (mime.startsWith('audio/')) return '🎵'
if (mime.includes('pdf')) return '📄'
if (mime.includes('zip') || mime.includes('tar')) return '📦'
if (mime.includes('word') || mime.includes('document')) return '📝'
if (mime.includes('sheet') || mime.includes('excel')) return '📊'
return '📁'
}
+329
View File
@@ -0,0 +1,329 @@
import { useState, useEffect } from 'react'
import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client'
import { formatBytes } from './ui'
// Audit-Log, Speicher-Bereinigung und der Stand der Texterkennung –
// alles, was im Hintergrund läuft und sonst unbeobachtbar wäre.
// ── Audit Tab ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
/** Aktionsnamen sind maschinenlesbar (`admin.user_delete`). Hier stehen die
* lesbaren Entsprechungen – was nicht drinsteht, wird aus dem Schlüssel
* hergeleitet, damit neue Aktionen nicht als leere Zeile erscheinen. */
const ACTION_LABELS: Record<string, string> = {
'auth.login': 'Signed in',
'auth.login_failed': 'Sign-in failed',
'auth.logout': 'Signed out',
'auth.password_change': 'Changed password',
'app_password.create': 'Created app password',
'app_password.revoke': 'Revoked app password',
'admin.user_create': 'Created user',
'admin.user_update': 'Updated user',
'admin.user_delete': 'Deleted user',
'admin.user_password_reset': 'Reset a password',
'admin.group_create': 'Created group',
'admin.group_update': 'Updated group',
'admin.group_delete': 'Deleted group',
'admin.group_member_add': 'Added group member',
'admin.group_member_remove': 'Removed group member',
'admin.settings_update': 'Changed settings',
'share.create': 'Created share link',
'share.delete': 'Deleted share link',
'file.delete': 'Moved file to trash',
'file.restore': 'Restored file',
'file.trash_empty': 'Emptied trash',
'file.hard_delete': 'Permanently deleted file',
'system.gc': 'Cleaned up orphaned data',
}
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action.split('.').pop()?.replace(/_/g, ' ') ?? action
}
/** Fehlgeschlagene Anmeldungen und endgültige Löschungen sind das, wonach man
* im Protokoll sucht – die bekommen Farbe, der Rest bleibt ruhig. */
function actionColor(action: string): string {
if (action === 'auth.login_failed') return 'var(--color-danger)'
if (action.includes('delete')) return 'var(--color-warning)'
return 'var(--color-text-muted)'
}
/**
* Stand der Texterkennung.
*
* Ohne diese Anzeige ist nicht zu unterscheiden, ob die Volltextsuche nichts
* findet oder den Bestand noch gar nicht gelesen hat. Nach dem ersten Start
* läuft die Erkennung je nach Menge stundenlang – bei einem Scan rund eine
* Minute je Seite –, und eine Suche ohne Treffer sieht in dieser Zeit aus wie
* ein Fehler.
*/
function IndexingStatus() {
const [status, setStatus] = useState<OcrStatus | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
async function tick() {
try {
const s = await adminApi.ocrStatus()
if (!alive) return
setStatus(s)
setFailed(false)
// Nur nachfragen, solange sich etwas bewegt. Steht die Warteschlange
// still, ändert sich die Zahl erst beim nächsten Upload – dann wäre
// jede weitere Anfrage verschenkt.
const busy = (s.pending ?? 0) + (s.running ?? 0) > 0
if (busy) timer = setTimeout(tick, 5000)
} catch {
if (alive) setFailed(true)
}
}
tick()
return () => { alive = false; clearTimeout(timer) }
}, [])
if (failed || !status) return null
const pending = status.pending ?? 0
const running = status.running ?? 0
const indexed = status.indexed ?? 0
const bad = status.failed ?? 0
const busy = pending + running > 0
let text: string
if (!status.enabled) {
text = 'Off — file contents are not searchable. Set OCR_ENABLED and make sure ocrmypdf and tesseract are in the image.'
} else if (busy) {
text = `${indexed} indexed · ${pending + running} to go — a scanned page takes about a minute.`
} else if (indexed === 0) {
text = 'No documents indexed yet. PDFs and images are read after upload.'
} else {
text = `${indexed} ${indexed === 1 ? 'document' : 'documents'} indexed — their contents are searchable.`
}
if (bad > 0) {
text += ` ${bad} could not be read.`
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
{/* Der Punkt sagt den Zustand auch ohne Lesen: grün fertig, gelb läuft,
gedämpft aus. */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: !status.enabled
? 'var(--color-text-muted)'
: busy ? 'var(--color-warning)' : 'var(--color-success)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Full-text indexing
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{text}
</div>
</div>
{busy && (
<div style={{
width: 12, height: 12, flexShrink: 0,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
)
}
/** Der GC läuft von selbst alle 12 Stunden. Der Knopf ist da, damit man sieht,
* was er tut – ein Aufräumjob, der unbeobachtbar Dateien löscht, ist schwer zu
* vertrauen. */
function StorageCleanup() {
const [running, setRunning] = useState(false)
const [result, setResult] = useState<string | null>(null)
async function run() {
setRunning(true)
setResult(null)
try {
const r = await adminApi.runCleanup()
setResult(
r.deleted > 0
? `${r.deleted} orphaned ${r.deleted === 1 ? 'file' : 'files'} removed, ${formatBytes(r.freed_bytes)} freed (${r.scanned} scanned)`
: `Nothing to clean up — all ${r.scanned} stored files are accounted for`,
)
} catch {
setResult('Cleanup failed')
} finally {
setRunning(false)
}
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Storage cleanup
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Removes stored files that no longer belong to any record. Runs automatically every 12 hours.'}
</div>
</div>
<button
onClick={run}
disabled={running}
style={{
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Running…' : 'Run now'}
</button>
</div>
)
}
export default function AuditTab() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [actions, setActions] = useState<string[]>([])
const [filter, setFilter] = useState('')
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
adminApi.listAuditActions().then(a => setActions(a ?? [])).catch(() => {})
}, [])
useEffect(() => {
setLoading(true)
adminApi.listAudit({ action: filter || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
.then(res => { setEntries(res.entries ?? []); setTotal(res.total) })
.catch(() => {})
.finally(() => setLoading(false))
}, [filter, page])
const pages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<IndexingStatus />
<StorageCleanup />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<select
value={filter}
onChange={e => { setFilter(e.target.value); setPage(0) }}
style={{
padding: '7px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">All actions</option>
{actions.map(a => <option key={a} value={a}>{actionLabel(a)}</option>)}
</select>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{total} {total === 1 ? 'entry' : 'entries'}
</span>
</div>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Nothing recorded yet.</p>
) : (
<div style={{
border: '1px solid var(--color-border)', borderRadius: 12, overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-surface-2)' }}>
{['When', 'Who', 'Action', 'Target', 'From'].map(h => (
<th key={h} style={{
textAlign: 'left', padding: '9px 12px', fontSize: 11,
fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4,
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id} style={{ borderTop: '1px solid var(--color-border)' }}>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{new Date(e.created_at).toLocaleString()}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: 'var(--color-text)' }}>
{e.actor_name}
{/* Fehlt die actor_id, ist das Konto gelöscht – der Name bleibt
stehen, damit der Eintrag lesbar bleibt. Bei einem Fehlversuch
auf einen unbekannten Namen gab es dagegen nie ein Konto,
„gelöscht" wäre dort schlicht falsch. */}
{e.actor_id === null && e.actor_name !== 'system'
&& e.action !== 'auth.login_failed' && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}> (deleted account)</span>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: actionColor(e.action), fontWeight: 500 }}>
{actionLabel(e.action)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', maxWidth: 280 }}>
{e.target_name || e.detail || '—'}
{e.target_name && e.detail && (
<div style={{ fontSize: 11, opacity: 0.8 }}>{e.detail}</div>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{e.ip ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page === 0 ? 'default' : 'pointer', opacity: page === 0 ? 0.5 : 1,
}}
>
Previous
</button>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
Page {page + 1} of {pages}
</span>
<button
onClick={() => setPage(p => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page >= pages - 1 ? 'default' : 'pointer', opacity: page >= pages - 1 ? 0.5 : 1,
}}
>
Next
</button>
</div>
)}
</div>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type AdminUser, type AdminGroup, type GroupMember, type GroupFolder } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { FolderGlyph } from '../../components/icons'
import { Card, Btn, Input, Modal, formatBytes } from './ui'
// Gruppen und Gruppenordner.
// ── Groups Tab ────────────────────────────────────────────────────────────────
/** Gruppenordner: ein Ordner, der der Gruppe gehört statt einer Person. Jedes
* Mitglied sieht ihn in seiner Wurzel; entfernen kann ihn nur der Admin – und
* zwar hier, nicht in der Dateiansicht. Deshalb steht der Löschen-Knopf
* ausschließlich an dieser Stelle. */
function GroupFolders({ groups }: { groups: AdminGroup[] }) {
const [folders, setFolders] = useState<GroupFolder[]>([])
const [loading, setLoading] = useState(true)
const [groupId, setGroupId] = useState('')
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState<GroupFolder | null>(null)
useEffect(() => { load() }, [])
async function load() {
try {
setFolders(await adminApi.listGroupFolders() ?? [])
} catch {
setError('Could not load group folders')
} finally {
setLoading(false)
}
}
async function create() {
if (!groupId || !name.trim()) return
setBusy(true); setError('')
try {
await adminApi.createGroupFolder(groupId, name.trim())
setName('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create group folder')
} finally {
setBusy(false)
}
}
async function remove(folder: GroupFolder) {
setConfirming(null)
try {
await adminApi.deleteGroupFolder(folder.id)
await load()
} catch {
setError('Could not delete group folder')
}
}
return (
<div style={{ marginBottom: 28 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Group folders
</h3>
<p style={{ margin: '0 0 14px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
A folder that belongs to the group instead of a person. Every member sees it
next to their own folders and can work in it. Members cannot delete or rename
the folder itself — only you can, here.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<select
value={groupId}
onChange={e => setGroupId(e.target.value)}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">Choose a group…</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<input
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create() }}
placeholder="Folder name"
style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
/>
<button
onClick={create}
disabled={!groupId || !name.trim() || busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: !groupId || !name.trim() || busy ? 'default' : 'pointer',
opacity: !groupId || !name.trim() || busy ? 0.5 : 1,
}}
>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
{error && <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : folders.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>No group folders yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{folders.map(f => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 9, border: '1px solid var(--color-border)',
}}>
<span style={{ flexShrink: 0, display: 'flex' }}>
<FolderGlyph size={20} color="rgb(var(--color-primary-rgb))" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{f.group_name} · {f.members} {f.members === 1 ? 'member' : 'members'} · {formatBytes(f.used_bytes)} used
</div>
</div>
<button
onClick={() => setConfirming(f)}
style={{
padding: '5px 12px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', fontSize: 12, cursor: 'pointer',
}}
>
Delete
</button>
</div>
))}
</div>
)}
{confirming && (
<ConfirmDialog
title="Delete group folder"
message={`"${confirming.name}" and everything in it will be permanently deleted for all ${confirming.members} members. This cannot be undone.`}
confirmLabel="Delete permanently"
danger
onConfirm={() => remove(confirming)}
onCancel={() => setConfirming(null)}
/>
)}
</div>
)
}
export default function GroupsTab({ allUsers }: { allUsers: AdminUser[] }) {
const [groups, setGroups] = useState<AdminGroup[]>([])
const [members, setMembers] = useState<Record<string, GroupMember[]>>({})
const [expanded, setExpanded] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<AdminGroup | null>(null)
const [confirmGroup, setConfirmGroup] = useState<AdminGroup | null>(null)
const [groupError, setGroupError] = useState('')
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const load = useCallback(() => {
adminApi.listGroups()
.then(data => setGroups(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function toggleExpand(groupId: string) {
if (expanded === groupId) { setExpanded(null); return }
setExpanded(groupId)
if (!members[groupId]) {
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
}
}
async function handleCreate() {
if (!newName) return
await adminApi.createGroup(newName, newDesc)
setNewName(''); setNewDesc(''); setShowCreate(false); load()
}
async function handleDelete(g: AdminGroup) {
setConfirmGroup(null)
try {
await adminApi.deleteGroup(g.id)
load()
} catch (e) {
setGroupError(`Could not delete group: ${e instanceof Error ? e.message : e}`)
}
}
function openEdit(g: AdminGroup) {
setEditing(g); setEditName(g.name); setEditDesc(g.description ?? '')
}
async function handleUpdate() {
if (!editing || !editName.trim()) return
try {
await adminApi.updateGroup(editing.id, editName.trim(), editDesc)
setEditing(null); load()
} catch (e) {
setGroupError(`Could not save group: ${e instanceof Error ? e.message : e}`)
}
}
async function handleAddMember(groupId: string, userId: string) {
await adminApi.addMember(groupId, userId)
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
load()
}
async function handleRemoveMember(groupId: string, userId: string) {
await adminApi.removeMember(groupId, userId)
setMembers(m => ({ ...m, [groupId]: (m[groupId] ?? []).filter(x => x.user_id !== userId) }))
load()
}
return (
<div>
{/* Gruppenordner zuerst: Mitgliedschaft ohne gemeinsamen Ordner tut nichts,
der Ordner ist der eigentliche Zweck einer Gruppe. */}
<GroupFolders groups={groups} />
<div style={{
height: 1, background: 'var(--color-border)', margin: '0 0 24px',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Groups</h2>
<Btn variant="primary" onClick={() => setShowCreate(v => !v)}>+ Create group</Btn>
</div>
{showCreate && (
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}><Input label="Name" value={newName} onChange={setNewName} placeholder="e.g. Editors" /></div>
<div style={{ flex: 2 }}><Input label="Description" value={newDesc} onChange={setNewDesc} placeholder="Optional" /></div>
<Btn variant="primary" onClick={handleCreate} disabled={!newName}>Create</Btn>
<Btn onClick={() => setShowCreate(false)}>Cancel</Btn>
</div>
</Card>
)}
{groupError && (
<Card style={{
marginBottom: 12, borderColor: 'var(--color-danger)',
background: 'rgba(239,68,68,0.08)', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--color-danger)' }}>{groupError}</span>
<button onClick={() => setGroupError('')} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-danger)', fontSize: 16, lineHeight: 1,
}}>×</button>
</Card>
)}
{confirmGroup && (
<ConfirmDialog
title="Delete group"
message={`Delete group "${confirmGroup.name}"? Members keep their accounts and files – only the group is removed.`}
confirmLabel="Delete group"
danger
onConfirm={() => handleDelete(confirmGroup)}
onCancel={() => setConfirmGroup(null)}
/>
)}
{editing && (
<Modal title={`Edit group "${editing.name}"`} onClose={() => setEditing(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Name" value={editName} onChange={setEditName} />
<Input label="Description" value={editDesc} onChange={setEditDesc} placeholder="Optional" />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn onClick={() => setEditing(null)}>Cancel</Btn>
<Btn variant="primary" onClick={handleUpdate} disabled={!editName.trim()}>Save</Btn>
</div>
</div>
</Modal>
)}
<Card>
{loading && <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>}
{!loading && groups.length === 0 && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center', padding: '20px 0' }}>No groups yet</p>
)}
{groups.map((g, i) => {
const isOpen = expanded === g.id
const groupMembers = members[g.id] ?? []
const nonMembers = allUsers.filter(u => !groupMembers.some(m => m.user_id === u.id))
return (
<div key={g.id} style={{ borderBottom: i < groups.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 4px', gap: 10 }}>
<button onClick={() => toggleExpand(g.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{g.name}</div>
{g.description && <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.description}</div>}
</div>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.member_count} {g.member_count === 1 ? 'member' : 'members'}</span>
<Btn size="sm" onClick={() => openEdit(g)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(g)}>Delete</Btn>
</div>
{isOpen && (
<div style={{ paddingLeft: 28, paddingBottom: 12 }}>
{groupMembers.length === 0 && <p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '4px 0 8px' }}>No members</p>}
{groupMembers.map(m => (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<span style={{ fontSize: 13, color: 'var(--color-text)', flex: 1 }}>{m.username}{m.display_name ? ` (${m.display_name})` : ''}</span>
<Btn size="sm" variant="danger" onClick={() => handleRemoveMember(g.id, m.user_id)}>Remove</Btn>
</div>
))}
{nonMembers.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<select
style={{ padding: '5px 8px', borderRadius: 7, border: '1px solid var(--color-border)', background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 12 }}
defaultValue=""
onChange={e => { if (e.target.value) { handleAddMember(g.id, e.target.value); e.target.value = '' } }}
>
<option value="" disabled>Add member…</option>
{nonMembers.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
)}
</div>
)}
</div>
)
})}
</Card>
</div>
)
}
+295
View File
@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react'
import { adminApi, type AppSettings } from '../../api/client'
import { applyTheme } from '../../theme'
import { useAppSettings } from '../../hooks/useAppSettings'
import ConfirmDialog from '../../components/ConfirmDialog'
import { Card, Btn, Input } from './ui'
// Erscheinungsbild: Name, Farben, Logo und App-Symbol.
// ── Theming Tab ───────────────────────────────────────────────────────────────
/** Bild-Verwaltung für Logo und Favicon. Getrennt vom übrigen
* Theming-Formular, weil ein Upload sofort wirkt statt erst beim Speichern –
* ein Bild in einem Formular mit Speichern-Knopf würde die Frage aufwerfen,
* ob es schon hochgeladen ist. */
interface ImageSettingProps {
label: string
url: string
/** Quadratischer Rahmen fürs Favicon, breiter fürs Logo. */
preview: { width: number; height: number }
accept: string
hint: string
removeTitle: string
removeMessage: string
onUpload: (file: File) => Promise<unknown>
onRemove: () => Promise<unknown>
onDone: () => Promise<void>
}
function ImageSetting({
label, url, preview, accept, hint, removeTitle, removeMessage,
onUpload, onRemove, onDone,
}: ImageSettingProps) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState(false)
const what = label.toLowerCase()
async function pick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
e.target.value = ''
if (!file) return
setBusy(true); setError('')
try {
await onUpload(file)
await onDone()
} catch (err) {
setError(err instanceof Error ? err.message : `Could not upload the ${what}`)
} finally {
setBusy(false)
}
}
async function remove() {
setConfirming(false)
setBusy(true); setError('')
try {
await onRemove()
await onDone()
} catch {
setError(`Could not remove the ${what}`)
} finally {
setBusy(false)
}
}
return (
<Card style={{ maxWidth: 480, marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{label}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: preview.width, height: preview.height, borderRadius: 10, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{url ? (
<img src={url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>none</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<label style={{
padding: '7px 14px', borderRadius: 8,
background: 'var(--color-primary)', color: '#fff',
fontSize: 12, fontWeight: 600,
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
}}>
{busy ? 'Uploading…' : url ? 'Replace' : 'Upload'}
<input type="file" accept={accept}
onChange={pick} disabled={busy} style={{ display: 'none' }} />
</label>
{url && (
<button
onClick={() => setConfirming(true)}
disabled={busy}
style={{
padding: '7px 14px', borderRadius: 8,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 12, cursor: 'pointer',
}}
>
Remove
</button>
)}
</div>
<p style={{ margin: 0, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{hint}
</p>
</div>
</div>
{error && <p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{confirming && (
<ConfirmDialog
title={removeTitle}
message={removeMessage}
confirmLabel="Remove"
danger
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>
)}
</Card>
)
}
/** Die beiden Bilder des Erscheinungsbilds. */
function BrandingSettings() {
const { logoUrl, appIconUrl, refresh } = useAppSettings()
return (
<>
<ImageSetting
label="Logo"
url={logoUrl}
preview={{ width: 84, height: 60 }}
accept="image/png,image/jpeg,image/webp,image/gif"
hint="Shown on the sign-in page and in the sidebar. PNG, JPEG, WebP or GIF, up to 2 MB. Takes effect immediately."
removeTitle="Remove logo"
removeMessage="The sign-in page and sidebar will show the default icon again."
onUpload={adminApi.uploadLogo}
onRemove={adminApi.deleteLogo}
onDone={refresh}
/>
<ImageSetting
label="App icon"
url={appIconUrl}
preview={{ width: 60, height: 60 }}
accept="image/png,image/webp,image/gif,image/jpeg"
hint="Used for the browser tab and for the installed app. One square PNG, at least 192×192 (512×512 is better), up to 2 MB – all sizes are generated from it. The tab changes immediately; an already installed app follows within a day or two."
removeTitle="Remove app icon"
removeMessage="Tabs and the installed app will show the default Aether icon again."
onUpload={adminApi.uploadAppIcon}
onRemove={adminApi.deleteAppIcon}
onDone={refresh}
/>
</>
)
}
export default function ThemingTab() {
const [settings, setSettings] = useState<Partial<AppSettings>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const { refresh } = useAppSettings()
useEffect(() => {
adminApi.getSettings()
.then(data => setSettings(data ?? {}))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Farbwahl wirkt sofort auf die ganze Oberfläche – man sieht das Ergebnis,
// bevor man sich festlegt. Beim Verlassen ohne Speichern wird der
// gespeicherte Stand wiederhergestellt, damit keine Vorschau hängen bleibt.
function previewColors(next: Partial<AppSettings>) {
applyTheme(next.primary_color, next.accent_color)
}
useEffect(() => {
return () => { void refresh() }
}, [refresh])
async function handleSave() {
setSaving(true)
const result = await adminApi.updateSettings({
cloud_name: settings.cloud_name,
primary_color: settings.primary_color,
accent_color: settings.accent_color,
}).catch(() => null)
setSaving(false)
if (result) {
setSettings(result)
setSaved(true)
// Neu laden statt nur die Variablen zu setzen: so übernimmt auch der
// Cloud-Name in der Seitenleiste und im Fenstertitel sofort.
await refresh()
setTimeout(() => setSaved(false), 2000)
}
}
if (loading) return <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>
return (
<div>
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Theming</h2>
<BrandingSettings />
<Card style={{ maxWidth: 480 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<Input
label="Cloud name"
value={settings.cloud_name ?? ''}
onChange={v => setSettings(s => ({ ...s, cloud_name: v }))}
placeholder="Aether"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>Primary colour</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={settings.primary_color ?? '#6366f1'}
onChange={e => setSettings(s => {
const next = { ...s, primary_color: e.target.value }
previewColors(next)
return next
})}
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)' }}>
{settings.primary_color ?? '#6366f1'}
</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>Accent colour</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={settings.accent_color ?? '#8b5cf6'}
onChange={e => setSettings(s => {
const next = { ...s, accent_color: e.target.value }
previewColors(next)
return next
})}
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)' }}>
{settings.accent_color ?? '#8b5cf6'}
</span>
</div>
</div>
{/* Preview */}
<div style={{ padding: 14, borderRadius: 10, border: '1px solid var(--color-border)', background: 'var(--color-surface-2)' }}>
<p style={{ margin: '0 0 8px', fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Preview</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ width: 28, height: 28, borderRadius: 7, background: settings.primary_color ?? '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
<span style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text)' }}>{settings.cloud_name || 'Aether'}</span>
<div style={{ marginLeft: 'auto', padding: '4px 12px', borderRadius: 99, background: settings.primary_color ?? '#6366f1', color: '#fff', fontSize: 12, fontWeight: 600 }}>
Upload
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Btn variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save settings'}
</Btn>
{saved && <span style={{ fontSize: 12, color: '#22c55e', fontWeight: 600 }}>✓ Saved</span>}
</div>
</div>
</Card>
</div>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type AdminUser } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { Card, Btn, Input, Modal, formatBytes, bytesFromGB, gbFromBytes } from './ui'
// Benutzerverwaltung: anlegen, bearbeiten, Passwort zurücksetzen, löschen.
// ── Users Tab ─────────────────────────────────────────────────────────────────
export default function UsersTab() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState<'create' | 'edit' | 'reset' | null>(null)
const [selected, setSelected] = useState<AdminUser | null>(null)
const [resetResult, setResetResult] = useState('')
const [error, setError] = useState('')
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null)
// Create form
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [newQuotaGB, setNewQuotaGB] = useState('10')
// Edit form
const [editDisplay, setEditDisplay] = useState('')
const [editIsAdmin, setEditIsAdmin] = useState(false)
const [editQuotaGB, setEditQuotaGB] = useState('10')
const load = useCallback(() => {
adminApi.listUsers()
.then(data => setUsers(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function handleCreate() {
if (!newUsername || !newPassword) return
try {
await adminApi.createUser({
username: newUsername, display_name: newDisplay,
password: newPassword, is_admin: newIsAdmin,
quota_bytes: bytesFromGB(parseFloat(newQuotaGB) || 10),
})
setModal(null); setNewUsername(''); setNewDisplay(''); setNewPassword(''); setNewIsAdmin(false); setNewQuotaGB('10')
load()
} catch (e) {
setError(`Could not create user: ${e instanceof Error ? e.message : e}`)
}
}
async function handleEdit() {
if (!selected) return
try {
await adminApi.updateUser(selected.id, {
display_name: editDisplay,
is_admin: editIsAdmin,
quota_bytes: bytesFromGB(parseFloat(editQuotaGB) || 10),
})
setModal(null); setSelected(null); load()
} catch (e) {
setError(`Could not save: ${e instanceof Error ? e.message : e}`)
}
}
async function handleReset() {
if (!selected) return
try {
const res = await adminApi.resetPassword(selected.id)
setResetResult(res.new_password)
} catch (e) {
setError(`Could not reset password: ${e instanceof Error ? e.message : e}`)
}
}
async function handleDelete(user: AdminUser) {
setConfirmUser(null)
try {
await adminApi.deleteUser(user.id)
load()
} catch (e) {
setError(`Could not delete: ${e instanceof Error ? e.message : e}`)
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Users</h2>
<Btn variant="primary" onClick={() => setModal('create')}>+ Create user</Btn>
</div>
{error && (
<Card style={{
marginBottom: 12, borderColor: 'var(--color-danger)',
background: 'rgba(239,68,68,0.08)', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--color-danger)' }}>{error}</span>
<button onClick={() => setError('')} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-danger)', fontSize: 16, lineHeight: 1,
}}>×</button>
</Card>
)}
<Card>
{loading && <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>}
{!loading && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['User', 'Storage', 'Quota', 'Groups', 'Role', ''].map(h => (
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ borderBottom: '1px solid var(--color-border)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<td style={{ padding: '10px 10px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>{u.username}</div>
{u.display_name && <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{u.display_name}</div>}
</td>
<td style={{ padding: '10px 10px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatBytes(u.used_bytes)}
</td>
<td style={{ padding: '10px 10px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatBytes(u.quota_bytes)}
</td>
<td style={{ padding: '10px 10px' }}>
{u.groups && u.groups.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{u.groups.map(g => (
<span key={g} style={{
fontSize: 11, padding: '2px 8px', borderRadius: 99,
background: 'var(--color-surface-2)', color: 'var(--color-text-muted)',
}}>{g}</span>
))}
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>–</span>
)}
</td>
<td style={{ padding: '10px 10px' }}>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 99,
background: u.is_admin ? 'rgba(var(--color-primary-rgb), 0.15)' : 'var(--color-surface-2)',
color: u.is_admin ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{u.is_admin ? 'Admin' : 'User'}
</span>
</td>
<td style={{ padding: '10px 10px' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn size="sm" onClick={() => {
setSelected(u); setEditDisplay(u.display_name)
setEditIsAdmin(u.is_admin); setEditQuotaGB(String(gbFromBytes(u.quota_bytes)))
setModal('edit')
}}>Edit</Btn>
<Btn size="sm" onClick={() => { setSelected(u); setResetResult(''); setModal('reset') }}>Reset password</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(u)}>Delete</Btn>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* Create modal */}
{modal === 'create' && (
<Modal title="Create user" onClose={() => setModal(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Username *" value={newUsername} onChange={setNewUsername} placeholder="e.g. alice" />
<Input label="Display name" value={newDisplay} onChange={setNewDisplay} placeholder="Alice Smith" />
<Input label="Password *" type="password" value={newPassword} onChange={setNewPassword} />
<Input label="Quota (GB)" value={newQuotaGB} onChange={setNewQuotaGB} placeholder="10" />
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={newIsAdmin} onChange={e => setNewIsAdmin(e.target.checked)} />
Administrator
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<Btn onClick={() => setModal(null)}>Cancel</Btn>
<Btn variant="primary" onClick={handleCreate} disabled={!newUsername || !newPassword}>Create</Btn>
</div>
</div>
</Modal>
)}
{/* Edit modal */}
{modal === 'edit' && selected && (
<Modal title={`Edit ${selected.username}`} onClose={() => { setModal(null); setSelected(null) }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Display name" value={editDisplay} onChange={setEditDisplay} />
<Input label="Quota (GB)" value={editQuotaGB} onChange={setEditQuotaGB} />
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={editIsAdmin} onChange={e => setEditIsAdmin(e.target.checked)} />
Administrator
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<Btn onClick={() => { setModal(null); setSelected(null) }}>Cancel</Btn>
<Btn variant="primary" onClick={handleEdit}>Save</Btn>
</div>
</div>
</Modal>
)}
{/* Reset password modal */}
{modal === 'reset' && selected && (
<Modal title={`Reset password – ${selected.username}`} onClose={() => { setModal(null); setSelected(null); setResetResult('') }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!resetResult ? (
<>
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
Generate a random password and pass it on to the user.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn onClick={() => { setModal(null); setSelected(null) }}>Cancel</Btn>
<Btn variant="primary" onClick={handleReset}>Generate password</Btn>
</div>
</>
) : (
<>
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
New password (shown only once):
</p>
<div style={{
padding: '10px 14px', borderRadius: 8,
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
fontFamily: 'monospace', fontSize: 14, color: 'var(--color-text)',
userSelect: 'all',
}}>
{resetResult}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Btn variant="primary" onClick={() => { setModal(null); setSelected(null); setResetResult('') }}>Done</Btn>
</div>
</>
)}
</div>
</Modal>
)}
{confirmUser && (
<ConfirmDialog
title="Delete user"
message={`Delete "${confirmUser.username}"? All of their files will be deleted as well. This cannot be undone.`}
confirmLabel="Delete user"
danger
onConfirm={() => handleDelete(confirmUser)}
onCancel={() => setConfirmUser(null)}
/>
)}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import type React from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
/**
* Bausteine, die alle Reiter des Admin-Bereichs teilen: Karte, Knopf, Feld,
* Dialog – dazu die Umrechnung zwischen Bytes und Gigabyte.
*
* Lagen bis zum 23.08.2026 zusammen mit allen vier Reitern in einer Datei mit
* 1300 Zeilen.
*/
export 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`
}
export function bytesFromGB(gb: number): number {
return Math.round(gb * 1024 ** 3)
}
export function gbFromBytes(bytes: number): number {
return Math.round((bytes / 1024 ** 3) * 10) / 10
}
// ── Shared UI ─────────────────────────────────────────────────────────────────
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, padding: 20,
...style,
}}>
{children}
</div>
)
}
export function Btn({
children, onClick, variant = 'secondary', size = 'md', disabled,
}: {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md'
disabled?: boolean
}) {
const colors = {
primary: { bg: 'var(--color-primary)', color: '#fff', border: 'none' },
secondary: { bg: 'transparent', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' },
danger: { bg: 'transparent', color: 'var(--color-danger)', border: '1px solid var(--color-danger)' },
}[variant]
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: size === 'sm' ? '4px 12px' : '7px 16px',
fontSize: size === 'sm' ? 12 : 13,
fontWeight: 500,
borderRadius: 8,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
...colors,
}}
>
{children}
</button>
)
}
export function Input({ label, value, onChange, type = 'text', placeholder }: {
label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
outline: 'none',
}}
/>
</div>
)
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
const overlay = useOverlayDismiss(onClose)
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 400, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, width: '100%', maxWidth: 440,
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
}}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 18, lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: 22 }}>{children}</div>
</div>
</div>
)
}