421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
import { asError } from '@/utils/errorTypes';
|
|
import React, { useState, useMemo } from 'react';
|
|
// TODO: P2-T19 — Replace hardcoded PermissionLevelBadge/PrincipalTypeBadge with i18n
|
|
import { useTranslation } from 'react-i18next';
|
|
import { SettingsRolesPage } from './SettingsRoles';
|
|
import { SettingsGroupsPage } from './SettingsGroups';
|
|
import { useAllEntityPermissions, useEntityRegistry } from '@/api/entityPermissionHooks';
|
|
import { type EntityPermission, type EntityRegistryEntry } from '@/api/entityPermissions';
|
|
import { useAuditLog, type AuditLogEntry } from '@/api/audit';
|
|
import { usePermission } from '@/hooks/usePermission';
|
|
import { Table, type TableColumn } from '@/components/ui/Table';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Select } from '@/components/ui/Select';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
|
import { apiDelete } from '@/api/client';
|
|
import { Search, Trash2, Shield, Users, Share2, History, LayoutGrid } from 'lucide-react';
|
|
import { WorkspaceManager } from '@/components/settings/WorkspaceManager';
|
|
import clsx from 'clsx';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function getPrincipalName(p: EntityPermission): string | null {
|
|
return p.principal_type === 'user' ? p.user_name : p.group_name;
|
|
}
|
|
|
|
function getPrincipalId(p: EntityPermission): string | null {
|
|
return p.principal_type === 'user' ? p.user_id : p.group_id;
|
|
}
|
|
|
|
// ─── Permission Level Badge ────────────────────────────────────────────────
|
|
|
|
function PermissionLevelBadge({ level }: { level: string }) {
|
|
const colors: Record<string, string> = {
|
|
read: 'bg-blue-100 text-blue-800',
|
|
write: 'bg-yellow-100 text-yellow-800',
|
|
admin: 'bg-purple-100 text-purple-800',
|
|
delete: 'bg-red-100 text-red-800',
|
|
owner: 'bg-green-100 text-green-800',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
read: 'Lesen',
|
|
write: 'Schreiben',
|
|
admin: 'Admin',
|
|
delete: 'Löschen',
|
|
owner: 'Besitzer',
|
|
};
|
|
return (
|
|
<Badge className={colors[level] || 'bg-secondary-100 text-secondary-800'}>
|
|
{labels[level] || level}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// ─── Principal Type Badge ────────────────────────────────────────────────────
|
|
|
|
function PrincipalTypeBadge({ type }: { type: string }) {
|
|
const colors: Record<string, string> = {
|
|
user: 'bg-cyan-100 text-cyan-800',
|
|
group: 'bg-indigo-100 text-indigo-800',
|
|
role: 'bg-amber-100 text-amber-800',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
user: 'Benutzer',
|
|
group: 'Gruppe',
|
|
role: 'Rolle',
|
|
};
|
|
return (
|
|
<Badge className={colors[type] || 'bg-secondary-100 text-secondary-800'}>
|
|
{labels[type] || type}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// ─── Freigaben Tab ─────────────────────────────────────────────────────────
|
|
|
|
function FreigabenTab() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const { hasPermission } = usePermission();
|
|
const { data: permsData, isLoading: permsLoading, refetch: refetchPerms } = useAllEntityPermissions();
|
|
const { data: registryData, isLoading: registryLoading } = useEntityRegistry();
|
|
const [filterEntityType, setFilterEntityType] = useState('');
|
|
const [filterPrincipal, setFilterPrincipal] = useState('');
|
|
const [confirmDelete, setConfirmDelete] = useState<EntityPermission | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const entityTypeMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
if (registryData) {
|
|
for (const entry of registryData) {
|
|
map.set(entry.entity_type, entry.display_name);
|
|
}
|
|
}
|
|
return map;
|
|
}, [registryData]);
|
|
|
|
const entityTypeOptions = useMemo(() => {
|
|
const types = new Set<string>();
|
|
if (permsData?.items) {
|
|
for (const p of permsData.items) {
|
|
types.add(p.entity_type);
|
|
}
|
|
}
|
|
return Array.from(types).sort().map((t) => ({
|
|
value: t,
|
|
label: entityTypeMap.get(t) || t,
|
|
}));
|
|
}, [permsData, entityTypeMap]);
|
|
|
|
const filteredPerms = useMemo(() => {
|
|
if (!permsData?.items) return [];
|
|
let items = permsData.items;
|
|
if (filterEntityType) {
|
|
items = items.filter((p) => p.entity_type === filterEntityType);
|
|
}
|
|
if (filterPrincipal) {
|
|
const q = filterPrincipal.toLowerCase();
|
|
items = items.filter(
|
|
(p) => {
|
|
const name = getPrincipalName(p);
|
|
const id = getPrincipalId(p);
|
|
return (name && name.toLowerCase().includes(q)) ||
|
|
(id && id.toLowerCase().includes(q));
|
|
}
|
|
);
|
|
}
|
|
return items;
|
|
}, [permsData, filterEntityType, filterPrincipal]);
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirmDelete) return;
|
|
setDeleting(true);
|
|
try {
|
|
await apiDelete(`/permissions/${confirmDelete.entity_type}/${confirmDelete.entity_id}/${confirmDelete.id}`);
|
|
toast.success('Berechtigung gelöscht');
|
|
setConfirmDelete(null);
|
|
refetchPerms();
|
|
} catch (err: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Löschen');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const columns: TableColumn<EntityPermission>[] = [
|
|
{
|
|
key: 'entity_type',
|
|
header: 'Entity-Typ',
|
|
render: (row) => entityTypeMap.get(row.entity_type) || row.entity_type,
|
|
},
|
|
{ key: 'entity_id', header: 'Entity-ID', render: (row) => row.entity_id.slice(0, 8) + '…' },
|
|
{
|
|
key: 'principal_type',
|
|
header: 'Principal',
|
|
render: (row) => {
|
|
const name = getPrincipalName(row);
|
|
const id = getPrincipalId(row);
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<PrincipalTypeBadge type={row.principal_type} />
|
|
<span>{name || (id ? id.slice(0, 8) + '…' : '—')}</span>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'permission_level',
|
|
header: 'Berechtigungsstufe',
|
|
render: (row) => <PermissionLevelBadge level={row.permission_level} />,
|
|
},
|
|
{
|
|
key: 'expires_at',
|
|
header: 'Läuft ab',
|
|
render: (row) => {
|
|
if (!row.expires_at) return '—';
|
|
const expires = new Date(row.expires_at);
|
|
const now = new Date();
|
|
const isExpired = expires < now;
|
|
return (
|
|
<span className={isExpired ? 'text-danger-600' : ''}>
|
|
{expires.toLocaleDateString('de-DE')}
|
|
{isExpired && ' (abgelaufen)'}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (row) =>
|
|
hasPermission('settings:write') ? (
|
|
<button
|
|
onClick={() => setConfirmDelete(row)}
|
|
className="p-1 rounded hover:bg-danger-50 text-danger-600"
|
|
aria-label="Löschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
if (permsLoading || registryLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card title="Freigaben Übersicht">
|
|
<div className="flex gap-4 mb-4">
|
|
<div className="w-64">
|
|
<Select
|
|
label="Entity-Typ filtern"
|
|
options={[
|
|
{ value: '', label: 'Alle' },
|
|
...entityTypeOptions,
|
|
]}
|
|
value={filterEntityType}
|
|
onChange={(e) => setFilterEntityType(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="w-64">
|
|
<Input
|
|
label="Nach Principal suchen"
|
|
placeholder="Name oder ID..."
|
|
value={filterPrincipal}
|
|
onChange={(e) => setFilterPrincipal(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
data={filteredPerms}
|
|
rowKey={(row) => row.id}
|
|
emptyMessage="Keine Freigaben gefunden"
|
|
/>
|
|
</Card>
|
|
|
|
{confirmDelete && (
|
|
<ConfirmDialog
|
|
open={!!confirmDelete}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
onConfirm={handleDelete}
|
|
title="Berechtigung löschen"
|
|
message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`}
|
|
confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Audit Tab ─────────────────────────────────────────────────────────────
|
|
|
|
function AuditTab() {
|
|
const { t } = useTranslation();
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 25;
|
|
const { data, isLoading, isError, error } = useAuditLog(page, pageSize, {
|
|
action: 'permission_grant,permission_revoke,permission_update',
|
|
});
|
|
|
|
const columns: TableColumn<AuditLogEntry>[] = [
|
|
{
|
|
key: 'timestamp',
|
|
header: 'Zeitpunkt',
|
|
render: (row) => new Date(row.timestamp).toLocaleString('de-DE'),
|
|
},
|
|
{ key: 'user', header: 'Benutzer' },
|
|
{
|
|
key: 'action',
|
|
header: 'Aktion',
|
|
render: (row: AuditLogEntry) => {
|
|
const labels: Record<string, string> = {
|
|
permission_grant: 'Freigabe erteilt',
|
|
permission_revoke: 'Freigabe entzogen',
|
|
permission_update: 'Freigabe geändert',
|
|
};
|
|
const colors: Record<string, string> = {
|
|
permission_grant: 'bg-green-100 text-green-800',
|
|
permission_revoke: 'bg-red-100 text-red-800',
|
|
permission_update: 'bg-yellow-100 text-yellow-800',
|
|
};
|
|
return (
|
|
<Badge className={colors[row.action] || 'bg-secondary-100 text-secondary-800'}>
|
|
{labels[row.action] || row.action}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{ key: 'entity', header: 'Entity' },
|
|
{
|
|
key: 'details',
|
|
header: 'Details',
|
|
render: (row: AuditLogEntry) => {
|
|
if (!row.details) return '—';
|
|
try {
|
|
const parsed = JSON.parse(row.details);
|
|
return (
|
|
<span className="text-xs font-mono">
|
|
{Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join(', ')}
|
|
</span>
|
|
);
|
|
} catch {
|
|
return <span className="text-xs">{row.details}</span>;
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
const totalPages = data?.total ? Math.ceil(data.total / pageSize) : 1;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card title="Audit-Log für Berechtigungen">
|
|
<Table
|
|
columns={columns}
|
|
data={data?.items || []}
|
|
rowKey={(row) => row.id}
|
|
loading={isLoading}
|
|
emptyMessage="Keine Audit-Einträge gefunden"
|
|
/>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<span className="text-sm text-secondary-600">
|
|
Seite {page} von {totalPages}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Zurück
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ──────────────────────────────────────────────────────────────
|
|
|
|
export function SettingsRechtePage() {
|
|
const { t } = useTranslation();
|
|
const { hasPermission } = usePermission();
|
|
const [activeTab, setActiveTab] = useState('rollen');
|
|
|
|
const tabs = [
|
|
{ key: 'rollen', label: 'Rollen', icon: Shield, permission: 'roles:read' },
|
|
{ key: 'gruppen', label: 'Gruppen', icon: Users, permission: 'groups:read' },
|
|
{ key: 'freigaben', label: 'Freigaben', icon: Share2, permission: 'settings:read' },
|
|
{ key: 'workspaces', label: 'Workspaces', icon: LayoutGrid, permission: 'workspaces:read' },
|
|
{ key: 'audit', label: 'Audit', icon: History, permission: 'audit:read' },
|
|
].filter((tab) => hasPermission(tab.permission));
|
|
|
|
return (
|
|
<div className="space-y-6" data-testid="settings-rechte-page">
|
|
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
|
|
|
|
<div className="border-b border-secondary-200">
|
|
<nav className="flex gap-4" role="tablist" aria-label="Rechteverwaltung Tabs">
|
|
{tabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
role="tab"
|
|
aria-selected={activeTab === tab.key}
|
|
className={clsx(
|
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
|
activeTab === tab.key
|
|
? 'border-primary-500 text-primary-700'
|
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
|
)}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
<div role="tabpanel">
|
|
{activeTab === 'rollen' && <SettingsRolesPage />}
|
|
{activeTab === 'gruppen' && <SettingsGroupsPage />}
|
|
{activeTab === 'freigaben' && <FreigabenTab />}
|
|
{activeTab === 'workspaces' && <WorkspaceManager />}
|
|
{activeTab === 'audit' && <AuditTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|