diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index 94430be..da8de31 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -59,6 +59,7 @@ class AIAssistantPlugin(BasePlugin): ], settings_pages=[ FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60), + FrontendSettingsPage(path='external-agents', label_key='settings.externalAgents', label='External Agents API', component='@/pages/SettingsExternalAgents', icon='Bot', order=61, permission='ai:read'), ], author="LeoCRM Team", min_app_version="1.0.0", diff --git a/frontend/src/__tests__/pages/SettingsExternalAgents.test.tsx b/frontend/src/__tests__/pages/SettingsExternalAgents.test.tsx new file mode 100644 index 0000000..5e100f8 --- /dev/null +++ b/frontend/src/__tests__/pages/SettingsExternalAgents.test.tsx @@ -0,0 +1,102 @@ +/** + * External Agents settings page tests — integration guide UI + * (UI-Backlog module 15/16). + * + * Covers: page render, agent cards with curl snippets, copy buttons, + * empty state, auth hint with token link. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsExternalAgentsPage } from '@/pages/SettingsExternalAgents'; + +type AgentLike = { + id: string; + name: string; + description: string; + is_active: boolean; +}; + +const makeAgent = (overrides: Partial = {}): AgentLike => ({ + id: '11111111-1111-1111-1111-111111111111', + name: 'Helpdesk Agent', + description: 'Answers support questions', + is_active: true, + ...overrides, +}); + +let mockAgents: AgentLike[] = []; +let mockLoading = false; + +vi.mock('@/api/externalAgent', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAiAgents: () => ({ data: mockAgents, isLoading: mockLoading, isError: false }), + }; +}); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockAgents = []; + mockLoading = false; +}); + +describe('SettingsExternalAgentsPage', () => { + it('renders the page with auth hint and token link', () => { + renderPage(); + expect(screen.getByTestId('settings-external-agents-page')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /API-Tokens verwalten|Manage API tokens/i })).toBeInTheDocument(); + }); + + it('renders empty state when no agents exist', () => { + renderPage(); + expect(screen.getByText(/Keine KI-Agenten|No AI agents/i)).toBeInTheDocument(); + }); + + it('renders agent cards with three curl snippets each', () => { + mockAgents = [makeAgent()]; + renderPage(); + expect(screen.getByTestId('external-agent-card-11111111-1111-1111-1111-111111111111')).toBeInTheDocument(); + expect(screen.getByTestId('curl-snippet-11111111-1111-1111-1111-111111111111-runAgent')).toBeInTheDocument(); + expect(screen.getByTestId('curl-snippet-11111111-1111-1111-1111-111111111111-getStatus')).toBeInTheDocument(); + expect(screen.getByTestId('curl-snippet-11111111-1111-1111-1111-111111111111-streamAgent (SSE)')).toBeInTheDocument(); + }); + + it('curl snippets contain endpoint URLs and bearer placeholder', () => { + mockAgents = [makeAgent()]; + renderPage(); + const runSnippet = screen.getByTestId('curl-snippet-11111111-1111-1111-1111-111111111111-runAgent'); + expect(runSnippet.textContent).toContain('/api/v1/external/agent/11111111-1111-1111-1111-111111111111/run'); + expect(runSnippet.textContent).toContain('Bearer '); + const statusSnippet = screen.getByTestId('curl-snippet-11111111-1111-1111-1111-111111111111-getStatus'); + expect(statusSnippet.textContent).toContain('/status'); + }); + + it('renders copy buttons per snippet', () => { + mockAgents = [makeAgent()]; + renderPage(); + expect(screen.getByTestId('curl-copy-11111111-1111-1111-1111-111111111111-runAgent')).toBeInTheDocument(); + expect(screen.getByTestId('curl-copy-11111111-1111-1111-1111-111111111111-getStatus')).toBeInTheDocument(); + }); + + it('renders multiple agents', () => { + mockAgents = [ + makeAgent(), + makeAgent({ id: '22222222-2222-2222-2222-222222222222', name: 'Second Agent', is_active: false }), + ]; + renderPage(); + expect(screen.getByTestId('external-agent-card-11111111-1111-1111-1111-111111111111')).toBeInTheDocument(); + expect(screen.getByTestId('external-agent-card-22222222-2222-2222-2222-222222222222')).toBeInTheDocument(); + expect(screen.getByText('Second Agent')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/SettingsOwnership.test.tsx b/frontend/src/__tests__/pages/SettingsOwnership.test.tsx new file mode 100644 index 0000000..5af4f7e --- /dev/null +++ b/frontend/src/__tests__/pages/SettingsOwnership.test.tsx @@ -0,0 +1,146 @@ +/** + * Ownership transfer settings page tests — admin bulk ownership transfer + * (UI-Backlog module 16/16). + * + * Covers: admin gate, user selects, entity type toggles, submit gating, + * transfer flow with result display. + */ +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 { SettingsOwnershipPage } from '@/pages/SettingsOwnership'; + +const transferMut = vi.fn().mockResolvedValue({ + message: 'Ownership transfer completed', + results: { contacts: 5, workflows: 0 }, +}); + +const makeUser = (id: string, name: string, email: string) => ({ + id, + email, + name, + first_name: null, + last_name: null, + avatar_url: null, + role: 'admin', + role_id: null, + is_active: true, + tenant_id: 't-1', +}); + +let mockIsAdmin = true; +let mockUsers: unknown[] = []; +let mockLoading = false; + +vi.mock('@/api/ownership', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTransferOwnership: () => ({ mutate: transferMut, mutateAsync: transferMut, isPending: false }), + }; +}); + +vi.mock('@/api/users', () => ({ + useUsers: () => ({ data: { items: mockUsers, total: mockUsers.length }, isLoading: mockLoading }), +})); + +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(); + mockIsAdmin = true; + mockLoading = false; + mockUsers = [ + makeUser('11111111-1111-1111-1111-111111111111', 'Alte Mitarbeiterin', 'old@example.de'), + makeUser('22222222-2222-2222-2222-222222222222', 'Nachfolger', 'new@example.de'), + ]; + transferMut.mockResolvedValue({ + message: 'Ownership transfer completed', + results: { contacts: 5, workflows: 0 }, + }); +}); + +describe('SettingsOwnershipPage', () => { + it('renders admin-only notice for non-admin users', () => { + mockIsAdmin = false; + renderPage(); + expect(screen.getByTestId('ownership-admin-only')).toBeInTheDocument(); + expect(screen.queryByTestId('ownership-form')).not.toBeInTheDocument(); + }); + + it('renders the form with two user selects and 10 entity type buttons', () => { + renderPage(); + expect(screen.getByTestId('ownership-form')).toBeInTheDocument(); + expect(screen.getByTestId('ownership-from')).toBeInTheDocument(); + expect(screen.getByTestId('ownership-to')).toBeInTheDocument(); + expect(screen.getByTestId('ownership-type-contacts')).toBeInTheDocument(); + expect(screen.getByTestId('ownership-type-notifications')).toBeInTheDocument(); + }); + + it('submit is disabled without both users selected', () => { + renderPage(); + const btn = screen.getByTestId('ownership-submit-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('submit is disabled when from and to are the same user', () => { + renderPage(); + const from = screen.getByTestId('ownership-from') as HTMLSelectElement; + fireEvent.change(from, { target: { value: '11111111-1111-1111-1111-111111111111' } }); + const to = screen.getByTestId('ownership-to') as HTMLSelectElement; + fireEvent.change(to, { target: { value: '11111111-1111-1111-1111-111111111111' } }); + const btn = screen.getByTestId('ownership-submit-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('toggles entity types on click', () => { + renderPage(); + const chip = screen.getByTestId('ownership-type-contacts'); + expect(chip.getAttribute('aria-pressed')).toBe('false'); + fireEvent.click(chip); + expect(chip.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(chip); + expect(chip.getAttribute('aria-pressed')).toBe('false'); + }); + + it('transfers ownership after confirmation and shows the result', async () => { + renderPage(); + const from = screen.getByTestId('ownership-from') as HTMLSelectElement; + fireEvent.change(from, { target: { value: '11111111-1111-1111-1111-111111111111' } }); + const to = screen.getByTestId('ownership-to') as HTMLSelectElement; + fireEvent.change(to, { target: { value: '22222222-2222-2222-2222-222222222222' } }); + fireEvent.click(screen.getByTestId('ownership-type-contacts')); + + const btn = screen.getByTestId('ownership-submit-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(false); + fireEvent.click(btn); + + const confirmBtn = await screen.findByRole('button', { name: 'Bestätigen' }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(transferMut).toHaveBeenCalledTimes(1); + }); + expect(transferMut).toHaveBeenCalledWith({ + from_user_id: '11111111-1111-1111-1111-111111111111', + to_user_id: '22222222-2222-2222-2222-222222222222', + entity_types: ['contacts'], + }); + + await waitFor(() => { + expect(screen.getByTestId('ownership-result')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/api/externalAgent.ts b/frontend/src/api/externalAgent.ts new file mode 100644 index 0000000..29fe5b4 --- /dev/null +++ b/frontend/src/api/externalAgent.ts @@ -0,0 +1,74 @@ +/** + * External Agent API integration client (UI-Backlog module 15/16). + * + * Backend: /api/v1/external/agent (ai_assistant plugin, external_api.py). + * - POST /{agent_id}/run → run agent, return full response (Bearer auth) + * - GET /{agent_id}/status → agent config + run stats (Bearer auth) + * - POST /{agent_id}/stream → SSE stream response (Bearer auth) + * + * These endpoints are Bearer-token-only (no session cookies) — they are + * meant for EXTERNAL systems, not the SPA itself. The UI is therefore an + * integration page: it lists the tenant's AI agents (via the existing + * /ai/agents endpoint) and renders copy-paste curl snippets per agent. + */ + +import { useQuery } from '@tanstack/react-query'; +import { fetchAgents, type AIAgent } from '@/api/ai'; + +/** TanStack wrapper around the existing fetchAgents (session auth). */ +export function useAiAgents() { + return useQuery({ + queryKey: ['ai-agents'], + queryFn: () => fetchAgents(), + }); +} + +export interface CurlSnippet { + method: 'POST' | 'GET'; + endpoint: string; + body?: string; + description: string; +} + +/** Base URL of this deployment (works for prod and local dev). */ +export function externalBaseUrl(): string { + return `${window.location.origin}/api/v1/external/agent`; +} + +/** Build the copy-paste curl examples for one agent. */ +export function buildCurlSnippets(agentId: string): CurlSnippet[] { + const base = externalBaseUrl(); + return [ + { + method: 'POST', + endpoint: `${base}/${agentId}/run`, + body: JSON.stringify({ message: 'Hello, summarize my open tasks' }), + description: 'runAgent', + }, + { + method: 'GET', + endpoint: `${base}/${agentId}/status`, + description: 'getStatus', + }, + { + method: 'POST', + endpoint: `${base}/${agentId}/stream`, + body: JSON.stringify({ message: 'Hello, stream me an answer' }), + description: 'streamAgent (SSE)', + }, + ]; +} + +/** Render a single-line curl command for a snippet. */ +export function curlCommand(snippet: CurlSnippet): string { + const parts: string[] = []; + parts.push(`curl -X ${snippet.method} '${snippet.endpoint}'`); + parts.push(`-H 'Authorization: Bearer '`); + if (snippet.body) { + parts.push(`-H 'Content-Type: application/json'`); + parts.push(`-d '${snippet.body}'`); + } + return parts.join(' '); +} + +export type { AIAgent }; diff --git a/frontend/src/api/ownership.ts b/frontend/src/api/ownership.ts new file mode 100644 index 0000000..f2cd3d1 --- /dev/null +++ b/frontend/src/api/ownership.ts @@ -0,0 +1,46 @@ +/** + * Ownership transfer API client (UI-Backlog module 16/16). + * + * Backend: /api/v1/ownership (app/routes/owner_transfer.py, require_admin). + * - POST /transfer → bulk-transfer records from one user to another + * + * Entity types: contacts, addresses, attachments, bank_accounts, + * workflows, sequences, saved_filters, saved_views, webhooks, + * notifications (mirrored from owner_transfer_service.ENTITY_TABLES). + */ + +import { useMutation } from '@tanstack/react-query'; +import { apiPost } from '@/api/client'; + +export const OWNERSHIP_ENTITY_TYPES = [ + 'contacts', + 'addresses', + 'attachments', + 'bank_accounts', + 'workflows', + 'sequences', + 'saved_filters', + 'saved_views', + 'webhooks', + 'notifications', +] as const; + +export type OwnershipEntityType = (typeof OWNERSHIP_ENTITY_TYPES)[number]; + +export interface OwnershipTransferPayload { + from_user_id: string; + to_user_id: string; + entity_types?: string[] | null; +} + +export interface OwnershipTransferResult { + message: string; + results: Record; +} + +export function useTransferOwnership() { + return useMutation({ + mutationFn: (data: OwnershipTransferPayload) => + apiPost('/ownership/transfer', data), + }); +} diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index ba3366e..222bf69 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -51,6 +51,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/pages/Marketplace': () => import('@/pages/Marketplace').then(normalizeModule), '@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })), '@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })), + '@/pages/SettingsExternalAgents': () => import('@/pages/SettingsExternalAgents').then((m) => ({ default: m.SettingsExternalAgentsPage })), '@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })), '@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })), '@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 21af10c..8969fb7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -403,7 +403,35 @@ "guestStatus_active": "Aktiv", "guestStatus_invited": "Eingeladen", "guestStatus_disabled": "Widerrufen", - "guestStatus_unknown": "Unbekannt" + "guestStatus_unknown": "Unbekannt", + "externalAgents": "Externe Agenten-API", + "externalAgentsHint": "Integrationsschnittstelle für externe Systeme (n8n, Skripte, Apps): KI-Agenten per Bearer-Token ausführen, Status abrufen oder Antworten streamen.", + "externalAgentsAuthHint": "Authentifizierung ausschließlich per Bearer-API-Token (keine Session-Cookies). Rate-Limit: 10 Anfragen/Minute pro Token.", + "externalAgentsTokenLink": "API-Tokens verwalten →", + "noExternalAgents": "Keine KI-Agenten vorhanden", + "ownership": "Besitzübertragung", + "ownershipHint": "Überträgt alle Datensätze eines Benutzers auf einen anderen — z.B. wenn ein Mitarbeiter das Unternehmen verlässt. Admin-only, wird im Audit-Log protokolliert.", + "ownershipAdminOnly": "Nur Administratoren können Besitz übertragen.", + "ownershipFrom": "Bisheriger Besitzer", + "ownershipTo": "Neuer Besitzer", + "ownershipEntityTypes": "Datentypen", + "ownershipAllHint": "keine Auswahl = alle Typen", + "ownershipSelectedTypes": "{{count}} Typen ausgewählt", + "ownershipAllTypes": "alle Typen", + "ownershipTransfer": "Übertragen", + "ownershipConfirm": "Besitz wirklich übertragen", + "ownershipTransferred": "Besitzübertragung abgeschlossen", + "ownershipResult": "Ergebnis", + "ownershipType_contacts": "Kontakte", + "ownershipType_addresses": "Adressen", + "ownershipType_attachments": "Dateianhänge", + "ownershipType_bank_accounts": "Bankkonten", + "ownershipType_workflows": "Workflows", + "ownershipType_sequences": "Nummernkreise", + "ownershipType_saved_filters": "Gespeicherte Filter", + "ownershipType_saved_views": "Gespeicherte Ansichten", + "ownershipType_webhooks": "Webhooks", + "ownershipType_notifications": "Benachrichtigungen" }, "auditLog": { "title": "Audit-Log", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 74cb28a..e9e2f76 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -403,7 +403,35 @@ "guestStatus_active": "Active", "guestStatus_invited": "Invited", "guestStatus_disabled": "Revoked", - "guestStatus_unknown": "Unknown" + "guestStatus_unknown": "Unknown", + "externalAgents": "External Agents API", + "externalAgentsHint": "Integration interface for external systems (n8n, scripts, apps): run AI agents via Bearer token, fetch status, or stream responses.", + "externalAgentsAuthHint": "Authentication via Bearer API token only (no session cookies). Rate limit: 10 requests/minute per token.", + "externalAgentsTokenLink": "Manage API tokens →", + "noExternalAgents": "No AI agents configured", + "ownership": "Ownership Transfer", + "ownershipHint": "Transfers all records from one user to another — e.g. when an employee leaves the company. Admin-only, recorded in the audit log.", + "ownershipAdminOnly": "Only administrators can transfer ownership.", + "ownershipFrom": "Current owner", + "ownershipTo": "New owner", + "ownershipEntityTypes": "Entity types", + "ownershipAllHint": "no selection = all types", + "ownershipSelectedTypes": "{{count}} types selected", + "ownershipAllTypes": "all types", + "ownershipTransfer": "Transfer", + "ownershipConfirm": "Really transfer ownership", + "ownershipTransferred": "Ownership transfer completed", + "ownershipResult": "Result", + "ownershipType_contacts": "Contacts", + "ownershipType_addresses": "Addresses", + "ownershipType_attachments": "Attachments", + "ownershipType_bank_accounts": "Bank accounts", + "ownershipType_workflows": "Workflows", + "ownershipType_sequences": "Sequences", + "ownershipType_saved_filters": "Saved filters", + "ownershipType_saved_views": "Saved views", + "ownershipType_webhooks": "Webhooks", + "ownershipType_notifications": "Notifications" }, "auditLog": { "title": "Audit Log", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ca44e53..6f712b8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -53,6 +53,7 @@ export function SettingsPage() { { 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' }, + { to: '/settings/ownership', label: 'Besitzübertragung', icon: '\ud83d\udce4' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/pages/SettingsExternalAgents.tsx b/frontend/src/pages/SettingsExternalAgents.tsx new file mode 100644 index 0000000..13397e8 --- /dev/null +++ b/frontend/src/pages/SettingsExternalAgents.tsx @@ -0,0 +1,150 @@ +/** + * External Agents API settings page — integration guide for external + * systems (UI-Backlog module 15/16). + * + * Backend: /api/v1/external/agent (ai_assistant plugin, external_api.py). + * - POST /{agent_id}/run → run agent (Bearer auth, 10 req/min) + * - GET /{agent_id}/status → agent status + run stats + * - POST /{agent_id}/stream → SSE stream + * + * These endpoints are Bearer-token-only (no session cookies) — meant for + * external systems (n8n, scripts, other apps). This page lists the + * tenant's AI agents and renders copy-paste curl snippets per agent. + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { Bot, Copy, Check, Inbox } from 'lucide-react'; +import { + useAiAgents, + buildCurlSnippets, + curlCommand, + type AIAgent, +} from '@/api/externalAgent'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Skeleton } from '@/components/ui/Skeleton'; + +function CopyButton({ text, testId }: { text: string; testId: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard API unavailable — user can select manually + } + }; + return ( + + ); +} + +function AgentCard({ agent }: { agent: AIAgent }) { + const { t } = useTranslation(); + const snippets = buildCurlSnippets(agent.id); + + return ( + +
+
+ {agent.description && ( +

+ {agent.description} +

+ )} +
+ {snippets.map((snippet) => ( +
+
+ + {snippet.description} + + +
+
+              {curlCommand(snippet)}
+            
+
+ ))} +
+
+ ); +} + +export function SettingsExternalAgentsPage() { + const { t } = useTranslation(); + const { data: agents, isLoading } = useAiAgents(); + + const items = agents ?? []; + + return ( +
+
+

+ {t('settings.externalAgents')} +

+
+

{t('settings.externalAgentsHint')}

+
+

+ {t('settings.externalAgentsAuthHint')}{' '} + + {t('settings.externalAgentsTokenLink')} + +

+
+ + {isLoading ? ( +
+ + +
+ ) : items.length === 0 ? ( +
+ ); +} diff --git a/frontend/src/pages/SettingsOwnership.tsx b/frontend/src/pages/SettingsOwnership.tsx new file mode 100644 index 0000000..08e1d1e --- /dev/null +++ b/frontend/src/pages/SettingsOwnership.tsx @@ -0,0 +1,208 @@ +/** + * Ownership transfer settings page — admin bulk ownership transfer + * (UI-Backlog module 16/16). + * + * Backend: POST /api/v1/ownership/transfer (require_admin). + * Transfers all records (owner_id) from one user to another, optionally + * scoped to selected entity types (10 known types). Use case: employee + * leaves the company — their records move to the successor. + */ + +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Lock } from 'lucide-react'; +import { asError } from '@/utils/errorTypes'; +import { + useTransferOwnership, + OWNERSHIP_ENTITY_TYPES, + type OwnershipTransferResult, +} from '@/api/ownership'; +import { useUsers, type UserResponse } from '@/api/users'; +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 { Select } from '@/components/ui/Select'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { Skeleton } from '@/components/ui/Skeleton'; + +export function SettingsOwnershipPage() { + const { t } = useTranslation(); + const toast = useToast(); + const user = useAuthStore((state) => state.user); + const isAdmin = user?.is_system_admin === true; + + const { data: usersData, isLoading } = useUsers(1, 100); + const transferMutation = useTransferOwnership(); + + const [fromId, setFromId] = useState(''); + const [toId, setToId] = useState(''); + const [selectedTypes, setSelectedTypes] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [result, setResult] = useState(null); + + const users: UserResponse[] = usersData?.items ?? []; + + const userOptions = useMemo( + () => users.map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })), + [users], + ); + + const fromUser = users.find((u) => u.id === fromId); + const toUser = users.find((u) => u.id === toId); + + if (!isAdmin) { + return ( +
+
+
+
+ ); + } + + const toggleType = (entityType: string) => { + setSelectedTypes((prev) => { + const next = new Set(prev); + if (next.has(entityType)) { + next.delete(entityType); + } else { + next.add(entityType); + } + return next; + }); + }; + + const handleTransfer = async () => { + try { + const payload = { + from_user_id: fromId, + to_user_id: toId, + entity_types: selectedTypes.size > 0 ? Array.from(selectedTypes) : null, + }; + const res = await transferMutation.mutateAsync(payload); + setResult(res); + toast.success(t('settings.ownershipTransferred')); + setConfirmOpen(false); + } catch (err: unknown) { + const errObj = asError(err); + toast.error(errObj.message || t('common.error')); + setConfirmOpen(false); + } + }; + + const canSubmit = fromId && toId && fromId !== toId; + + return ( +
+
+

{t('settings.ownership')}

+
+

{t('settings.ownershipHint')}

+ + {isLoading ? ( +
+ + +
+ ) : ( + +
+
+ setToId(e.target.value)} + data-testid="ownership-to" + /> +
+ +
+

+ {t('settings.ownershipEntityTypes')}{' '} + + ({t('settings.ownershipAllHint')}) + +

+
+ {OWNERSHIP_ENTITY_TYPES.map((et) => ( + + ))} +
+
+ +
+ +
+
+
+ )} + + {result && ( + +

+ {t('settings.ownershipResult')} +

+
+ {Object.entries(result.results).map(([entityType, count]) => ( +
+ + {t(`settings.ownershipType_${entityType}`, entityType)} + + 0 ? 'success' : 'secondary'}>{count} +
+ ))} +
+
+ )} + + 0 + ? t('settings.ownershipSelectedTypes', { count: selectedTypes.size }) + : t('settings.ownershipAllTypes') + })` + : '' + } + variant="danger" + onConfirm={handleTransfer} + onCancel={() => setConfirmOpen(false)} + /> +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 495d822..618921e 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -51,6 +51,7 @@ const TenantsPage = React.lazy(() => import('@/pages/Tenants').then(m => ({ defa 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 SettingsOwnershipPage = React.lazy(() => import('@/pages/SettingsOwnership').then(m => ({ default: m.SettingsOwnershipPage }))); 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 }))); @@ -218,6 +219,7 @@ const router = createBrowserRouter([ { path: 'permission-templates', element: withSuspense() }, { path: 'policies', element: withSuspense() }, { path: 'guests', element: withSuspense() }, + { path: 'ownership', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, // Phase Q2: plugin settings sub-pages render with bare sub-segments { path: '*', element: {} },