From b3eaa0e39b973836c862a039fd1dca202d6606b1 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 16 Sep 2026 00:08:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20G=C3=A4ste-Verwaltung=20UI=20?= =?UTF-8?q?=E2=80=94=20SettingsGuests=20(UI-Backlog=20Modul=2014/16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/guests.ts: useGuests, useInviteGuest, useRevokeGuest (/api/v1/guests) - SettingsGuests.tsx: Admin-Gate (Outbox-Muster), Gästeliste mit Status-Badges (invited/active/disabled), Invite-Modal (RHF+zod), Revoke-ConfirmDialog - Route /settings/guests + Nav-Eintrag in Settings.tsx - i18n de/en: 14 Keys - Vitest 8/8, tsc clean, Build OK --- .../__tests__/pages/SettingsGuests.test.tsx | 154 ++++++++++++ frontend/src/api/guests.ts | 65 ++++++ frontend/src/i18n/locales/de.json | 16 +- frontend/src/i18n/locales/en.json | 16 +- frontend/src/pages/Settings.tsx | 1 + frontend/src/pages/SettingsGuests.tsx | 221 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 7 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/pages/SettingsGuests.test.tsx create mode 100644 frontend/src/api/guests.ts create mode 100644 frontend/src/pages/SettingsGuests.tsx diff --git a/frontend/src/__tests__/pages/SettingsGuests.test.tsx b/frontend/src/__tests__/pages/SettingsGuests.test.tsx new file mode 100644 index 0000000..84c8aa9 --- /dev/null +++ b/frontend/src/__tests__/pages/SettingsGuests.test.tsx @@ -0,0 +1,154 @@ +/** + * Guests settings page tests — admin guest management UI + * (UI-Backlog module 14/16). + * + * Covers: admin gate, guest list rendering with status badges, + * invite dialog flow, revoke flow, empty state. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsGuestsPage } from '@/pages/SettingsGuests'; + +type GuestLike = { + id: string; + email: string; + name: string; + status: string; + role: string; + created_at: string | null; +}; + +const inviteMut = vi.fn().mockResolvedValue({}); +const revokeMut = vi.fn().mockResolvedValue({}); + +const makeGuest = (overrides: Partial = {}): GuestLike => ({ + id: '11111111-1111-1111-1111-111111111111', + email: 'gast@example.de', + name: 'Test Gast', + status: 'active', + role: 'guest', + created_at: '2026-09-01T10:00:00Z', + ...overrides, +}); + +let mockGuests: GuestLike[] = []; +let mockIsAdmin = true; +let mockLoading = false; + +vi.mock('@/api/guests', () => ({ + useGuests: () => ({ data: mockGuests, isLoading: mockLoading, isError: false }), + useInviteGuest: () => ({ mutate: inviteMut, mutateAsync: inviteMut, isPending: false }), + useRevokeGuest: () => ({ mutate: revokeMut, mutateAsync: revokeMut, isPending: false }), +})); + +vi.mock('@/store/authStore', () => ({ + useAuthStore: (selector: (state: { user: { is_system_admin: boolean } | null }) => unknown) => + selector({ user: { is_system_admin: mockIsAdmin } }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGuests = []; + mockIsAdmin = true; + mockLoading = false; +}); + +describe('SettingsGuestsPage', () => { + it('renders admin-only notice for non-admin users', () => { + mockIsAdmin = false; + renderPage(); + expect(screen.getByTestId('guests-admin-only')).toBeInTheDocument(); + expect(screen.queryByTestId('invite-guest-btn')).not.toBeInTheDocument(); + }); + + it('renders empty state when no guests exist', () => { + renderPage(); + expect(screen.getByTestId('settings-guests-page')).toBeInTheDocument(); + expect(screen.getByTestId('invite-guest-btn')).toBeInTheDocument(); + // EmptyState text is rendered via i18n (noGuests) + expect(screen.getByText(/Noch keine Gäste|No guests/i)).toBeInTheDocument(); + }); + + it('renders guest cards with status badges', () => { + mockGuests = [ + makeGuest({ status: 'active' }), + makeGuest({ id: '22222222-2222-2222-2222-222222222222', email: 'pending@example.de', name: 'Pending Gast', status: 'invited' }), + makeGuest({ id: '33333333-3333-3333-3333-333333333333', email: 'gone@example.de', name: 'Gone Gast', status: 'disabled' }), + ]; + renderPage(); + expect(screen.getByTestId('guest-card-11111111-1111-1111-1111-111111111111')).toBeInTheDocument(); + expect(screen.getByTestId('guest-card-22222222-2222-2222-2222-222222222222')).toBeInTheDocument(); + expect(screen.getByTestId('guest-card-33333333-3333-3333-3333-333333333333')).toBeInTheDocument(); + expect(screen.getByText('gast@example.de')).toBeInTheDocument(); + expect(screen.getByText('pending@example.de')).toBeInTheDocument(); + expect(screen.getByText('gone@example.de')).toBeInTheDocument(); + }); + + it('hides revoke button for disabled guests only', () => { + mockGuests = [ + makeGuest({ status: 'disabled' }), + ]; + renderPage(); + expect(screen.queryByTestId('revoke-guest-11111111-1111-1111-1111-111111111111')).not.toBeInTheDocument(); + }); + + it('shows revoke button for active guests', () => { + mockGuests = [makeGuest({ status: 'active' })]; + renderPage(); + expect(screen.getByTestId('revoke-guest-11111111-1111-1111-1111-111111111111')).toBeInTheDocument(); + }); + + it('opens invite dialog, fills form and submits', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('invite-guest-btn')); + const form = await screen.findByTestId('invite-guest-form'); + expect(form).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('guest-name'), { target: { value: 'Neuer Gast' } }); + fireEvent.change(screen.getByTestId('guest-email'), { target: { value: 'neu@example.de' } }); + fireEvent.click(screen.getByTestId('send-guest-invite-btn')); + + await waitFor(() => { + expect(inviteMut).toHaveBeenCalledWith({ name: 'Neuer Gast', email: 'neu@example.de' }); + }); + }); + + it('revokes a guest after confirmation', async () => { + mockGuests = [makeGuest({ status: 'active' })]; + renderPage(); + fireEvent.click(screen.getByTestId('revoke-guest-11111111-1111-1111-1111-111111111111')); + + // ConfirmDialog confirm button (default label, de locale) — must not + // match the card's revoke button ('Widerrufen'), so exact match. + const confirmBtn = await screen.findByRole('button', { name: 'Bestätigen' }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(revokeMut).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111'); + }); + }); + + it('blocks invite submission with invalid email (zod)', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('invite-guest-btn')); + await screen.findByTestId('invite-guest-form'); + + fireEvent.change(screen.getByTestId('guest-name'), { target: { value: 'Neuer Gast' } }); + fireEvent.change(screen.getByTestId('guest-email'), { target: { value: 'not-an-email' } }); + fireEvent.click(screen.getByTestId('send-guest-invite-btn')); + + await waitFor(() => { + expect(inviteMut).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/api/guests.ts b/frontend/src/api/guests.ts new file mode 100644 index 0000000..69a5d20 --- /dev/null +++ b/frontend/src/api/guests.ts @@ -0,0 +1,65 @@ +/** + * Guests API client — admin guest management (UI-Backlog module 14/16). + * + * Backend: /api/v1/guests (app/routes/guests.py, require_admin). + * - GET / → list guests (role=guest memberships for tenant) + * - POST /invite → invite guest (name + email; 409 if already active) + * - DELETE /{id} → revoke guest (status=disabled + session invalidation) + * + * Guests are regular users with role='guest' in user_tenants; they log in + * via the normal login flow (password via reset flow). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiDelete } from '@/api/client'; + +export interface Guest { + id: string; + email: string; + name: string; + status: 'invited' | 'active' | 'disabled' | string; + role: string; + created_at: string | null; +} + +export interface GuestInviteResponse { + email: string; + name: string; + status: string; + role: string; + message: string; +} + +export interface GuestInvitePayload { + email: string; + name: string; +} + +export function useGuests(enabled = true) { + return useQuery({ + queryKey: ['guests'], + queryFn: () => apiGet('/guests'), + enabled, + }); +} + +export function useInviteGuest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: GuestInvitePayload) => + apiPost('/guests/invite', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['guests'] }); + }, + }); +} + +export function useRevokeGuest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (guestId: string) => apiDelete<{ message: string }>(`/guests/${guestId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['guests'] }); + }, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0505ca7..21af10c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -389,7 +389,21 @@ "resetTheme": "Zurücksetzen", "saveTheme": "Theme speichern", "mcp": "MCP", - "documents": "Dokumente" + "documents": "Dokumente", + "guests": "Gäste", + "inviteGuest": "Gast einladen", + "guestsHint": "Gäste sind reguläre Benutzer mit eingeschränkter Rolle. Sie melden sich über den normalen Login an (Passwort über die Passwort-zurücksetzen-Funktion).", + "guestsAdminOnly": "Nur Administratoren können Gäste verwalten.", + "guestInvited": "Gast eingeladen", + "guestRevoked": "Gast widerrufen — alle Sitzungen ungültig", + "revokeGuest": "Widerrufen", + "revokeGuestConfirm": "Gastzugang widerrufen", + "noGuests": "Noch keine Gäste eingeladen", + "guestPasswordHint": "Der Gast erhält ein zufälliges Passwort und legt sein eigenes Passwort über die Passwort-zurücksetzen-Funktion fest.", + "guestStatus_active": "Aktiv", + "guestStatus_invited": "Eingeladen", + "guestStatus_disabled": "Widerrufen", + "guestStatus_unknown": "Unbekannt" }, "auditLog": { "title": "Audit-Log", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5a49ba8..74cb28a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -389,7 +389,21 @@ "resetTheme": "Reset", "saveTheme": "Save theme", "mcp": "MCP", - "documents": "Documents" + "documents": "Documents", + "guests": "Guests", + "inviteGuest": "Invite guest", + "guestsHint": "Guests are regular users with a restricted role. They sign in via the normal login flow (password via the reset flow).", + "guestsAdminOnly": "Only administrators can manage guests.", + "guestInvited": "Guest invited", + "guestRevoked": "Guest revoked — all sessions invalidated", + "revokeGuest": "Revoke", + "revokeGuestConfirm": "Revoke guest access", + "noGuests": "No guests invited yet", + "guestPasswordHint": "The guest gets a random password and sets their own password via the reset flow.", + "guestStatus_active": "Active", + "guestStatus_invited": "Invited", + "guestStatus_disabled": "Revoked", + "guestStatus_unknown": "Unknown" }, "auditLog": { "title": "Audit Log", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d848e07..ca44e53 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -52,6 +52,7 @@ export function SettingsPage() { { to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' }, { to: '/settings/permission-templates', label: 'Berechtigungs-Vorlagen', icon: '\ud83d\udd11' }, { to: '/settings/policies', label: 'ABAC-Richtlinien', icon: '\ud83d\udee1\ufe0f' }, + { to: '/settings/guests', label: 'Gäste', icon: '\ud83d\udc64' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/pages/SettingsGuests.tsx b/frontend/src/pages/SettingsGuests.tsx new file mode 100644 index 0000000..721ec0d --- /dev/null +++ b/frontend/src/pages/SettingsGuests.tsx @@ -0,0 +1,221 @@ +/** + * Guests settings page — admin guest management (UI-Backlog module 14/16). + * + * Backend: /api/v1/guests (require_admin). + * - List guests (role=guest memberships), invite new guests, revoke access. + * Guests are regular users with role='guest' who log in via the normal + * login flow (password via the standard reset flow). + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Lock } from 'lucide-react'; +import { asError } from '@/utils/errorTypes'; +import { + useGuests, + useInviteGuest, + useRevokeGuest, + type Guest, +} from '@/api/guests'; +import { useAuthStore } from '@/store/authStore'; +import { useToast } from '@/components/ui/Toast'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { Avatar } from '@/components/ui/Avatar'; + +const STATUS_VARIANT: Record = { + active: 'success', + invited: 'warning', + disabled: 'danger', +}; + +const inviteSchema = z.object({ + name: z.string().min(1, 'required'), + email: z.string().min(1, 'required').email('invalidEmail'), +}); + +type InviteFormData = z.infer; + +function statusLabel(status: string): string { + const key = STATUS_VARIANT[status] !== undefined ? status : 'unknown'; + return key; +} + +export function SettingsGuestsPage() { + const { t } = useTranslation(); + const toast = useToast(); + const user = useAuthStore((state) => state.user); + const isAdmin = user?.is_system_admin === true; + + const { data: guests, isLoading } = useGuests(isAdmin); + const inviteMutation = useInviteGuest(); + const revokeMutation = useRevokeGuest(); + + const [inviteOpen, setInviteOpen] = useState(false); + const [confirmRevoke, setConfirmRevoke] = useState(null); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(inviteSchema), + defaultValues: { name: '', email: '' }, + }); + + if (!isAdmin) { + return ( +
+
+
+
+ ); + } + + const items = guests ?? []; + + const onSubmit = async (data: InviteFormData) => { + try { + await inviteMutation.mutateAsync({ + name: data.name.trim(), + email: data.email.trim(), + }); + toast.success(t('settings.guestInvited')); + reset({ name: '', email: '' }); + setInviteOpen(false); + } catch (err: unknown) { + const errObj = asError(err); + toast.error(errObj.message || t('common.error')); + } + }; + + const handleRevoke = async () => { + if (!confirmRevoke) return; + try { + await revokeMutation.mutateAsync(confirmRevoke.id); + toast.success(t('settings.guestRevoked')); + setConfirmRevoke(null); + } catch (err: unknown) { + const errObj = asError(err); + toast.error(errObj.message || t('common.error')); + } + }; + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'invalidEmail') return t('validation.email'); + return key; + }; + + return ( +
+
+

{t('settings.guests')}

+ +
+

{t('settings.guestsHint')}

+ + {isLoading ? ( +
+ + + +
+ ) : items.length === 0 ? ( + setInviteOpen(true)}>{t('settings.inviteGuest')}} + /> + ) : ( +
+ {items.map((guest) => ( + +
+
+ +
+

{guest.name}

+

{guest.email}

+
+ + {t(`settings.guestStatus_${statusLabel(guest.status)}`)} + +
+
+ {guest.status !== 'disabled' && ( + + )} +
+
+
+ ))} +
+ )} + + setInviteOpen(false)} title={t('settings.inviteGuest')}> +
+ + +

{t('settings.guestPasswordHint')}

+
+ + +
+
+
+ + setConfirmRevoke(null)} + /> +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index dc57b5e..495d822 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -50,6 +50,7 @@ const ApiTokensPage = React.lazy(() => import('@/pages/ApiTokens').then(m => ({ const TenantsPage = React.lazy(() => import('@/pages/Tenants').then(m => ({ default: m.TenantsPage }))); const PermissionTemplatesPage = React.lazy(() => import('@/pages/PermissionTemplates').then(m => ({ default: m.PermissionTemplatesPage }))); const SettingsPoliciesPage = React.lazy(() => import('@/pages/SettingsPolicies').then(m => ({ default: m.SettingsPoliciesPage }))); +const SettingsGuestsPage = React.lazy(() => import('@/pages/SettingsGuests').then(m => ({ default: m.SettingsGuestsPage }))); const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage }))); const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage }))); const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage }))); @@ -216,6 +217,7 @@ const router = createBrowserRouter([ { path: 'tenants', element: withSuspense() }, { path: 'permission-templates', element: withSuspense() }, { path: 'policies', element: withSuspense() }, + { path: 'guests', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, // Phase Q2: plugin settings sub-pages render with bare sub-segments { path: '*', element: {} },