Files
Aether/frontend/src/components/ShareWithDialog.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

273 lines
10 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 } 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>
)
}