Restructures frontend to a feature-based folder layout
Continuous Integration / config (pull_request) Successful in 11s
Continuous Integration / changes (pull_request) Successful in 21s
Continuous Integration / backend-build (pull_request) Skipped
Continuous Integration / backend-test (pull_request) Skipped
Continuous Integration / vulnerability-scan (pull_request) Skipped
Continuous Integration / frontend-prepare (pull_request) Successful in 1m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m12s
Continuous Integration / frontend-test (pull_request) Successful in 4m42s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m51s
Continuous Integration / deploy-test (pull_request) Skipped

Groups auth, setup, invitation, profile, users, cms, availability and
system code (services/hooks, components, schemas, mocks, pages) under
src/features/<name> instead of splitting by technical layer (api/,
components/, lib/schemas/, mocks/, pages/). Renames the old
connection-oriented `api` layer to `services` per feature, and splits
the monolithic api/types.ts into per-feature types.ts files (with
ProblemDetails/ApiResult merged into lib/api-client.ts as shared
infra). Layout-agnostic code (ui primitives, app shell, i18n, test
utils, lib) stays at the top level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:30:37 +02:00
co-authored by Claude Sonnet 5
parent 650cf09cfa
commit 11ec08aac3
83 changed files with 249 additions and 254 deletions
@@ -1,44 +0,0 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
interface ModuleGuardProps {
requiredModule: string;
children: ReactNode;
}
/**
* Hides a feature that only exists on backend instances with a given module loaded
* (e.g. the CMS-instance management page requires Modules.Master — a local slave
* instance without it should not expose this page even to an Owner).
*/
export function ModuleGuard({ requiredModule, children }: ModuleGuardProps) {
const { t } = useTranslation();
const { data: capabilities, isPending } = useSystemCapabilities();
if (isPending) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (capabilities?.modules.includes(requiredModule)) {
return <>{children}</>;
}
return (
<div
data-testid="feature-unavailable-message"
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<h1 className="text-2xl font-semibold">{t('errors.featureUnavailableTitle')}</h1>
<p className="max-w-sm text-sm text-muted-foreground">{t('errors.featureUnavailable')}</p>
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
{t('nav.dashboard')}
</Link>
</div>
);
}
@@ -1,38 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { renderApp } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('RoleGuard', () => {
it('renders protected content when user has an allowed role (Owner on /users)', async () => {
// Default auth mock returns Owner role, /users requires Owner or Administrator
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json(makeAuthResponse()),
),
);
renderApp('/users');
expect(await screen.findByTestId('users-title')).toBeInTheDocument();
});
it('shows access denied message when user lacks the required role', async () => {
// Authenticate with 'User' role — /users requires Owner or Administrator
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json(makeAuthResponse({ user: { ...mockUser, role: 'User' } })),
),
);
renderApp('/users');
expect(await screen.findByTestId('access-denied-message')).toBeInTheDocument();
expect(screen.queryByTestId('users-title')).not.toBeInTheDocument();
});
});
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import type { UserRole } from '@/api/types';
import { useAuth } from '@/contexts/auth-context';
interface RoleGuardProps {
allowedRoles: UserRole[];
children: ReactNode;
}
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const { t } = useTranslation();
const { user } = useAuth();
if (user !== null && allowedRoles.includes(user.role)) {
return <>{children}</>;
}
const requiredLabel = allowedRoles.join(' or ');
return (
<div
data-testid="access-denied-message"
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<h1 className="text-2xl font-semibold">{t('errors.accessDenied')}</h1>
<p className="max-w-sm text-sm text-muted-foreground">
{t('errors.accessDeniedDetail', { roles: requiredLabel })}
</p>
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
{t('nav.dashboard')}
</Link>
</div>
);
}
@@ -1,71 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockCmsInstances();
});
async function openDialog() {
mockAuthenticated();
renderApp('/cms');
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('cms-add-button'));
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
}
describe('AddCmsInstanceDialog', () => {
it('shows name, url and apiKey fields on open', async () => {
await openDialog();
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-url')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-apikey-input')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-submit')).toBeInTheDocument();
});
it('shows field error when name is empty', async () => {
await openDialog();
await userEvent.click(screen.getByTestId('add-cms-url'));
await userEvent.click(screen.getByTestId('add-cms-name'));
await userEvent.tab();
expect(await screen.findByTestId('add-cms-name-error')).toBeInTheDocument();
});
it('shows field error when URL has no protocol', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-url'), 'cms.example.com');
await userEvent.tab();
expect(await screen.findByTestId('add-cms-url-error')).toBeInTheDocument();
});
it('shows FormErrorBanner on HTTP 400', async () => {
server.use(
http.post(`${API_BASE}/api/v1/CmsInstances`, () =>
HttpResponse.json({ title: 'Bad Request' }, { status: 400 }),
),
);
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-name'), 'CMS');
await userEvent.type(screen.getByTestId('add-cms-url'), 'http://example.com');
await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'key');
await userEvent.click(screen.getByTestId('add-cms-submit'));
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
});
it('resets form when dialog is closed and reopened', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('add-cms-name'), 'Typed value');
await userEvent.keyboard('{Escape}');
await userEvent.click(screen.getByTestId('cms-add-button'));
expect((screen.getByTestId('add-cms-name') as HTMLInputElement).value).toBe('');
});
});
@@ -1,138 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordField } from '@/components/ui/PasswordField';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useAddCmsInstance } from '@/api/useAddCmsInstance';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/lib/schemas/cms';
interface AddCmsInstanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddCmsInstanceDialog({ open, onOpenChange }: AddCmsInstanceDialogProps) {
const { t } = useTranslation();
const [serverError, setServerError] = useState<string | null>(null);
const addInstance = useAddCmsInstance({
onError: (err) => {
if (err instanceof ProblemDetailsError && err.status === 409) {
setServerError(t('cms.add.errors.conflict'));
} else if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<AddCmsInstanceFormData>({
resolver: zodResolver(addCmsInstanceSchema),
mode: 'onTouched',
});
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setServerError(null);
addInstance.reset();
reset();
}
onOpenChange(nextOpen);
};
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
await addInstance.mutateAsync(values);
toast.success(t('cms.add.successToast'));
onOpenChange(false);
} catch {
// error shown via serverError state
}
});
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('cms.add.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="add-cms-name">{t('cms.add.nameLabel')}</Label>
<Input
id="add-cms-name"
type="text"
data-testid="add-cms-name"
aria-invalid={errors.name !== undefined}
{...register('name')}
/>
{errors.name && (
<FieldError message={errors.name.message} testId="add-cms-name-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-cms-url">{t('cms.add.urlLabel')}</Label>
<Input
id="add-cms-url"
type="text"
placeholder="http://cms.example.com"
data-testid="add-cms-url"
aria-invalid={errors.url !== undefined}
{...register('url')}
/>
{errors.url && (
<FieldError message={errors.url.message} testId="add-cms-url-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-cms-apikey">{t('cms.add.apiKeyLabel')}</Label>
<PasswordField
id="add-cms-apikey"
{...register('apiKey')}
/>
{errors.apiKey && (
<FieldError message={errors.apiKey.message} testId="add-cms-apikey-error" />
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || addInstance.isPending}
data-testid="add-cms-submit"
>
{isSubmitting || addInstance.isPending ? '…' : t('cms.add.submitButton')}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,112 +0,0 @@
import { useTranslation } from 'react-i18next';
import { MoreHorizontal, CheckCircle, XCircle, MinusCircle } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
const STATUS_BADGE_CONFIG: Record<
CmsInstanceStatus,
{ colorClass: string; Icon: React.ComponentType<{ className?: string }> }
> = {
Available: {
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
Icon: CheckCircle,
},
NotAvailable: {
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
Icon: XCircle,
},
Inactive: {
colorClass: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
Icon: MinusCircle,
},
};
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
interface CmsInstanceListProps {
instances: CmsInstance[];
onSetStatus: (instance: CmsInstance) => void;
}
export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps) {
const { t } = useTranslation();
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('cms.table.name')}</TableHead>
<TableHead>{t('cms.table.url')}</TableHead>
<TableHead>{t('cms.table.status')}</TableHead>
<TableHead>{t('cms.table.lastContact')}</TableHead>
<TableHead>{t('cms.table.disableMessage')}</TableHead>
<TableHead>{t('cms.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{instances.map((instance) => {
const { colorClass, Icon } = STATUS_BADGE_CONFIG[instance.status];
return (
<TableRow
key={instance.id}
data-testid="cms-instance-row"
className={instance.status === 'Inactive' ? 'opacity-50' : ''}
>
<TableCell>{instance.name}</TableCell>
<TableCell>{instance.url}</TableCell>
<TableCell>
<div
data-testid="cms-instance-status-badge"
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${colorClass}`}
>
<Icon className="size-4" />
<span>{t(`cms.status.${instance.status}`)}</span>
</div>
</TableCell>
<TableCell>{formatDate(instance.lastContactedAt)}</TableCell>
<TableCell>{instance.disableMessage ?? '—'}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
data-testid="cms-instance-actions-trigger"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="cms-instance-set-status"
onClick={() => onSetStatus(instance)}
>
{t('cms.actions.setStatus')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { resetMockCmsInstances } from '@/mocks/cms/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockCmsInstances();
});
async function openSetStatusDialog() {
mockAuthenticated();
renderApp('/cms');
const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 10000 });
await userEvent.click(triggers[0]);
await userEvent.click(await screen.findByTestId('cms-instance-set-status'));
expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
}
describe('SetStatusDialog', () => {
it('opens with status select and submit button', async () => {
await openSetStatusDialog();
expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
expect(screen.getByTestId('set-status-submit')).toBeInTheDocument();
});
it('does not show DisableMessage field when status is Available', async () => {
await openSetStatusDialog();
expect(screen.queryByTestId('set-status-disable-message')).not.toBeInTheDocument();
});
it('shows DisableMessage field when NotAvailable is selected', async () => {
await openSetStatusDialog();
await userEvent.click(screen.getByTestId('set-status-select'));
await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' }));
expect(await screen.findByTestId('set-status-disable-message')).toBeInTheDocument();
});
it('blocks submit when NotAvailable is selected but DisableMessage is empty', async () => {
await openSetStatusDialog();
await userEvent.click(screen.getByTestId('set-status-select'));
await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' }));
await screen.findByTestId('set-status-disable-message');
await userEvent.click(screen.getByTestId('set-status-submit'));
expect(await screen.findByTestId('set-status-disable-message-error')).toBeInTheDocument();
});
});
@@ -1,187 +0,0 @@
import { useState } from 'react';
import { useForm, useWatch, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useUpdateCmsInstanceStatus } from '@/api/useUpdateCmsInstanceStatus';
import { NetworkError } from '@/lib/api-client';
import { setStatusSchema, type SetStatusFormData } from '@/lib/schemas/cms';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
interface SetStatusDialogProps {
instance: CmsInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const statusOptions: CmsInstanceStatus[] = ['Available', 'NotAvailable', 'Inactive'];
export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialogProps) {
const { t } = useTranslation();
const [serverError, setServerError] = useState<string | null>(null);
const updateStatus = useUpdateCmsInstanceStatus({
onSuccess: (result) => {
if (result.slaveContactSuccess) {
toast.success(t('cms.setStatus.successContactedToast'));
} else {
toast.success(t('cms.setStatus.successUnreachableToast'));
}
onOpenChange(false);
},
onError: (err) => {
if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
const {
register,
handleSubmit,
control,
reset,
formState: { errors, isSubmitting },
} = useForm<SetStatusFormData>({
resolver: zodResolver(setStatusSchema),
mode: 'onTouched',
defaultValues: { status: 'Available', disableMessage: '' },
});
const selectedStatus = useWatch({ control, name: 'status' });
// Adjusts form values when the target instance changes, without an Effect
// (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
const [syncedInstance, setSyncedInstance] = useState(instance);
if (instance !== syncedInstance) {
setSyncedInstance(instance);
if (instance) {
reset({ status: instance.status, disableMessage: instance.disableMessage ?? '' });
} else {
reset({ status: 'Available', disableMessage: '' });
}
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setServerError(null);
updateStatus.reset();
reset({ status: 'Available', disableMessage: '' });
}
onOpenChange(nextOpen);
};
const onSubmit = handleSubmit(async (values) => {
if (!instance) return;
setServerError(null);
try {
await updateStatus.mutateAsync({
id: instance.id,
status: values.status,
disableMessage: values.status === 'NotAvailable' ? (values.disableMessage ?? null) : null,
});
} catch {
// error shown via serverError state
}
});
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('cms.setStatus.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="set-status-status">{t('cms.setStatus.statusLabel')}</Label>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as CmsInstanceStatus)}
>
<SelectTrigger
id="set-status-status"
data-testid="set-status-select"
aria-invalid={errors.status !== undefined}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((s) => (
<SelectItem key={s} value={s}>
{t(`cms.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.status && (
<FieldError message={errors.status.message} testId="set-status-status-error" />
)}
</div>
{selectedStatus === 'NotAvailable' && (
<div className="space-y-2">
<Label htmlFor="set-status-disable-message">
{t('cms.setStatus.disableMessageLabel')}
</Label>
<Input
id="set-status-disable-message"
type="text"
placeholder={t('cms.setStatus.disableMessagePlaceholder')}
data-testid="set-status-disable-message"
aria-invalid={errors.disableMessage !== undefined}
{...register('disableMessage')}
/>
{errors.disableMessage && (
<FieldError
message={errors.disableMessage.message}
testId="set-status-disable-message-error"
/>
)}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || updateStatus.isPending}
data-testid="set-status-submit"
>
{isSubmitting || updateStatus.isPending ? '…' : t('cms.setStatus.submitButton')}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { Sidebar } from '@/components/layout/Sidebar';
import { MobileBar } from '@/components/layout/MobileBar';
import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
@@ -5,7 +5,7 @@ import { server } from '@/mocks/server';
import { setupHandlers } from '@/mocks/index';
import { _resetSetupStatusCache } from '@/router';
import { http, HttpResponse } from 'msw';
import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
import { API_BASE, makeAuthResponse, mockUser } from '@/features/auth/mocks/fixtures';
beforeEach(() => {
_resetSetupStatusCache();
+3 -3
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { useSystemCapabilities } from '@/features/system/services/useSystemCapabilities';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
+1 -1
View File
@@ -11,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
import { useAuth } from '@/features/auth/context/auth-context';
export function UserMenu() {
const { t } = useTranslation();
@@ -1,100 +0,0 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '@/test/utils';
import { AvailabilityStatusBadge } from './AvailabilityStatusBadge';
describe('AvailabilityStatusBadge', () => {
it('renders Available status with green styling', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
const label = screen.getByTestId('availability-status-label');
expect(label).toHaveTextContent('Available');
const badge = screen.getByTestId('availability-badge');
expect(badge.querySelector('.bg-green-100')).toBeTruthy();
});
it('renders Maintenance status with amber styling', () => {
renderWithProviders(<AvailabilityStatusBadge status="Maintenance" />);
const label = screen.getByTestId('availability-status-label');
expect(label).toHaveTextContent('Maintenance');
const badge = screen.getByTestId('availability-badge');
expect(badge.querySelector('.bg-amber-100')).toBeTruthy();
});
it('renders Unavailable status with red styling', () => {
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
const label = screen.getByTestId('availability-status-label');
expect(label).toHaveTextContent('Unavailable');
const badge = screen.getByTestId('availability-badge');
expect(badge.querySelector('.bg-red-100')).toBeTruthy();
});
it('Available always shows the default translated message, ignoring any backend message', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" message="ignored" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is running normally.',
);
});
it('Available shows the default translated message when no message is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is running normally.',
);
});
it('Maintenance shows a custom reason when provided', () => {
renderWithProviders(
<AvailabilityStatusBadge status="Maintenance" message="Scheduled downtime until 18:00" />,
);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'Scheduled downtime until 18:00',
);
});
it('Maintenance falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Maintenance" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is undergoing scheduled maintenance.',
);
});
it('Unavailable shows a custom reason when provided', () => {
renderWithProviders(
<AvailabilityStatusBadge status="NotAvailable" message="Emergency shutdown in progress" />,
);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'Emergency shutdown in progress',
);
});
it('Unavailable falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is currently unavailable.',
);
});
it('shows stale indicator when stale={true}', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" stale={true} />);
expect(screen.getByTestId('availability-stale-indicator')).toBeInTheDocument();
});
it('hides stale indicator when stale is omitted (default false)', () => {
renderWithProviders(<AvailabilityStatusBadge status="Available" />);
expect(screen.queryByTestId('availability-stale-indicator')).toBeNull();
});
});
@@ -1,82 +0,0 @@
import { AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AvailabilityStatus } from '@/api/types';
interface AvailabilityStatusBadgeProps {
status: AvailabilityStatus;
message?: string;
stale?: boolean;
}
const STATUS_CONFIG: Record<
AvailabilityStatus,
{
labelKey: string;
defaultMessageKey: string;
/** When true the default translated message is always shown, ignoring any backend message. */
alwaysDefault: boolean;
colorClass: string;
Icon: React.ComponentType<{ className?: string }>;
}
> = {
Available: {
labelKey: 'availability.available',
defaultMessageKey: 'availability.messageAvailable',
alwaysDefault: true,
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
Icon: CheckCircle,
},
Maintenance: {
labelKey: 'availability.maintenance',
defaultMessageKey: 'availability.messageMaintenance',
alwaysDefault: false,
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
Icon: AlertTriangle,
},
NotAvailable: {
labelKey: 'availability.unavailable',
defaultMessageKey: 'availability.messageUnavailable',
alwaysDefault: false,
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
Icon: XCircle,
},
};
export function AvailabilityStatusBadge({
status,
message,
stale = false,
}: AvailabilityStatusBadgeProps) {
const { t } = useTranslation();
const { labelKey, defaultMessageKey, alwaysDefault, colorClass, Icon } = STATUS_CONFIG[status];
const displayMessage = alwaysDefault ? t(defaultMessageKey) : (message || t(defaultMessageKey));
return (
<div
data-testid="availability-badge"
className={stale ? 'rounded-lg border-2 border-dashed border-amber-400 p-3' : undefined}
>
<div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${colorClass}`}>
<Icon className="size-4" />
<span data-testid="availability-status-label">{t(labelKey)}</span>
</div>
<p
data-testid="availability-message"
className="mt-2 text-sm text-muted-foreground"
>
{displayMessage}
</p>
{stale && (
<div
data-testid="availability-stale-indicator"
className="mt-2 flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
>
<Clock className="size-3" />
<span>{t('availability.staleLabel')}</span>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { UserRole } from '@/api/types';
import type { UserRole } from '@/features/auth/services/types';
function roleBadgeVariant(role: UserRole): 'default' | 'secondary' | 'outline' {
if (role === 'Owner') return 'default';
@@ -1,63 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import type { UserListItem } from '@/api/types';
interface DeleteUserDialogProps {
user: UserListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending: boolean;
}
export function DeleteUserDialog({
user,
open,
onOpenChange,
onConfirm,
isPending,
}: DeleteUserDialogProps) {
const { t } = useTranslation();
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="delete-user-dialog">
<DialogHeader>
<DialogTitle>{t('users.delete.title')}</DialogTitle>
<DialogDescription>
{user.invitationPending
? t('users.delete.descriptionPending', { email: user.email })
: t('users.delete.description', { name: user.name, email: user.email })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
data-testid="delete-user-cancel"
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isPending}
data-testid="delete-user-confirm"
>
{isPending ? '...' : t('users.delete.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,90 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
async function openDialog() {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-invite-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
}
describe('InviteUserDialog', () => {
it('shows email and role fields on open', async () => {
await openDialog();
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-role')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-submit')).toBeInTheDocument();
});
it('shows validation error when submitting with empty email', async () => {
await openDialog();
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-email-error')).toBeInTheDocument();
});
it('shows validation error when submitting without selecting a role', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'new@example.com');
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-role-error')).toBeInTheDocument();
});
it('advances to step 2 (invite link) after successful submission', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'new@example.com');
// Open role dropdown and select "User" (use role=option to avoid ambiguity)
await userEvent.click(screen.getByTestId('invite-dialog-role'));
const options = await screen.findAllByRole('option');
const userOption = options.find((o) => o.textContent === 'User');
await userEvent.click(userOption!);
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-dialog-link-input')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-copy')).toBeInTheDocument();
});
it('shows error banner when API returns 400', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Users/invite`, () =>
HttpResponse.json({ detail: 'User already exists.' }, { status: 400 }),
),
);
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'dup@example.com');
await userEvent.click(screen.getByTestId('invite-dialog-role'));
const opts = await screen.findAllByRole('option');
const userOpt = opts.find((o) => o.textContent === 'User');
await userEvent.click(userOpt!);
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
});
it('resets to step 1 when dialog is closed and reopened', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'test@example.com');
// Close the dialog by pressing Escape
await userEvent.keyboard('{Escape}');
// Reopen
await userEvent.click(screen.getByTestId('users-invite-button'));
const emailInput = screen.getByTestId('invite-dialog-email') as HTMLInputElement;
expect(emailInput.value).toBe('');
});
});
@@ -1,193 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useInviteUser } from '@/api/useUsers';
import { inviteUserSchema, type InviteUserFormData } from '@/lib/schemas/users';
import { NetworkError } from '@/lib/api-client';
interface InviteUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const inviteUser = useInviteUser({
onError: (err) => {
if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('errors.generic'));
}
},
});
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteUserFormData>({
resolver: zodResolver(inviteUserSchema),
});
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setStep(1);
setInviteLink(null);
setServerError(null);
inviteUser.reset();
reset();
}
onOpenChange(nextOpen);
};
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
const response = await inviteUser.mutateAsync(values);
setInviteLink(response.inviteLink);
setStep(2);
} catch {
// error shown via serverError state
}
});
const handleCopy = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(`${window.location.origin}${inviteLink}`);
toast.success(t('users.actions.linkCopied'));
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 1 ? t('users.invite.title') : t('users.invite.step2Title')}
</DialogTitle>
</DialogHeader>
{step === 1 && (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="invite-email">{t('users.invite.emailLabel')}</Label>
<Input
id="invite-email"
type="email"
placeholder="user@example.com"
data-testid="invite-dialog-email"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && (
<FieldError message={errors.email.message} testId="invite-email-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">{t('users.invite.roleLabel')}</Label>
<Select
onValueChange={(val) =>
setValue('role', val as 'Administrator' | 'User', {
shouldValidate: true,
})
}
>
<SelectTrigger
id="invite-role"
data-testid="invite-dialog-role"
aria-invalid={errors.role !== undefined}
>
<SelectValue placeholder={t('users.invite.rolePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Administrator">
{t('users.roles.Administrator')}
</SelectItem>
<SelectItem value="User">{t('users.roles.User')}</SelectItem>
</SelectContent>
</Select>
{errors.role && (
<FieldError message={errors.role.message} testId="invite-role-error" />
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || inviteUser.isPending}
data-testid="invite-dialog-submit"
>
{isSubmitting || inviteUser.isPending
? '...'
: t('users.invite.submitButton')}
</Button>
</form>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('users.invite.step2Description')}
</p>
<div className="space-y-2">
<Label>{t('users.invite.linkLabel')}</Label>
<Input
value={`${window.location.origin}${inviteLink ?? ''}`}
readOnly
data-testid="invite-dialog-link-input"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleCopy}
data-testid="invite-dialog-copy"
>
{t('users.invite.copyLink')}
</Button>
<Button
className="flex-1"
onClick={() => onOpenChange(false)}
data-testid="invite-dialog-close"
>
{t('users.invite.close')}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}