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

+357
View File
@@ -0,0 +1,357 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type AdminUser, type AdminGroup, type GroupMember, type GroupFolder } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { FolderGlyph } from '../../components/icons'
import { Card, Btn, Input, Modal, formatBytes } from './ui'
// Gruppen und Gruppenordner.
// ── Groups Tab ────────────────────────────────────────────────────────────────
/** Gruppenordner: ein Ordner, der der Gruppe gehört statt einer Person. Jedes
* Mitglied sieht ihn in seiner Wurzel; entfernen kann ihn nur der Admin – und
* zwar hier, nicht in der Dateiansicht. Deshalb steht der Löschen-Knopf
* ausschließlich an dieser Stelle. */
function GroupFolders({ groups }: { groups: AdminGroup[] }) {
const [folders, setFolders] = useState<GroupFolder[]>([])
const [loading, setLoading] = useState(true)
const [groupId, setGroupId] = useState('')
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState<GroupFolder | null>(null)
useEffect(() => { load() }, [])
async function load() {
try {
setFolders(await adminApi.listGroupFolders() ?? [])
} catch {
setError('Could not load group folders')
} finally {
setLoading(false)
}
}
async function create() {
if (!groupId || !name.trim()) return
setBusy(true); setError('')
try {
await adminApi.createGroupFolder(groupId, name.trim())
setName('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create group folder')
} finally {
setBusy(false)
}
}
async function remove(folder: GroupFolder) {
setConfirming(null)
try {
await adminApi.deleteGroupFolder(folder.id)
await load()
} catch {
setError('Could not delete group folder')
}
}
return (
<div style={{ marginBottom: 28 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Group folders
</h3>
<p style={{ margin: '0 0 14px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
A folder that belongs to the group instead of a person. Every member sees it
next to their own folders and can work in it. Members cannot delete or rename
the folder itself — only you can, here.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<select
value={groupId}
onChange={e => setGroupId(e.target.value)}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">Choose a group…</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<input
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create() }}
placeholder="Folder name"
style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
/>
<button
onClick={create}
disabled={!groupId || !name.trim() || busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: !groupId || !name.trim() || busy ? 'default' : 'pointer',
opacity: !groupId || !name.trim() || busy ? 0.5 : 1,
}}
>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
{error && <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : folders.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>No group folders yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{folders.map(f => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 9, border: '1px solid var(--color-border)',
}}>
<span style={{ flexShrink: 0, display: 'flex' }}>
<FolderGlyph size={20} color="rgb(var(--color-primary-rgb))" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{f.group_name} · {f.members} {f.members === 1 ? 'member' : 'members'} · {formatBytes(f.used_bytes)} used
</div>
</div>
<button
onClick={() => setConfirming(f)}
style={{
padding: '5px 12px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', fontSize: 12, cursor: 'pointer',
}}
>
Delete
</button>
</div>
))}
</div>
)}
{confirming && (
<ConfirmDialog
title="Delete group folder"
message={`"${confirming.name}" and everything in it will be permanently deleted for all ${confirming.members} members. This cannot be undone.`}
confirmLabel="Delete permanently"
danger
onConfirm={() => remove(confirming)}
onCancel={() => setConfirming(null)}
/>
)}
</div>
)
}
export default function GroupsTab({ allUsers }: { allUsers: AdminUser[] }) {
const [groups, setGroups] = useState<AdminGroup[]>([])
const [members, setMembers] = useState<Record<string, GroupMember[]>>({})
const [expanded, setExpanded] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<AdminGroup | null>(null)
const [confirmGroup, setConfirmGroup] = useState<AdminGroup | null>(null)
const [groupError, setGroupError] = useState('')
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const load = useCallback(() => {
adminApi.listGroups()
.then(data => setGroups(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function toggleExpand(groupId: string) {
if (expanded === groupId) { setExpanded(null); return }
setExpanded(groupId)
if (!members[groupId]) {
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
}
}
async function handleCreate() {
if (!newName) return
await adminApi.createGroup(newName, newDesc)
setNewName(''); setNewDesc(''); setShowCreate(false); load()
}
async function handleDelete(g: AdminGroup) {
setConfirmGroup(null)
try {
await adminApi.deleteGroup(g.id)
load()
} catch (e) {
setGroupError(`Could not delete group: ${e instanceof Error ? e.message : e}`)
}
}
function openEdit(g: AdminGroup) {
setEditing(g); setEditName(g.name); setEditDesc(g.description ?? '')
}
async function handleUpdate() {
if (!editing || !editName.trim()) return
try {
await adminApi.updateGroup(editing.id, editName.trim(), editDesc)
setEditing(null); load()
} catch (e) {
setGroupError(`Could not save group: ${e instanceof Error ? e.message : e}`)
}
}
async function handleAddMember(groupId: string, userId: string) {
await adminApi.addMember(groupId, userId)
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
load()
}
async function handleRemoveMember(groupId: string, userId: string) {
await adminApi.removeMember(groupId, userId)
setMembers(m => ({ ...m, [groupId]: (m[groupId] ?? []).filter(x => x.user_id !== userId) }))
load()
}
return (
<div>
{/* Gruppenordner zuerst: Mitgliedschaft ohne gemeinsamen Ordner tut nichts,
der Ordner ist der eigentliche Zweck einer Gruppe. */}
<GroupFolders groups={groups} />
<div style={{
height: 1, background: 'var(--color-border)', margin: '0 0 24px',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Groups</h2>
<Btn variant="primary" onClick={() => setShowCreate(v => !v)}>+ Create group</Btn>
</div>
{showCreate && (
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}><Input label="Name" value={newName} onChange={setNewName} placeholder="e.g. Editors" /></div>
<div style={{ flex: 2 }}><Input label="Description" value={newDesc} onChange={setNewDesc} placeholder="Optional" /></div>
<Btn variant="primary" onClick={handleCreate} disabled={!newName}>Create</Btn>
<Btn onClick={() => setShowCreate(false)}>Cancel</Btn>
</div>
</Card>
)}
{groupError && (
<Card style={{
marginBottom: 12, borderColor: 'var(--color-danger)',
background: 'rgba(239,68,68,0.08)', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--color-danger)' }}>{groupError}</span>
<button onClick={() => setGroupError('')} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-danger)', fontSize: 16, lineHeight: 1,
}}>×</button>
</Card>
)}
{confirmGroup && (
<ConfirmDialog
title="Delete group"
message={`Delete group "${confirmGroup.name}"? Members keep their accounts and files – only the group is removed.`}
confirmLabel="Delete group"
danger
onConfirm={() => handleDelete(confirmGroup)}
onCancel={() => setConfirmGroup(null)}
/>
)}
{editing && (
<Modal title={`Edit group "${editing.name}"`} onClose={() => setEditing(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Name" value={editName} onChange={setEditName} />
<Input label="Description" value={editDesc} onChange={setEditDesc} placeholder="Optional" />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn onClick={() => setEditing(null)}>Cancel</Btn>
<Btn variant="primary" onClick={handleUpdate} disabled={!editName.trim()}>Save</Btn>
</div>
</div>
</Modal>
)}
<Card>
{loading && <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>}
{!loading && groups.length === 0 && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center', padding: '20px 0' }}>No groups yet</p>
)}
{groups.map((g, i) => {
const isOpen = expanded === g.id
const groupMembers = members[g.id] ?? []
const nonMembers = allUsers.filter(u => !groupMembers.some(m => m.user_id === u.id))
return (
<div key={g.id} style={{ borderBottom: i < groups.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 4px', gap: 10 }}>
<button onClick={() => toggleExpand(g.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{g.name}</div>
{g.description && <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.description}</div>}
</div>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.member_count} {g.member_count === 1 ? 'member' : 'members'}</span>
<Btn size="sm" onClick={() => openEdit(g)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(g)}>Delete</Btn>
</div>
{isOpen && (
<div style={{ paddingLeft: 28, paddingBottom: 12 }}>
{groupMembers.length === 0 && <p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '4px 0 8px' }}>No members</p>}
{groupMembers.map(m => (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<span style={{ fontSize: 13, color: 'var(--color-text)', flex: 1 }}>{m.username}{m.display_name ? ` (${m.display_name})` : ''}</span>
<Btn size="sm" variant="danger" onClick={() => handleRemoveMember(g.id, m.user_id)}>Remove</Btn>
</div>
))}
{nonMembers.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<select
style={{ padding: '5px 8px', borderRadius: 7, border: '1px solid var(--color-border)', background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 12 }}
defaultValue=""
onChange={e => { if (e.target.value) { handleAddMember(g.id, e.target.value); e.target.value = '' } }}
>
<option value="" disabled>Add member…</option>
{nonMembers.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
)}
</div>
)}
</div>
)
})}
</Card>
</div>
)
}