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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,272 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user