182 lines
7.5 KiB
TypeScript
182 lines
7.5 KiB
TypeScript
import { h } from 'preact';
|
|
import { useState, useEffect } from 'preact/hooks';
|
|
import type { PanelACL, Grant, Folder } from './lib/types';
|
|
|
|
interface Props {
|
|
ifaceId: string;
|
|
ifaceName: string;
|
|
folders: Folder[];
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
// ShareDialog edits a single panel's sharing settings: its folder, public
|
|
// visibility, and explicit per-user / per-group grants. Only the owner can
|
|
// reach it (the caller is gated upstream in InterfaceList).
|
|
export default function ShareDialog({ ifaceId, ifaceName, folders, onClose, onSaved }: Props) {
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [owner, setOwner] = useState('');
|
|
const [folder, setFolder] = useState('');
|
|
const [pub, setPub] = useState<'' | 'read' | 'write'>('');
|
|
const [grants, setGrants] = useState<Grant[]>([]);
|
|
const [userGroups, setUserGroups] = useState<string[]>([]);
|
|
|
|
// New-grant row state.
|
|
const [newKind, setNewKind] = useState<'user' | 'group'>('user');
|
|
const [newName, setNewName] = useState('');
|
|
const [newPerm, setNewPerm] = useState<'read' | 'write'>('read');
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`).then(r => r.ok ? r.json() : null),
|
|
fetch('/api/v1/usergroups').then(r => r.ok ? r.json() : []),
|
|
])
|
|
.then(([acl, groups]: [PanelACL | null, string[]]) => {
|
|
if (acl) {
|
|
setOwner(acl.owner || '');
|
|
setFolder(acl.folder || '');
|
|
setPub(acl.public || '');
|
|
setGrants(Array.isArray(acl.grants) ? acl.grants : []);
|
|
}
|
|
setUserGroups(Array.isArray(groups) ? groups : []);
|
|
})
|
|
.catch(() => setError('Failed to load sharing settings.'))
|
|
.finally(() => setLoading(false));
|
|
}, [ifaceId]);
|
|
|
|
function addGrant() {
|
|
const name = newName.trim();
|
|
if (!name) return;
|
|
// Replace an existing grant for the same kind+name rather than duplicating.
|
|
const next = grants.filter(g => !(g.kind === newKind && g.name === name));
|
|
next.push({ kind: newKind, name, perm: newPerm });
|
|
setGrants(next);
|
|
setNewName('');
|
|
}
|
|
|
|
function removeGrant(idx: number) {
|
|
setGrants(grants.filter((_, i) => i !== idx));
|
|
}
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
setError('');
|
|
try {
|
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ folder, public: pub, grants }),
|
|
});
|
|
if (!res.ok) {
|
|
const msg = await res.json().catch(() => null);
|
|
throw new Error(msg?.error || `Save failed (${res.status})`);
|
|
}
|
|
onSaved();
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div class="wizard-backdrop" onClick={onClose}>
|
|
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 560px;">
|
|
<div class="wizard-header">
|
|
<span>Share — {ifaceName || ifaceId}</span>
|
|
<button class="icon-btn" onClick={onClose}>✕</button>
|
|
</div>
|
|
|
|
<div class="wizard-body">
|
|
{loading ? (
|
|
<p class="hint">Loading…</p>
|
|
) : (
|
|
<div>
|
|
{owner && (
|
|
<p class="hint" style="padding: 0 0 0.75rem 0;">Owner: <strong>{owner}</strong></p>
|
|
)}
|
|
|
|
<div class="wizard-section-title">Folder</div>
|
|
<div class="wizard-field">
|
|
<select class="prop-select" value={folder} onChange={e => setFolder((e.target as HTMLSelectElement).value)}>
|
|
<option value="">(root — no folder)</option>
|
|
{folders.map(f => (
|
|
<option key={f.id} value={f.id}>{f.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="wizard-section-title" style="margin-top: 1rem;">Public access</div>
|
|
<div class="wizard-field">
|
|
<select class="prop-select" value={pub} onChange={e => setPub((e.target as HTMLSelectElement).value as any)}>
|
|
<option value="">Private (only owner & shares)</option>
|
|
<option value="read">Everyone can view</option>
|
|
<option value="write">Everyone can edit</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="wizard-section-title" style="margin-top: 1rem;">Shared with</div>
|
|
{grants.length === 0 ? (
|
|
<p class="hint" style="padding: 0 0 0.5rem 0;">Not shared with anyone yet.</p>
|
|
) : (
|
|
<ul class="iface-list" style="margin-bottom: 0.5rem;">
|
|
{grants.map((g, idx) => (
|
|
<li key={`${g.kind}:${g.name}`} class="iface-item" style="cursor: default;">
|
|
<span>
|
|
<span style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155; margin-right: 6px;">
|
|
{g.kind}
|
|
</span>
|
|
<span style="color: #e2e8f0;">{g.name}</span>
|
|
<span style="color: #64748b; margin-left: 6px;">({g.perm})</span>
|
|
</span>
|
|
<button class="icon-btn iface-delete" title="Remove" onClick={() => removeGrant(idx)}>✕</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div class="wizard-field wizard-field-row" style="gap: 0.5rem; align-items: center;">
|
|
<select class="prop-select" style="flex: 0 0 80px;" value={newKind} onChange={e => { setNewKind((e.target as HTMLSelectElement).value as any); setNewName(''); }}>
|
|
<option value="user">User</option>
|
|
<option value="group">Group</option>
|
|
</select>
|
|
{newKind === 'group' ? (
|
|
<select class="prop-select" style="flex: 1;" value={newName} onChange={e => setNewName((e.target as HTMLSelectElement).value)}>
|
|
<option value="">Select group…</option>
|
|
{userGroups.map(g => <option key={g} value={g}>{g}</option>)}
|
|
</select>
|
|
) : (
|
|
<input
|
|
class="prop-input"
|
|
style="flex: 1;"
|
|
placeholder="username"
|
|
value={newName}
|
|
onInput={e => setNewName((e.target as HTMLInputElement).value)}
|
|
onKeyDown={e => e.key === 'Enter' && addGrant()}
|
|
/>
|
|
)}
|
|
<select class="prop-select" style="flex: 0 0 80px;" value={newPerm} onChange={e => setNewPerm((e.target as HTMLSelectElement).value as any)}>
|
|
<option value="read">read</option>
|
|
<option value="write">write</option>
|
|
</select>
|
|
<button class="panel-btn" onClick={addGrant} disabled={!newName.trim()}>Add</button>
|
|
</div>
|
|
|
|
{error && <p class="wizard-error" style="margin-top: 0.75rem;">{error}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div class="wizard-footer">
|
|
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
|
<button class="toolbar-btn toolbar-btn-primary" onClick={save} disabled={saving || loading}>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|