feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user