diff --git a/frontend/src/__tests__/pages/Tenants.test.tsx b/frontend/src/__tests__/pages/Tenants.test.tsx new file mode 100644 index 0000000..113aafe --- /dev/null +++ b/frontend/src/__tests__/pages/Tenants.test.tsx @@ -0,0 +1,194 @@ +/** + * Tenants page tests — multi-tenant management (module 4/16). + * + * Covers: rendering, tenant cards with default badge, expandable user list, + * create dialog, assign user flow, permission gating (tenants:write), + * empty and error states. + */ +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 { TenantsPage } from '@/pages/Tenants'; +import type { Tenant } from '@/api/tenants'; +import type { UserResponse } from '@/api/users'; + +const createMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({}); + return Promise.resolve({}); +}); +const assignMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({ message: 'ok' }); + return Promise.resolve({}); +}); + +const makeTenant = (overrides: Partial = {}): Tenant => ({ + id: 'tn-1', + name: 'Default Org', + slug: 'default-org', + is_default: true, + ...overrides, +}); + +const TENANT_USER = { + id: '11111111-1111-1111-1111-111111111111', + email: 'admin@example.com', + name: 'Administrator', + role: 'admin', + is_active: true, +}; + +const ALL_USER: UserResponse = { + id: '22222222-2222-2222-2222-222222222222', + email: 'other@example.com', + name: 'Other User', + first_name: null, + last_name: null, + avatar_url: null, + role: 'viewer', + role_id: null, + is_active: true, + tenant_id: 'tn-1', +}; + +let mockTenants: Tenant[] = []; +let mockTenantUsers: typeof TENANT_USER[] = []; +let mockCanWrite = true; +let mockError = false; + +vi.mock('@/api/tenants', () => ({ + useTenants: () => ({ + data: { items: mockTenants }, + isLoading: false, + isError: mockError, + isFetching: false, + refetch: vi.fn(), + }), + useTenantUsers: (tenantId: string | null) => ({ + data: tenantId ? { items: mockTenantUsers } : undefined, + isLoading: false, + }), + useCreateTenant: () => ({ + mutate: createMut, + isPending: false, + }), + useAssignUserToTenant: () => ({ + mutate: assignMut, + isPending: false, + }), +})); + +vi.mock('@/api/users', () => ({ + useUsers: () => ({ + data: { items: [ALL_USER], total: 1, page: 1, page_size: 100 }, + isLoading: false, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => mockCanWrite || perm !== 'tenants:write', + }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockTenants = []; + mockTenantUsers = []; + mockCanWrite = true; + mockError = false; +}); + +describe('TenantsPage', () => { + it('renders the page with title', () => { + renderPage(); + expect(screen.getByTestId('tenants-page')).toBeInTheDocument(); + }); + + it('shows empty state when no tenants exist', () => { + renderPage(); + expect(screen.getByTestId('tenants-empty')).toBeInTheDocument(); + }); + + it('shows error state on load failure', () => { + mockError = true; + renderPage(); + expect(screen.getByTestId('tenants-error')).toBeInTheDocument(); + }); + + it('renders tenant cards with name, slug and default badge', () => { + mockTenants = [makeTenant()]; + renderPage(); + expect(screen.getByTestId('tenant-card-tn-1')).toBeInTheDocument(); + expect(screen.getByText('Default Org')).toBeInTheDocument(); + expect(screen.getByText('default-org')).toBeInTheDocument(); + // Default badge — 'Standard' appears exactly once (the tenant name + // 'Default Org' would also match a /default/i regex, so match exactly) + expect(screen.getByText(/^standard$/i)).toBeInTheDocument(); + }); + + it('expands the user list via the toggle', async () => { + mockTenants = [makeTenant()]; + mockTenantUsers = [TENANT_USER]; + renderPage(); + fireEvent.click(screen.getByTestId('tenant-users-toggle-tn-1')); + expect(await screen.findByTestId('tenant-users-list-tn-1')).toBeInTheDocument(); + expect(screen.getByTestId(`tenant-user-tn-1-${TENANT_USER.id}`)).toBeInTheDocument(); + expect(screen.getByText('Administrator')).toBeInTheDocument(); + }); + + it('creates a tenant via the dialog', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('tenant-create-open')); + expect(screen.getByTestId('tenant-create-submit')).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'Zweite Firma' } }); + const slugInput = screen.getByLabelText(/slug/i, { selector: 'input' }) as HTMLInputElement; + fireEvent.change(slugInput, { target: { value: 'zweite-firma' } }); + + fireEvent.click(screen.getByTestId('tenant-create-submit')); + + await waitFor(() => { + expect(createMut).toHaveBeenCalledWith( + { name: 'Zweite Firma', slug: 'zweite-firma' }, + expect.anything(), + ); + }); + }); + + it('hides create and toggles without tenants:write', () => { + mockTenants = [makeTenant()]; + mockCanWrite = false; + renderPage(); + expect(screen.queryByTestId('tenant-create-open')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tenant-users-toggle-tn-1')).not.toBeInTheDocument(); + }); + + it('assigns a user to the expanded tenant', async () => { + mockTenants = [makeTenant()]; + mockTenantUsers = [TENANT_USER]; + renderPage(); + fireEvent.click(screen.getByTestId('tenant-users-toggle-tn-1')); + fireEvent.click(await screen.findByTestId('tenant-assign-open')); + + const select = screen.getByRole('combobox'); + fireEvent.change(select, { target: { value: ALL_USER.id } }); + fireEvent.click(screen.getByTestId('tenant-assign-submit')); + + await waitFor(() => { + expect(assignMut).toHaveBeenCalledWith( + { tenantId: 'tn-1', userId: ALL_USER.id }, + expect.anything(), + ); + }); + }); +}); diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts new file mode 100644 index 0000000..562628b --- /dev/null +++ b/frontend/src/api/tenants.ts @@ -0,0 +1,85 @@ +/** + * Tenants API client — multi-tenant management. + * + * Backend: /api/v1/tenants (list, create, list users, assign user). + * Permissions: tenants:read (list) / tenants:write (create, users, assign). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost } from '@/api/client'; + +export interface Tenant { + id: string; + name: string; + slug: string; + is_default: boolean; +} + +export interface TenantListResponse { + items: Tenant[]; +} + +export interface TenantUser { + id: string; + email: string; + name: string; + role: string; + is_active: boolean; +} + +export interface TenantUsersResponse { + items: TenantUser[]; +} + +export interface TenantCreatePayload { + name: string; + slug: string; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useTenants() { + return useQuery({ + queryKey: ['tenants'], + queryFn: () => apiGet('/tenants'), + }); +} + +export function useTenantUsers(tenantId: string | null) { + return useQuery({ + queryKey: ['tenants', tenantId, 'users'], + queryFn: () => apiGet(`/tenants/${tenantId}/users`), + enabled: !!tenantId, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useCreateTenant() { + const qc = useQueryClient(); + return useMutation< + { id: string; name: string; slug: string }, + Error, + TenantCreatePayload + >({ + mutationFn: (data) => apiPost('/tenants', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenants'] }); + }, + }); +} + +export function useAssignUserToTenant() { + const qc = useQueryClient(); + return useMutation< + { message: string }, + Error, + { tenantId: string; userId: string } + >({ + mutationFn: ({ tenantId, userId }) => + apiPost(`/tenants/${tenantId}/users`, { user_id: userId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tenants'] }); + }, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1f0d070..bf22914 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1684,5 +1684,26 @@ "revokeConfirm": "Token '{{name}}' wirklich widerrufen? Anwendungen damit verlieren sofort den Zugriff.", "empty": "Keine API-Tokens vorhanden.", "loadError": "API-Tokens konnten nicht geladen werden." + }, + "tenants": { + "title": "Mandanten", + "create": "Mandant erstellen", + "createTitle": "Neuen Mandanten erstellen", + "createSubmit": "Erstellen", + "name": "Name", + "namePlaceholder": "z.B. Zweite Firma GmbH", + "slug": "Slug", + "slugPlaceholder": "z.B. zweite-firma", + "slugHelper": "Kleingeschrieben, Bindestriche statt Leerzeichen.", + "users": "Benutzer", + "loadingUsers": "Lade Benutzer...", + "assign": "Benutzer zuweisen", + "assignTitle": "Benutzer zu {{name}} zuweisen", + "assignSubmit": "Zuweisen", + "user": "Benutzer", + "userPlaceholder": "Benutzer auswaehlen", + "defaultBadge": "Standard", + "empty": "Keine Mandanten vorhanden.", + "loadError": "Mandanten konnten nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f57aa9c..4d84a68 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1684,5 +1684,26 @@ "revokeConfirm": "Really revoke token '{{name}}'? Applications using it lose access immediately.", "empty": "No API tokens yet.", "loadError": "Failed to load API tokens." + }, + "tenants": { + "title": "Tenants", + "create": "Create tenant", + "createTitle": "Create a new tenant", + "createSubmit": "Create", + "name": "Name", + "namePlaceholder": "e.g. Second Company Ltd", + "slug": "Slug", + "slugPlaceholder": "e.g. second-company", + "slugHelper": "Lowercase, hyphens instead of spaces.", + "users": "Users", + "loadingUsers": "Loading users...", + "assign": "Assign user", + "assignTitle": "Assign user to {{name}}", + "assignSubmit": "Assign", + "user": "User", + "userPlaceholder": "Select a user", + "defaultBadge": "Default", + "empty": "No tenants yet.", + "loadError": "Failed to load tenants." } } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 40ec6f6..dd0b39f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -49,6 +49,7 @@ export function SettingsPage() { { to: '/settings/workspaces', label: 'Workspaces', icon: '\ud83d\udd58\ufe0f' }, { to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' }, { to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' }, + { to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/pages/Tenants.tsx b/frontend/src/pages/Tenants.tsx new file mode 100644 index 0000000..52cd8dc --- /dev/null +++ b/frontend/src/pages/Tenants.tsx @@ -0,0 +1,331 @@ +/** + * Tenants settings page — multi-tenant management (UI-Backlog module 4/16). + * + * Backend: /api/v1/tenants (list, create, list users, assign user). + * Permissions: tenants:read (list) / tenants:write (create, users, assign). + */ + +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Building2, + Plus, + ChevronDown, + ChevronRight, + UserPlus, + AlertTriangle, + Inbox, +} from 'lucide-react'; +import { + useTenants, + useTenantUsers, + useCreateTenant, + useAssignUserToTenant, + type Tenant, +} from '@/api/tenants'; +import { useUsers } from '@/api/users'; +import { usePermission } from '@/hooks/usePermission'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { Select } from '@/components/ui/Select'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; + +function TenantCard({ + tenant, + canWrite, + isExpanded, + onToggle, +}: { + tenant: Tenant; + canWrite: boolean; + isExpanded: boolean; + onToggle: (tenant: Tenant) => void; +}) { + const { t } = useTranslation(); + const { data: usersData, isLoading: usersLoading } = useTenantUsers( + canWrite && isExpanded ? tenant.id : null, + ); + + return ( + +
+
+
+ {canWrite && ( + + )} +
+ + {canWrite && isExpanded && ( +
+ {usersLoading && ( +
+ {t('tenants.loadingUsers')} +
+ )} +
    + {(usersData?.items ?? []).map((u) => ( +
  • + + {u.name} ({u.email}) + + + {u.role} + +
  • + ))} +
+
+ )} +
+ ); +} + +function CreateTenantDialog({ + open, + onClose, + onSubmit, + isSubmitting, +}: { + open: boolean; + onClose: () => void; + onSubmit: (payload: { name: string; slug: string }) => void; + isSubmitting: boolean; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + + const valid = name.trim().length > 0 && slug.trim().length > 0; + + const submit = () => { + if (!valid) return; + onSubmit({ name: name.trim(), slug: slug.trim() }); + }; + + return ( + +
+ setName(e.target.value)} + required + placeholder={t('tenants.namePlaceholder')} + /> + setSlug(e.target.value.replace(/\s+/g, '-').toLowerCase())} + required + placeholder={t('tenants.slugPlaceholder')} + helperText={t('tenants.slugHelper')} + /> +
+ + +
+
+
+ ); +} + +function AssignUserDialog({ + tenant, + open, + onClose, + onSubmit, + isSubmitting, + existingUserIds, +}: { + tenant: Tenant | null; + open: boolean; + onClose: () => void; + onSubmit: (userId: string) => void; + isSubmitting: boolean; + existingUserIds: Set; +}) { + const { t } = useTranslation(); + const [userId, setUserId] = useState(''); + const { data: usersData } = useUsers(1, 100); + + const userOptions = useMemo( + () => + (usersData?.items ?? []) + .filter((u) => !existingUserIds.has(u.id)) + .map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })), + [usersData, existingUserIds], + ); + + return ( + +
+