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>
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|