Files
Aether/frontend/src/pages/admin/GroupsTab.tsx
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

358 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}