feat(CC-079): add Delegated Access page (roles CRUD + member assignment view)
This commit is contained in:
parent
36c0dc0ac0
commit
d66dd56601
1 changed files with 169 additions and 0 deletions
169
src/app/dashboard/privacy/delegated/page.tsx
Normal file
169
src/app/dashboard/privacy/delegated/page.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface Role {
|
||||
id: string; name: string; description: string | null;
|
||||
permissions: string[]; createdAt: string; isSystem: boolean | null;
|
||||
}
|
||||
|
||||
interface WorkspaceMember {
|
||||
id: string; userId: string; email: string | null; role: string; joinedAt: string;
|
||||
}
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
read: 'Citire', write: 'Scriere', delete: 'Ștergere', admin: 'Admin',
|
||||
ai: 'AI', export: 'Export', import: 'Import', billing: 'Facturare',
|
||||
};
|
||||
|
||||
export default function DelegatedAccessPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Role | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '', permissions: [] as string[] });
|
||||
|
||||
const { data: roles = [], isLoading: rolesL } = useQuery({
|
||||
queryKey: ['roles-delegated', tenantId],
|
||||
queryFn: () => apiFetch<Role[]>('/v1/roles', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
const { data: members = [] } = useQuery({
|
||||
queryKey: ['members-delegated', tenantId],
|
||||
queryFn: () => apiFetch<WorkspaceMember[]>('/v1/workspace/members', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/roles', { tenantId, method: 'POST', body: form }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['roles-delegated', tenantId] }); setShowCreate(false); setForm({ name: '', description: '', permissions: [] }); },
|
||||
});
|
||||
|
||||
function togglePerm(p: string) {
|
||||
setForm((prev) => ({ ...prev, permissions: prev.permissions.includes(p) ? prev.permissions.filter((x) => x !== p) : [...prev.permissions, p] }));
|
||||
}
|
||||
|
||||
const PERMS = Object.keys(PERMISSION_LABELS);
|
||||
const roleMembers = members.filter((m) => selected && m.role === selected.name);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Acces Delegat</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{rolesL ? 'Se încarcă…' : `${roles.length} roluri · ${members.length} membri`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Rol nou
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Rol nou</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Nume rol" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Descriere" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-ink-faint mb-2">Permisiuni</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PERMS.map((p) => (
|
||||
<button key={p} onClick={() => togglePerm(p)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${form.permissions.includes(p) ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
|
||||
{PERMISSION_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.name || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2 space-y-2">
|
||||
{rolesL ? <p className="text-center text-sm text-ink-faint py-4">Se încarcă…</p>
|
||||
: roles.length === 0 ? <p className="text-center text-sm text-ink-faint py-4">Niciun rol.</p>
|
||||
: roles.map((role) => (
|
||||
<button key={role.id} onClick={() => setSelected(role)}
|
||||
className={`w-full text-left card p-4 hover:border-primary/40 transition-colors ${selected?.id === role.id ? 'border-primary/50 bg-primary/5' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-ink flex-1">{role.name}</p>
|
||||
{role.isSystem && <span className="text-[9px] bg-muted text-ink-faint rounded px-1">sistem</span>}
|
||||
</div>
|
||||
{role.description && <p className="text-[10px] text-ink-faint mt-0.5 line-clamp-1">{role.description}</p>}
|
||||
<div className="flex flex-wrap gap-0.5 mt-1.5">
|
||||
{(role.permissions ?? []).slice(0, 4).map((p) => (
|
||||
<span key={p} className="rounded bg-primary/10 px-1 text-[8px] text-ink">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
{!selected ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🔑</p>
|
||||
<p className="text-xs text-ink-faint">Selectează un rol.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-ink">{selected.name}</h2>
|
||||
{selected.description && <p className="text-xs text-ink-faint mt-0.5">{selected.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-semibold text-ink-faint uppercase tracking-wider">Permisiuni</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(selected.permissions ?? []).length === 0 ? (
|
||||
<p className="text-xs text-ink-faint">Nicio permisiune.</p>
|
||||
) : (selected.permissions ?? []).map((p) => (
|
||||
<span key={p} className="rounded-full bg-primary/10 px-2.5 py-0.5 text-xs text-ink">
|
||||
{PERMISSION_LABELS[p] ?? p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-border/50 pt-4">
|
||||
<p className="text-[10px] font-semibold text-ink-faint uppercase tracking-wider">
|
||||
Membri cu acest rol ({roleMembers.length})
|
||||
</p>
|
||||
{roleMembers.length === 0 ? (
|
||||
<p className="text-xs text-ink-faint">Niciun membru cu acest rol.</p>
|
||||
) : roleMembers.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-2 py-1">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-[10px] font-bold text-primary">
|
||||
{(m.email ?? '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs text-ink">{m.email ?? m.userId}</span>
|
||||
<span className="text-[10px] text-ink-faint ml-auto">
|
||||
{new Date(m.joinedAt).toLocaleDateString('ro-RO')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue