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>
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import {
|
||
aclApi,
|
||
type AclEntry, type ShareRecipient, type SharePermission,
|
||
} from '../api/client'
|
||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||
|
||
interface Props {
|
||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
||
resource: { type: 'folder' | 'file'; id: string; name: string }
|
||
onClose: () => void
|
||
}
|
||
|
||
/** Freigabe an Personen und Gruppen innerhalb der Cloud.
|
||
*
|
||
* Bewusst getrennt vom öffentlichen Freigabelink: Der eine gibt einen
|
||
* anonymen Zugang ins Internet, der andere benennt Personen, die sich
|
||
* ohnehin anmelden. Ein Dialog für beides würde die Frage „wer kann das
|
||
* sehen?" verwischen – und genau die muss beim Teilen eindeutig sein. */
|
||
export default function ShareWithDialog({ resource, onClose }: Props) {
|
||
const overlay = useOverlayDismiss(onClose)
|
||
|
||
const [entries, setEntries] = useState<AclEntry[]>([])
|
||
const [recipients, setRecipients] = useState<ShareRecipient[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
|
||
const [subject, setSubject] = useState('')
|
||
const [permission, setPermission] = useState<SharePermission>('read')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
useEffect(() => { load() }, [resource.id])
|
||
|
||
async function load() {
|
||
setLoading(true)
|
||
try {
|
||
const [list, people] = await Promise.all([
|
||
resource.type === 'folder'
|
||
? aclApi.listForFolder(resource.id)
|
||
: aclApi.listForFile(resource.id),
|
||
aclApi.recipients(),
|
||
])
|
||
setEntries(list ?? [])
|
||
setRecipients(people ?? [])
|
||
} catch {
|
||
setError('Could not load sharing settings')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function grant() {
|
||
const chosen = recipients.find(r => r.id === subject)
|
||
if (!chosen) return
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
if (resource.type === 'folder') {
|
||
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||
} else {
|
||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||
}
|
||
setSubject('')
|
||
await load()
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Could not share')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
async function revoke(entry: AclEntry) {
|
||
try {
|
||
await aclApi.revoke(entry.id)
|
||
setEntries(list => list.filter(e => e.id !== entry.id))
|
||
} catch {
|
||
setError('Could not remove access')
|
||
}
|
||
}
|
||
|
||
// Wer schon Zugriff hat, gehört nicht noch einmal in die Auswahlliste.
|
||
const alreadyShared = new Set(entries.map(e => e.subject_id))
|
||
const available = recipients.filter(r => !alreadyShared.has(r.id))
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 300,
|
||
background: 'rgba(0,0,0,0.55)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||
}} {...overlay}>
|
||
<div style={{
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 16, width: 480, maxWidth: '100%', maxHeight: '85vh',
|
||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||
}}>
|
||
{/* Kopf */}
|
||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)' }}>
|
||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
|
||
Share with people
|
||
</h3>
|
||
<p style={{
|
||
margin: '3px 0 0', fontSize: 12, color: 'var(--color-text-muted)',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>
|
||
{resource.name}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Hinzufügen */}
|
||
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--color-border)' }}>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<select
|
||
value={subject}
|
||
onChange={e => setSubject(e.target.value)}
|
||
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',
|
||
}}
|
||
>
|
||
<option value="">Choose a person or group…</option>
|
||
{available.filter(r => r.type === 'user').length > 0 && (
|
||
<optgroup label="People">
|
||
{available.filter(r => r.type === 'user').map(r => (
|
||
<option key={r.id} value={r.id}>{r.name}</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
{available.filter(r => r.type === 'group').length > 0 && (
|
||
<optgroup label="Groups">
|
||
{available.filter(r => r.type === 'group').map(r => (
|
||
<option key={r.id} value={r.id}>{r.name}</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
</select>
|
||
|
||
<select
|
||
value={permission}
|
||
onChange={e => setPermission(e.target.value as SharePermission)}
|
||
style={{
|
||
padding: '8px 10px', borderRadius: 8, flexShrink: 0,
|
||
border: '1px solid var(--color-border)',
|
||
background: 'var(--color-surface-2)', color: 'var(--color-text)',
|
||
fontSize: 13, outline: 'none',
|
||
}}
|
||
>
|
||
<option value="read">Can view</option>
|
||
<option value="write">Can edit</option>
|
||
</select>
|
||
|
||
<button
|
||
onClick={grant}
|
||
disabled={!subject || saving}
|
||
style={{
|
||
padding: '8px 16px', borderRadius: 8, border: 'none', flexShrink: 0,
|
||
background: 'var(--color-primary)', color: '#fff',
|
||
fontSize: 13, fontWeight: 600,
|
||
cursor: !subject || saving ? 'default' : 'pointer',
|
||
opacity: !subject || saving ? 0.5 : 1,
|
||
}}
|
||
>
|
||
{saving ? '…' : 'Share'}
|
||
</button>
|
||
</div>
|
||
|
||
{resource.type === 'folder' && (
|
||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||
Everything inside this folder is shared too, including anything added later.
|
||
Files stay in your storage and count towards your quota.
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Wer hat Zugriff */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)',
|
||
textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 10,
|
||
}}>
|
||
Who has access
|
||
</div>
|
||
|
||
{loading ? (
|
||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
|
||
) : entries.length === 0 ? (
|
||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||
Only you. Nobody else can see this.
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{entries.map(e => (
|
||
<div key={e.id} style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '9px 11px', borderRadius: 9,
|
||
border: '1px solid var(--color-border)',
|
||
}}>
|
||
<span style={{ color: 'var(--color-text-muted)', display: 'flex', flexShrink: 0 }}>
|
||
{e.subject_type === 'group' ? <GroupIcon /> : <PersonIcon />}
|
||
</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 13, color: 'var(--color-text)',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>
|
||
{e.subject_name}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||
{e.subject_type === 'group' ? 'Group' : 'Person'} ·{' '}
|
||
{e.permission === 'write' ? 'Can edit' : 'Can view'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => revoke(e)}
|
||
style={{
|
||
padding: '4px 11px', borderRadius: 7, flexShrink: 0,
|
||
border: '1px solid var(--color-border)', background: 'transparent',
|
||
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{
|
||
padding: '12px 22px', borderTop: '1px solid var(--color-border)',
|
||
display: 'flex', justifyContent: 'flex-end',
|
||
}}>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
padding: '8px 18px', borderRadius: 9,
|
||
border: '1px solid var(--color-border)', background: 'transparent',
|
||
color: 'var(--color-text)', fontSize: 13, cursor: 'pointer',
|
||
}}
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PersonIcon() {
|
||
return (
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="12" cy="8" r="4" />
|
||
<path d="M4 21v-1a7 7 0 0114 0v1" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function GroupIcon() {
|
||
return (
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="9" cy="8" r="3.5" />
|
||
<path d="M2 21v-1a7 7 0 0114 0v1" />
|
||
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
|
||
</svg>
|
||
)
|
||
}
|