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,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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 '📁'
|
||||
}
|
||||
@@ -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