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

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

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

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

No files matched your search

+813
View File
@@ -0,0 +1,813 @@
// Typed API client. All fetch calls go through here — never use fetch directly in components.
export interface User {
id: string
username: string
display_name: string
is_admin: boolean
quota_bytes: number
used_bytes: number
/** Persönliche Palette – bleibt erhalten, auch wenn sie gerade nicht
* angewendet wird (siehe use_custom_theme). */
primary_color: string
accent_color: string
use_custom_theme: boolean
/** Konto meldet sich über den Identitätsanbieter an und hat hier kein
* Passwort – die Passwortänderung wird dann ausgeblendet. */
is_oidc?: boolean
created_at: string
}
export interface FileItem {
id: string
/** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */
document_date?: string
owner_id: string | null
group_id: string | null
folder_id: string | null
name: string
mime_type: string
size: number
checksum: string
has_thumbnail: boolean
version: number
versioning_enabled: boolean
created_at: string
updated_at: string
}
export interface FolderItem {
id: string
/** Genau eines von beiden ist gesetzt: persönlicher Ordner oder Gruppenordner. */
owner_id: string | null
group_id: string | null
parent_id: string | null
name: string
created_at: string
}
export interface ListResponse {
files: FileItem[]
folders: FolderItem[]
/** Etiketten je Datei-ID. Kommen mit der Liste in einer Abfrage mit – bei
* fuenfzig Kacheln waeren fuenfzig Einzelabfragen der falsche Weg. */
tags?: Record<string, Tag[]>
/** Absender je Datei-ID – auf demselben Weg wie die Etiketten. */
correspondents?: Record<string, Correspondent>
}
export interface AuthResponse {
user: User
csrf_token: string
}
export interface ShareLink {
id: string
file_id: string
owner_id: string
token: string
has_password: boolean
expires_at: string | null
download_count: number
created_at: string
}
export interface CreateShareRequest {
password?: string
expires_in_hours?: number
}
export interface PublicShareInfo {
link: ShareLink
file_name: string
file_size: number
mime_type: string
}
export interface FileVersion {
id: string
file_id: string
version: number
size: number
checksum: string
created_at: string
}
export interface UploadProgress {
loaded: number
total: number
percent: number
}
class APIError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'APIError'
}
}
let csrfToken = ''
export function setCSRFToken(token: string) {
csrfToken = token
}
async function request<T>(
method: string,
path: string,
body?: unknown,
isFormData = false,
/** Abbruchsignal – die Suche verwirft damit überholte Anfragen. */
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = {}
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
if (body && !isFormData) headers['Content-Type'] = 'application/json'
const res = await fetch(`/api/v1${path}`, {
method,
headers,
credentials: 'include',
signal,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
})
if (res.status === 204) return undefined as T
const json = await res.json().catch(() => ({ error: res.statusText }))
if (!res.ok) throw new APIError(res.status, json.error ?? 'Unknown error')
return json.data ?? json as T
}
// XHR-based upload with real progress reporting.
export function uploadWithProgress(
file: File,
folderId: string | undefined,
onProgress: (p: UploadProgress) => void
): Promise<FileItem> {
return new Promise((resolve, reject) => {
const form = new FormData()
// folder_id MUST come before the file so the server can stream the file
// straight to storage without buffering the whole body first.
if (folderId) form.append('folder_id', folderId)
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/files')
xhr.withCredentials = true
if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) })
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 201) {
try {
const json = JSON.parse(xhr.responseText)
resolve(json.data ?? json)
} catch {
reject(new APIError(xhr.status, 'Invalid response'))
}
} else {
let msg = 'Upload failed'
try {
msg = JSON.parse(xhr.responseText).error ?? msg
} catch { /* kein JSON – Standardtext behalten */ }
// 413 kommt hier nur vom Quota-Check; der rohe Servertext sagt einem
// Nutzer vor dem Upload-Fenster wenig.
if (xhr.status === 413) msg = 'Storage full – your quota is not sufficient'
reject(new APIError(xhr.status, msg))
}
})
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
xhr.send(form)
})
}
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface ExternalApp {
name: string
url: string
icon: string
}
export interface PublicConfig {
oidc_enabled: boolean
cloud_name: string
/** Leer, wenn kein Logo hinterlegt ist. Enthält einen Versionsanteil, der
* sich bei jedem Austausch ändert – damit Browser nicht das alte zeigen. */
logo_url?: string
/** Tab-Symbol (64 px) aus dem hochgeladenen App-Symbol. Leer, wenn keins
* hinterlegt ist. Ebenfalls mit Versionsanteil. */
favicon_url?: string
/** Dasselbe Symbol in 192 px – für den Startbildschirm von iOS. */
apple_touch_icon_url?: string
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
office_enabled?: boolean
primary_color?: string
accent_color?: string
external_apps: ExternalApp[]
}
export const publicApi = {
config: (): Promise<PublicConfig> =>
fetch('/api/v1/public/config').then(r => r.json()).then(j => j.data ?? j),
}
export const authApi = {
login: (username: string, password: string) =>
request<AuthResponse>('POST', '/auth/login', { username, password }),
logout: () => request<void>('POST', '/auth/logout'),
me: () => request<AuthResponse>('GET', '/auth/me'),
updateProfile: (displayName: string) =>
request<User>('PATCH', '/auth/profile', { display_name: displayName }),
updateColors: (primaryColor: string, accentColor: string) =>
request<User>('PATCH', '/auth/profile', {
primary_color: primaryColor,
accent_color: accentColor,
}),
setUseCustomTheme: (enabled: boolean) =>
request<User>('PATCH', '/auth/profile', { use_custom_theme: enabled }),
changePassword: (currentPassword: string, newPassword: string) =>
request<void>('POST', '/auth/change-password', { current_password: currentPassword, new_password: newPassword }),
}
// ── Internal shares (people and groups) ───────────────────────────────────────
export type SharePermission = 'read' | 'write'
export interface AclEntry {
id: string
subject_type: 'user' | 'group'
subject_id: string
subject_name: string
permission: SharePermission
created_at: string
}
export interface ShareRecipient {
id: string
name: string
type: 'user' | 'group'
}
export interface SharedResource {
type: 'folder' | 'file'
id: string
name: string
owner_id: string
owner_name: string
permission: SharePermission
size: number
mime_type: string
shared_at: string
/** Gesetzt, wenn der Zugriff über eine Gruppe kommt statt persönlich. */
via_group: string
}
export const aclApi = {
recipients: () => request<ShareRecipient[]>('GET', '/share-recipients'),
sharedWithMe: () => request<SharedResource[]>('GET', '/shared-with-me'),
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
}
// ── App passwords (WebDAV / sync clients) ─────────────────────────────────────
export interface AppPassword {
id: string
name: string
last_used_at: string | null
created_at: string
}
// The plaintext password is returned exactly once, on creation.
export interface AppPasswordCreated extends AppPassword {
password: string
}
export const appPasswordsApi = {
list: () => request<AppPassword[]>('GET', '/app-passwords'),
create: (name: string) =>
request<AppPasswordCreated>('POST', '/app-passwords', { name }),
revoke: (id: string) => request<void>('DELETE', `/app-passwords/${id}`),
}
// ── Files ─────────────────────────────────────────────────────────────────────
// ── Suche ────────────────────────────────────────────────────────────────────
export interface SearchHit {
kind: 'folder' | 'file'
id: string
name: string
/** Ordner, in dem der Treffer liegt – null heisst Wurzel. */
folder_id: string | null
folder_name: string
mime_type?: string
size: number
has_thumbnail: boolean
updated_at: string
/** true, wenn nicht der Name passte, sondern der erkannte Text im Dokument. */
matched_content?: boolean
/** Fundstelle im Text; Treffer in <b>…</b>. Nur bei matched_content. */
snippet?: string
}
export interface OcrStatus {
enabled: boolean
/** Fehlen alle, wenn enabled false ist. */
pending?: number
running?: number
done?: number
failed?: number
/** Dateien mit gespeichertem Text. */
indexed?: number
}
export interface SearchResponse {
hits: SearchHit[]
query: string
/** true, wenn die Liste bei der Obergrenze abgeschnitten wurde. */
truncated?: boolean
}
export interface Tag {
id: string
name: string
/** Leer heisst: die Oberflaeche leitet eine Farbe aus dem Namen ab. */
color: string
/** Emoji oder leer. Steht **neben** der Farbe: Die Farbe traegt die
* Wiedererkennung im Augenwinkel, das Symbol die Bedeutung. Ab etwa fuenf
* Etiketten reicht Farbe allein nicht mehr – dann fragt man sich, ob Blau
* nun „Uni" oder „Versicherung" war. */
icon: string
/** Gesetzt, wenn das Etikett einer Gruppe gehoert – dann sehen es alle
* Mitglieder, und es gilt nur fuer Dateien in deren Gruppenordner. */
group_id?: string
group_name?: string
file_count: number
created_at: string
}
/**
* Absender („Korrespondent") – von wem ein Dokument kommt.
*
* Der Unterschied zum Etikett ist die Anzahl: Etiketten sagen, *wofür* ein
* Dokument gebraucht wird, davon trägt eine Datei beliebig viele. Der Absender
* sagt, *von wem* es kommt – davon hat eine Datei genau einen. Deshalb ein
* Feld und keine Mehrfachauswahl, und deshalb eine eigene Liste: Sonst stünde
* „Stadtwerke" neben „absetzbar" in derselben Aufzählung.
*/
export interface Correspondent {
id: string
name: string
/** Emoji oder leer – dieselbe Regel wie beim Etikett. */
icon: string
group_id?: string
group_name?: string
file_count: number
created_at: string
}
/** Datei-Treffer beim Filtern nach einem Etikett. */
export interface TaggedFile {
id: string
name: string
folder_id: string | null
folder_name: string
mime_type: string
size: number
has_thumbnail: boolean
/** Datum auf dem Beleg – nicht das des Uploads. */
document_date?: string
updated_at: string
}
/**
* Meldet, dass sich an den Etiketten etwas geändert hat.
*
* Die Seitenleiste zeigt Zählstände, die sich ändern, sobald irgendwo
* etikettiert wird – und sie steht neben dem Dialog, in dem das passiert. Eine
* Zahl, die daneben stehenbleibt, während man sie gerade verändert hat, sieht
* nach einem Fehler aus.
*
* Ein Ereignis statt Zustand durch drei Ebenen zu reichen: Der Dialog steckt
* tief im Dateibrowser, die Seitenleiste hängt am Shell – ein gemeinsamer
* Zustand nur für einen Zählstand wäre viel Verkabelung für wenig.
*/
export const tagsChangedEvent = 'aether:tags-changed'
export function notifyTagsChanged() {
window.dispatchEvent(new Event(tagsChangedEvent))
}
/**
* Startet einen Archiv-Download.
*
* Über ein abgeschicktes Formular, nicht über fetch(): Nur eine **Navigation**
* behandelt der Browser als Download. Ein fetch() müsste die Antwort erst
* vollständig in den Speicher holen – bei einem 5-GB-Ordner also 5 GB im
* Arbeitsspeicher, und der Nutzer sähe bis dahin nichts.
*
* POST statt GET, weil eine Auswahl beliebig lang sein kann: Fünfzig
* Kennungen sind schon knapp 1900 Zeichen, und Adressen haben eine Grenze.
*/
export function downloadZip(opts: { fileIds?: string[]; folderId?: string; name?: string }) {
const form = document.createElement('form')
form.method = 'POST'
form.action = '/api/v1/download/zip'
// Das CSRF-Token als Feld – ein Formular kann keine Header setzen. Der
// Server vergleicht es weiterhin gegen das Cookie (Double-Submit).
const add = (name: string, value: string) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.appendChild(input)
}
add('csrf_token', csrfToken)
if (opts.folderId) add('folder_id', opts.folderId)
if (opts.name) add('name', opts.name)
for (const id of opts.fileIds ?? []) add('file_ids', id)
document.body.appendChild(form)
form.submit()
form.remove()
}
export const tagsApi = {
list: () => request<Tag[]>('GET', '/tags'),
create: (name: string, color = '', groupId?: string, icon = '') =>
request<Tag>('POST', '/tags', { name, color, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, color: string, icon = '') =>
request<Tag>('PATCH', `/tags/${id}`, { name, color, icon }),
remove: (id: string) => request<void>('DELETE', `/tags/${id}`),
files: (id: string) => request<TaggedFile[]>('GET', `/tags/${id}/files`),
/**
* Setzt Etiketten, Belegdatum und Absender einer Datei in **einem** Vorgang.
*
* Alles drei steht im selben Dialog, also geht es auch zusammen zum Server –
* sonst gäbe es einen Zustand, in dem die Etiketten sitzen und der Absender
* nicht.
*
* Weglassen heisst jeweils „unveraendert", leerer String heisst „loeschen".
* `correspondentName` legt den Absender bei Bedarf an; das ist der Normalfall
* beim Tippen.
*/
setForFile: (fileId: string, tagIds: string[], documentDate?: string,
correspondent?: { id?: string; name?: string }) =>
request<Tag[]>('PUT', `/files/${fileId}/tags`, {
tag_ids: tagIds,
...(documentDate === undefined ? {} : { document_date: documentDate }),
...(correspondent?.name ? { correspondent_name: correspondent.name }
: correspondent?.id === undefined ? {} : { correspondent_id: correspondent.id }),
}),
}
export const correspondentsApi = {
list: () => request<Correspondent[]>('GET', '/correspondents'),
create: (name: string, groupId?: string, icon = '') =>
request<Correspondent>('POST', '/correspondents', { name, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, icon = '') =>
request<Correspondent>('PATCH', `/correspondents/${id}`, { name, icon }),
remove: (id: string) => request<void>('DELETE', `/correspondents/${id}`),
files: (id: string) => request<TaggedFile[]>('GET', `/correspondents/${id}/files`),
}
/** Was der Server über eine Office-Sitzung mitteilt. */
export interface OfficeSession {
enabled: boolean
provider?: string
/** Adresse der Bedienoberflaeche beim Office-Server, samt WOPISrc. */
url: string
/** Kurzlebiges Zugriffstoken – gilt fuer **genau diese** Datei. */
token: string
token_ttl: number
can_write: boolean
file_name: string
}
/**
* Endungen, die ein Office-Server bearbeiten kann.
*
* Dieselbe Positivliste wie im Backend (internal/office). Hier steht sie nur,
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
* entschieden wird serverseitig.
*/
const officeExtensions = new Set([
'odt', 'docx', 'doc', 'rtf', 'txt',
'ods', 'xlsx', 'xls', 'csv',
'odp', 'pptx', 'ppt',
'odg',
])
export function isOfficeFile(name: string): boolean {
const i = name.lastIndexOf('.')
return i >= 0 && officeExtensions.has(name.slice(i + 1).toLowerCase())
}
export const officeApi = {
session: (fileId: string) =>
request<OfficeSession>('GET', `/office/session/${fileId}`),
}
export const ocrApi = {
/** Stand der Texterkennung für die **eigenen** Dateien. Braucht keine
* Adminrechte – anders als adminApi.ocrStatus, das den ganzen Server zählt. */
myStatus: () => request<OcrStatus>('GET', '/ocr'),
}
export const searchApi = {
query: (q: string, signal?: AbortSignal) =>
request<SearchResponse>('GET', `/search?q=${encodeURIComponent(q)}`, undefined, false, signal),
}
export const filesApi = {
list: (folderId?: string) => {
const qs = folderId ? `?folder_id=${folderId}` : ''
return request<ListResponse>('GET', `/files${qs}`)
},
upload: (file: File, folderId?: string) => {
const form = new FormData()
// folder_id before file – see uploadWithProgress note (streaming upload).
if (folderId) form.append('folder_id', folderId)
form.append('file', file)
return request<FileItem>('POST', '/files', form, true)
},
download: (id: string) => `/api/v1/files/${id}/download`,
/** Dieselbe Datei, aber zum Anzeigen im Browser statt zum Speichern. Der
* Server liefert das nur für eine feste Liste harmloser Typen inline aus. */
inline: (id: string) => `/api/v1/files/${id}/download?inline=1`,
thumbnail: (id: string) => `/api/v1/files/${id}/thumbnail`,
/**
* Der erkannte Text einer Datei, in Seiten zerlegt.
*
* Für die Suche im PDF-Betrachter: Bei einem Scan steht im PDF selbst kein
* Wort, nur ein Bild – ohne diesen Weg fände die Suche im Dokument nichts.
* `source` sagt, woher der Text stammt ('layer' aus dem PDF, 'ocr' erkannt).
*/
textPages: (id: string) =>
request<{ pages: string[]; source: string }>('GET', `/files/${id}/text`),
delete: (id: string) => request<void>('DELETE', `/files/${id}`),
rename: (id: string, name: string) =>
request<FileItem>('PATCH', `/files/${id}/rename`, { name }),
move: (id: string, folderId: string | null) =>
request<FileItem>('PATCH', `/files/${id}/move`, { folder_id: folderId }),
}
// ── Folders ───────────────────────────────────────────────────────────────────
export const foldersApi = {
list: async (parentId?: string): Promise<FolderItem[]> => {
const data = await filesApi.list(parentId)
return data.folders ?? []
},
create: (name: string, parentId?: string) =>
request<FolderItem>('POST', '/folders', { name, parent_id: parentId }),
rename: (id: string, name: string) =>
request<FolderItem>('PATCH', `/folders/${id}/rename`, { name }),
move: (id: string, parentId: string | null) =>
request<FolderItem>('PATCH', `/folders/${id}/move`, { parent_id: parentId }),
delete: (id: string) =>
request<void>('DELETE', `/folders/${id}`),
}
// ── Favorites ─────────────────────────────────────────────────────────────────
export interface FavoriteItem {
id: string
folder_id: string
folder_name: string
folder_parent_id: string | null
position: number
is_expanded: boolean
}
export const favoritesApi = {
list: () => request<FavoriteItem[]>('GET', '/favorites'),
add: (folderId: string) => request<FavoriteItem>('POST', '/favorites', { folder_id: folderId }),
remove: (folderId: string) => request<void>('DELETE', `/favorites/${folderId}`),
setExpanded: (folderId: string, expanded: boolean) =>
request<void>('PATCH', `/favorites/${folderId}/expand`, { expanded }),
reorder: (folderIds: string[]) =>
request<void>('PATCH', '/favorites/reorder', { folder_ids: folderIds }),
}
// ── Trash ─────────────────────────────────────────────────────────────────────
export interface TrashItem extends FileItem {
folder_path: string | null
}
export const trashApi = {
list: () => request<TrashItem[]>('GET', '/trash'),
restore: (id: string) => request<FileItem>('POST', `/trash/${id}/restore`),
deletePermanently: (id: string) => request<void>('DELETE', `/trash/${id}`),
emptyAll: () => request<void>('DELETE', '/trash'),
}
// ── Shares ────────────────────────────────────────────────────────────────────
export const sharesApi = {
create: (fileId: string, opts: CreateShareRequest) =>
request<ShareLink>('POST', `/files/${fileId}/share`, opts),
list: () => request<ShareLink[]>('GET', '/shares'),
delete: (id: string) => request<void>('DELETE', `/shares/${id}`),
// Public endpoints (no auth) – /share/ prefix avoids chi /api/v1 middleware conflict
getInfo: (token: string) =>
fetch(`/share/${token}`)
.then(r => r.json())
.then(j => (j.data ?? j) as PublicShareInfo),
download: (token: string, password?: string) =>
fetch(`/share/${token}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
}
// ── Versions ──────────────────────────────────────────────────────────────────
export const versionsApi = {
list: (fileId: string) =>
request<FileVersion[]>('GET', `/files/${fileId}/versions`),
download: (fileId: string, versionId: string) =>
`/api/v1/files/${fileId}/versions/${versionId}/download`,
restore: (fileId: string, versionId: string) =>
request<FileItem>('POST', `/files/${fileId}/versions/${versionId}/restore`),
setEnabled: (fileId: string, enabled: boolean) =>
request<FileItem>('PATCH', `/files/${fileId}/versioning`, { enabled }),
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface AdminUser {
id: string
username: string
display_name: string
is_admin: boolean
quota_bytes: number
used_bytes: number
groups: string[]
created_at: string
}
export interface AdminGroup {
id: string
name: string
description: string
member_count: number
created_at: string
}
export interface GroupMember {
user_id: string
username: string
display_name: string
joined_at: string
}
export interface AppSettings {
cloud_name: string
primary_color: string
accent_color: string
logo_storage_key: string
app_icon_storage_key: string
}
export interface AuditEntry {
id: string
actor_id: string | null
actor_name: string
action: string
target_type: string | null
target_id: string | null
target_name: string | null
ip: string | null
detail: string | null
created_at: string
}
export interface GroupFolder {
id: string
name: string
group_id: string
group_name: string
members: number
used_bytes: number
quota_bytes: number
}
function uploadBranding(kind: 'logo' | 'icon', file: File) {
const form = new FormData()
form.append('file', file)
return request<{ url: string }>('POST', `/admin/branding/${kind}`, form, true)
}
export const adminApi = {
// Branding
uploadLogo: (file: File) => uploadBranding('logo', file),
deleteLogo: () => request<void>('DELETE', '/admin/branding/logo'),
uploadAppIcon: (file: File) => uploadBranding('icon', file),
deleteAppIcon: () => request<void>('DELETE', '/admin/branding/icon'),
// Group folders
listGroupFolders: () => request<GroupFolder[]>('GET', '/admin/group-folders'),
createGroupFolder: (groupId: string, name: string) =>
request<FolderItem>('POST', '/admin/group-folders', { group_id: groupId, name }),
deleteGroupFolder: (id: string) => request<void>('DELETE', `/admin/group-folders/${id}`),
// Audit log
listAudit: (params: { action?: string; actorId?: string; limit?: number; offset?: number } = {}) => {
const qs = new URLSearchParams()
if (params.action) qs.set('action', params.action)
if (params.actorId) qs.set('actor_id', params.actorId)
if (params.limit != null) qs.set('limit', String(params.limit))
if (params.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs}` : ''
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
},
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
runCleanup: () => request<{
scanned: number; orphans: number; deleted: number
freed_bytes: number; skipped_young: number
}>('POST', '/admin/gc'),
/** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die
* Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */
ocrStatus: () => request<OcrStatus>('GET', '/admin/ocr'),
// Users
listUsers: () => request<AdminUser[]>('GET', '/admin/users'),
createUser: (data: { username: string; display_name: string; password: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('POST', '/admin/users', data),
updateUser: (id: string, data: { display_name: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('PATCH', `/admin/users/${id}`, data),
resetPassword: (id: string, password?: string) =>
request<{ new_password: string }>('POST', `/admin/users/${id}/reset-password`, password ? { password } : {}),
deleteUser: (id: string) => request<void>('DELETE', `/admin/users/${id}`),
// Groups
listGroups: () => request<AdminGroup[]>('GET', '/admin/groups'),
createGroup: (name: string, description: string) =>
request<AdminGroup>('POST', '/admin/groups', { name, description }),
updateGroup: (id: string, name: string, description: string) =>
request<AdminGroup>('PATCH', `/admin/groups/${id}`, { name, description }),
deleteGroup: (id: string) => request<void>('DELETE', `/admin/groups/${id}`),
listMembers: (groupId: string) => request<GroupMember[]>('GET', `/admin/groups/${groupId}/members`),
addMember: (groupId: string, userId: string) =>
request<void>('POST', `/admin/groups/${groupId}/members`, { user_id: userId }),
removeMember: (groupId: string, userId: string) =>
request<void>('DELETE', `/admin/groups/${groupId}/members/${userId}`),
// Settings
getSettings: () => request<AppSettings>('GET', '/admin/settings'),
updateSettings: (data: Partial<AppSettings>) =>
request<AppSettings>('PATCH', '/admin/settings', data),
}
export { APIError }