feat(delegations): UI fuer Berechtigungs-Delegationen — Modul 2/16 des UI-Backlogs

Backend existierte vollstaendig (5 Endpoints: list/create/update/delete/
active-check, delegations:read/write seit Audit-Fix im Katalog), Frontend
hatte 0% Abdeckung. Modul folgt dem Approvals-Muster (Modul 1):

- api/delegations.ts: TanStack-Hooks (useDelegations mit direction-Filter,
useActiveDelegations, create/update/delete-Mutations mit Cache-Invalidierung)
- pages/Delegations.tsx: Richtungstabs (alle/von mir/an mich), Karten mit
  Phasen-Badges (aktiv/geplant/abgelaufen/inaktiv), Erstellen-Dialog mit
  Empfaenger-Picker (useUsers, sich selbst ausschliessend), Start/Ende-
  Datetime, Scope-Toggle (alle Berechtigungen), Aktivieren/Deaktivieren,
  Loeschen mit Confirm — Aktionen hinter delegations:write gegated
- Route /delegations (PermissionRoute delegations:read) — als Core-Route
  bewusst statisch registriert (Phase-Q-Regel: nur Plugin-Routen laufen
  ueber Manifeste) Sidebar-Entry order 92 (ArrowRightLeft-Icon)
- i18n delegations.* + nav.delegations de/en

Verifikation: Vitest 9/9 (Rendering, Tabs, Phasen, Permission-Gating,
Create-Flow, Toggle, Delete) · tsc exit 0 · production build exit 0.
This commit is contained in:
Agent Zero
2026-09-13 09:17:17 +02:00
parent b58c96ff71
commit 36771d471d
7 changed files with 752 additions and 3 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Delegations API client — temporary permission handovers between users.
*
* Backend: /api/v1/delegations (list, create, update, delete, active-check).
* Lifecycle: active while now is within [start_at, end_at] and active=true.
* Permissions: delegations:read / delegations:write.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client';
export type DelegationDirection = 'from' | 'to' | 'all';
export interface Delegation {
id: string;
from_user_id: string;
to_user_id: string;
start_at: string | null;
end_at: string | null;
scope: Record<string, unknown> | null;
active: boolean;
tenant_id: string;
created_at: string | null;
updated_at: string | null;
}
export interface DelegationListResponse {
items: Delegation[];
total: number;
}
export interface DelegationCreatePayload {
to_user_id: string;
start_at: string;
end_at: string;
scope?: Record<string, unknown> | null;
}
export interface DelegationUpdatePayload {
start_at?: string;
end_at?: string;
scope?: Record<string, unknown> | null;
active?: boolean;
}
export interface ActiveDelegationCheck {
is_active: boolean;
active_delegations: Delegation[];
count: number;
}
// ─── Query hooks ─────────────────────────────────────────────
export function useDelegations(direction: DelegationDirection = 'all') {
return useQuery<DelegationListResponse>({
queryKey: ['delegations', direction],
queryFn: () =>
apiGet<DelegationListResponse>(`/delegations?direction=${direction}`),
});
}
export function useActiveDelegations() {
return useQuery<ActiveDelegationCheck>({
queryKey: ['delegations', 'active-check'],
queryFn: () => apiGet<ActiveDelegationCheck>('/delegations/active'),
});
}
// ─── Mutation hooks ──────────────────────────────────────────
function useInvalidateDelegations() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['delegations'] });
};
}
export function useCreateDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<Delegation, Error, DelegationCreatePayload>({
mutationFn: (data) => apiPost<Delegation>('/delegations', data),
onSuccess: invalidate,
});
}
export function useUpdateDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<
Delegation,
Error,
{ delegationId: string; payload: DelegationUpdatePayload }
>({
mutationFn: ({ delegationId, payload }) =>
apiPut<Delegation>(`/delegations/${delegationId}`, payload),
onSuccess: invalidate,
});
}
export function useDeleteDelegation() {
const invalidate = useInvalidateDelegations();
return useMutation<void, Error, string>({
mutationFn: (delegationId) =>
apiDelete(`/delegations/${delegationId}`),
onSuccess: invalidate,
});
}