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
@@ -0,0 +1,38 @@
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 '@/features/auth/mocks/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();
});
});
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import type { UserRole } from '@/features/auth/services/types';
import { useAuth } from '@/features/auth/context/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>
);
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import { useAuth } from '@/features/auth/context/auth-context';
import { api } from '@/lib/api-client';
import { server } from '@/mocks/server';
import { API_BASE, mockUser } from '@/features/auth/mocks/fixtures';
import { mockGuest } from '@/test/utils';
import i18n from '@/i18n/config';
function wrapper({ children }: { children: ReactNode }) {
return (
<I18nextProvider i18n={i18n}>
<AuthProvider>{children}</AuthProvider>
</I18nextProvider>
);
}
describe('AuthContext', () => {
it('hydrates the session via silent refresh on mount (BR-U1-01)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe(mockUser.email);
expect(result.current.accessToken).toBe('mock-access-token');
});
it('falls back to guest when silent refresh fails', async () => {
mockGuest();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('guest'));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('refreshes and retries once on a 401 (BR-U1-04)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
let calls = 0;
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () => {
calls += 1;
if (calls === 1) {
return HttpResponse.json(
{ status: 401, title: 'Unauthorized' },
{ status: 401 },
);
}
return HttpResponse.json({ value: 'ok' });
}),
);
let data: unknown;
await act(async () => {
data = await api.get('/api/v1/widgets');
});
expect(calls).toBe(2);
expect(data).toEqual({ value: 'ok' });
});
it('clears the session when the 401 refresh also fails', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
// Both the protected call and the refresh now fail.
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
);
await act(async () => {
await expect(api.get('/api/v1/widgets')).rejects.toThrow();
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(false));
});
});
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse } from '@/features/auth/services/types';
import { api } from '@/lib/api-client';
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/features/auth/context/auth-context';
/**
* Owns the in-memory authentication session. On mount it attempts a silent
* refresh using the httpOnly cookie (BR-U1-01) and wires the ApiClient's
* 401 interceptor to this provider's refresh/clear logic (BR-U1-04).
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContextValue['user']>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const applySession = useCallback((data: AuthResponse) => {
setUser(data.user);
setAccessToken(data.accessToken);
setExpiresAt(data.expiresAt);
api.setAccessToken(data.accessToken);
setStatus('authenticated');
}, []);
const clearSession = useCallback(() => {
setUser(null);
setAccessToken(null);
setExpiresAt(null);
api.setAccessToken(null);
setStatus('guest');
}, []);
const refresh = useCallback(async (): Promise<string | null> => {
try {
const data = await api.post<AuthResponse>('/api/v1/auth/refresh', undefined, {
skipAuthRefresh: true,
});
applySession(data);
return data.accessToken;
} catch {
clearSession();
return null;
}
}, [applySession, clearSession]);
const login = useCallback(
async (email: string, password: string) => {
const data = await api.post<AuthResponse>(
'/api/v1/auth/login',
{ email, password },
{ skipAuthRefresh: true },
);
applySession(data);
},
[applySession],
);
const logout = useCallback(async () => {
try {
await api.post('/api/v1/auth/revoke', undefined, { skipAuthRefresh: true });
} catch {
// Revoke is best-effort; clear local state regardless.
} finally {
clearSession();
}
}, [clearSession]);
// Wire the ApiClient interceptor hooks to this provider.
useEffect(() => {
api.setRefreshHandler(refresh);
api.setAuthFailureHandler(clearSession);
return () => {
api.setRefreshHandler(null);
api.setAuthFailureHandler(null);
};
}, [refresh, clearSession]);
// Silent refresh on app mount (BR-U1-01). This intentionally synchronizes
// React state with the external session (httpOnly cookie) on startup.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
}, [refresh]);
const value = useMemo<AuthContextValue>(
() => ({
status,
user,
accessToken,
expiresAt,
isAuthenticated: status === 'authenticated',
login,
logout,
refresh,
}),
[status, user, accessToken, expiresAt, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@@ -0,0 +1,27 @@
import { createContext, useContext } from 'react';
import type { User } from '@/features/auth/services/types';
export type AuthStatus = 'loading' | 'authenticated' | 'guest';
export interface AuthContextValue {
status: AuthStatus;
user: User | null;
/** Access token, kept only in memory (BR-U1-02). */
accessToken: string | null;
expiresAt: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Performs a cookie-based silent refresh; resolves to the new token or null. */
refresh: () => Promise<string | null>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
@@ -0,0 +1,34 @@
import type { AuthResponse, User } from '@/features/auth/services/types';
import { getAppConfig } from '@/lib/config';
/**
* Base URL the ApiClient targets; mocks must match it exactly. Reads the same
* getAppConfig().apiBaseUrl the real ApiClient is constructed with, rather than
* re-deriving VITE_API_BASE_URL with its own fallback — a second, diverging default here
* previously matched the real client's origin only when a local .env.local happened to set
* VITE_API_BASE_URL, and silently mismatched (all mocks unmatched) whenever it was unset, as in CI.
*/
export const API_BASE = getAppConfig().apiBaseUrl;
export const TEST_CREDENTIALS = {
email: 'owner@example.com',
password: 'Password123!',
};
export const mockUser: User = {
id: '11111111-1111-1111-1111-111111111111',
email: TEST_CREDENTIALS.email,
name: 'Test Owner',
role: 'Owner',
isActive: true,
};
export function makeAuthResponse(overrides: Partial<AuthResponse> = {}): AuthResponse {
return {
accessToken: 'mock-access-token',
// Fixed timestamp keeps fixtures deterministic.
expiresAt: '2099-01-01T00:00:00.000Z',
user: mockUser,
...overrides,
};
}
@@ -0,0 +1,49 @@
import { http, HttpResponse } from 'msw';
import type { LoginRequest } from '@/features/auth/services/types';
import type { ProblemDetails } from '@/lib/api-client';
import { API_BASE, makeAuthResponse, TEST_CREDENTIALS } from './fixtures';
function problem(status: number, title: string, detail?: string): ProblemDetails {
return {
type: 'about:blank',
title,
status,
detail,
traceId: '00-mock-trace-00',
};
}
/**
* Default auth mocks aligned with the real backend contract:
* POST /api/v1/auth/login, /refresh, /revoke. Tests override these per case
* via `server.use(...)` to simulate failures and 401 flows.
*/
export const authHandlers = [
http.post(`${API_BASE}/api/v1/auth/login`, async ({ request }) => {
const body = (await request.json()) as LoginRequest;
if (body.email === TEST_CREDENTIALS.email && body.password === TEST_CREDENTIALS.password) {
return HttpResponse.json(makeAuthResponse());
}
return HttpResponse.json(problem(401, 'Invalid email or password'), { status: 401 });
}),
// By default refresh succeeds (simulates a valid httpOnly cookie present).
http.post(`${API_BASE}/api/v1/auth/refresh`, () => {
return HttpResponse.json(makeAuthResponse());
}),
http.post(`${API_BASE}/api/v1/auth/revoke`, () => {
return new HttpResponse(null, { status: 204 });
}),
http.post(`${API_BASE}/api/v1/Auth/change-password`, async ({ request }) => {
const body = (await request.json()) as { currentPassword: string; newPassword: string };
if (body.currentPassword === TEST_CREDENTIALS.password) {
return new HttpResponse(null, { status: 200 });
}
return HttpResponse.json(
problem(400, 'Incorrect password', 'Current password is incorrect.'),
{ status: 400 },
);
}),
];
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { TEST_CREDENTIALS } from '@/features/auth/mocks/fixtures';
describe('LoginPage', () => {
it('shows validation errors when submitting an empty form', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
const submit = await screen.findByTestId('login-form-submit-button');
await user.click(submit);
expect(await screen.findByTestId('login-email-error')).toBeInTheDocument();
expect(await screen.findByTestId('login-password-error')).toBeInTheDocument();
});
it('logs in with valid credentials and lands on the dashboard', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), TEST_CREDENTIALS.email);
await user.type(screen.getByTestId('login-password-input'), TEST_CREDENTIALS.password);
await user.click(screen.getByTestId('login-form-submit-button'));
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
});
it('shows an error banner on invalid credentials', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), 'wrong@example.com');
await user.type(screen.getByTestId('login-password-input'), 'wrongpassword');
await user.click(screen.getByTestId('login-form-submit-button'));
const banner = await screen.findByTestId('login-error');
expect(banner).toBeInTheDocument();
// Still on the login page.
await waitFor(() =>
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument(),
);
});
});
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/features/auth/context/auth-context';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { loginFormSchema, type LoginFormData } from '@/features/auth/schemas/auth';
export function LoginPage() {
const { t } = useTranslation();
const { login } = useAuth();
const navigate = useNavigate();
const search = useSearch({ strict: false }) as { redirect?: string };
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginFormSchema),
mode: 'onTouched',
defaultValues: { email: '', password: '' },
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
await login(values.email, values.password);
await navigate({ to: (search.redirect as string | undefined) ?? '/dashboard' });
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 401) {
setServerError(t('login.errors.invalidCredentials'));
} else if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('login.errors.generic'));
}
}
});
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('login.title')}</CardTitle>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} noValidate className="space-y-4">
{serverError !== null && (
<div
role="alert"
data-testid="login-error"
className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder')}
data-testid="login-email-input"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && (
<p
className="text-sm text-destructive"
data-testid="login-email-error"
>
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password')}</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
autoComplete="current-password"
data-testid="login-password-input"
aria-invalid={errors.password !== undefined}
{...register('password')}
/>
{errors.password && (
<p
className="text-sm text-destructive"
data-testid="login-password-error"
>
{errors.password.message}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
data-testid="login-form-submit-button"
>
{isSubmitting ? t('login.submitting') : t('login.submit')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
passwordSchema,
setupFormSchema,
loginFormSchema,
invitationCompleteFormSchema,
} from '@/features/auth/schemas/auth';
describe('passwordSchema', () => {
it('accepts a valid password', () => {
expect(passwordSchema.safeParse('Password1!').success).toBe(true);
});
it('rejects a password that is too short', () => {
expect(passwordSchema.safeParse('Pass1!').success).toBe(false);
});
it('rejects a password without an uppercase letter', () => {
expect(passwordSchema.safeParse('password1!').success).toBe(false);
});
it('rejects a password without a digit', () => {
expect(passwordSchema.safeParse('Password!').success).toBe(false);
});
it('rejects a password without a special character', () => {
expect(passwordSchema.safeParse('Password1').success).toBe(false);
});
});
describe('setupFormSchema', () => {
const valid = {
name: 'Admin',
email: 'admin@example.com',
password: 'Password1!',
confirmPassword: 'Password1!',
language: 'nl' as const,
};
it('accepts valid setup data', () => {
expect(setupFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects when passwords do not match', () => {
const result = setupFormSchema.safeParse({ ...valid, confirmPassword: 'Different1!' });
expect(result.success).toBe(false);
});
it('rejects empty name', () => {
expect(setupFormSchema.safeParse({ ...valid, name: '' }).success).toBe(false);
});
it('rejects name exceeding 255 characters', () => {
expect(setupFormSchema.safeParse({ ...valid, name: 'a'.repeat(256) }).success).toBe(false);
});
it('rejects invalid email format', () => {
expect(setupFormSchema.safeParse({ ...valid, email: 'not-an-email' }).success).toBe(false);
});
it('rejects empty email', () => {
expect(setupFormSchema.safeParse({ ...valid, email: '' }).success).toBe(false);
});
it('rejects invalid language', () => {
expect(setupFormSchema.safeParse({ ...valid, language: 'fr' }).success).toBe(false);
});
it('accepts "en" as language', () => {
expect(setupFormSchema.safeParse({ ...valid, language: 'en' }).success).toBe(true);
});
});
describe('loginFormSchema', () => {
it('accepts valid login credentials', () => {
expect(loginFormSchema.safeParse({ email: 'user@example.com', password: 'anypass' }).success).toBe(true);
});
it('rejects empty email', () => {
expect(loginFormSchema.safeParse({ email: '', password: 'pass' }).success).toBe(false);
});
it('rejects invalid email format', () => {
expect(loginFormSchema.safeParse({ email: 'not-email', password: 'pass' }).success).toBe(false);
});
it('rejects empty password', () => {
expect(loginFormSchema.safeParse({ email: 'user@example.com', password: '' }).success).toBe(false);
});
});
describe('invitationCompleteFormSchema', () => {
const valid = {
name: 'New User',
password: 'Password1!',
confirmPassword: 'Password1!',
};
it('accepts valid invitation completion data', () => {
expect(invitationCompleteFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects when passwords do not match', () => {
expect(
invitationCompleteFormSchema.safeParse({ ...valid, confirmPassword: 'Different1!' }).success,
).toBe(false);
});
it('rejects empty name', () => {
expect(invitationCompleteFormSchema.safeParse({ ...valid, name: '' }).success).toBe(false);
});
it('rejects weak password', () => {
expect(
invitationCompleteFormSchema.safeParse({
...valid,
password: 'weak',
confirmPassword: 'weak',
}).success,
).toBe(false);
});
});
@@ -0,0 +1,61 @@
import { z } from 'zod';
// Shared password validation schema matching backend rules:
// - Minimum 8 characters
// - At least 1 uppercase letter
// - At least 1 digit
// - At least 1 special character
export const passwordSchema = z.string()
.min(8, 'Wachtwoord moet minstens 8 tekens bevatten')
.regex(/[A-Z]/, 'Wachtwoord moet minstens één hoofdletter bevatten')
.regex(/[0-9]/, 'Wachtwoord moet minstens één cijfer bevatten')
.regex(
/[!@#$%^&*()\-_=+[\]{}|;:,.<>?]/,
'Wachtwoord moet minstens één speciaal teken bevatten'
);
export const confirmPasswordSchema = z.string()
.min(1, 'Bevestig wachtwoord is vereist');
// Setup form schema
export const setupFormSchema = z.object({
name: z.string()
.min(1, 'Naam is vereist')
.max(255, 'Naam mag maximaal 255 tekens zijn'),
email: z.string()
.min(1, 'E-mailadres is vereist')
.email('Voer een geldig e-mailadres in'),
password: passwordSchema,
confirmPassword: confirmPasswordSchema,
language: z.enum(['en', 'nl'])
}).refine((data) => data.password === data.confirmPassword, {
message: 'Wachtwoorden komen niet overeen',
path: ['confirmPassword']
});
export type SetupFormData = z.infer<typeof setupFormSchema>;
// Invitation completion form schema
export const invitationCompleteFormSchema = z.object({
name: z.string()
.min(1, 'Naam is vereist')
.max(255, 'Naam mag maximaal 255 tekens zijn'),
password: passwordSchema,
confirmPassword: confirmPasswordSchema
}).refine((data) => data.password === data.confirmPassword, {
message: 'Wachtwoorden komen niet overeen',
path: ['confirmPassword']
});
export type InvitationCompleteFormData = z.infer<typeof invitationCompleteFormSchema>;
// Login form schema (password only)
export const loginFormSchema = z.object({
email: z.string()
.min(1, 'E-mailadres is vereist')
.email('Voer een geldig e-mailadres in'),
password: z.string()
.min(1, 'Wachtwoord is vereist')
});
export type LoginFormData = z.infer<typeof loginFormSchema>;
@@ -0,0 +1,21 @@
// Roles aligned with backend authorization.
export type UserRole = 'Owner' | 'Administrator' | 'User';
export interface User {
id: string; // UUID
email: string;
name: string;
role: UserRole;
isActive: boolean;
}
export interface AuthResponse {
accessToken: string; // JWT access token
expiresAt: string; // ISO timestamp
user: User;
}
export interface LoginRequest {
email: string;
password: string;
}
@@ -0,0 +1,100 @@
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();
});
});
@@ -0,0 +1,82 @@
import { AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AvailabilityStatus } from '@/features/availability/services/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>
);
}
@@ -0,0 +1,14 @@
import { http, HttpResponse } from 'msw';
export const availabilityHandlers = [
http.get('*/Availability/status', () =>
HttpResponse.json({
status: 'Available',
checkedAt: new Date().toISOString(),
message: '',
isMasterControlled: false,
}),
),
http.post('*/Availability/admin/status', () => new HttpResponse(null, { status: 200 })),
];
@@ -0,0 +1,9 @@
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
message: string;
/** True when a Master CMS has taken control of this instance's gate — the local status cannot be changed here. */
isMasterControlled: boolean;
}
@@ -0,0 +1,73 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useAvailabilityStatus } from './useAvailability';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const AVAILABILITY_URL = `${API_BASE}/api/v1/Availability/status`;
describe('useAvailabilityStatus', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useAvailabilityStatus(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
});
it('returns data on successful fetch', async () => {
const { result } = renderHook(() => useAvailabilityStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toMatchObject({
status: 'Available',
message: '',
});
expect(typeof result.current.data?.checkedAt).toBe('string');
});
it('enters error state when the server returns 5xx', async () => {
server.use(
http.get(AVAILABILITY_URL, () =>
HttpResponse.json(
{ title: 'Internal Server Error', status: 500 },
{ status: 500 },
),
),
);
const { result } = renderHook(() => useAvailabilityStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isError).toBe(true);
expect(result.current.data).toBeUndefined();
});
it('exposes a refetch function', async () => {
const { result } = renderHook(() => useAvailabilityStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(typeof result.current.refetch).toBe('function');
});
});
@@ -0,0 +1,24 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { AvailabilityResponse, AvailabilityStatus } from './types';
export interface UpdateAvailabilityPayload {
newStatus: AvailabilityStatus;
reason: string;
}
export function useAvailabilityStatus() {
return useQuery<AvailabilityResponse, Error>({
queryKey: ['availability', 'status'],
queryFn: () => api.get<AvailabilityResponse>('/api/v1/Availability/status'),
staleTime: 30_000,
});
}
export function useUpdateAvailability() {
const queryClient = useQueryClient();
return useMutation<void, Error, UpdateAvailabilityPayload>({
mutationFn: (data) => api.post<void>('/api/v1/Availability/admin/status', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['availability', 'status'] }),
});
}
@@ -0,0 +1,71 @@
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 '@/features/auth/mocks/fixtures';
import { resetMockCmsInstances } from '@/features/cms/mocks/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('');
});
});
@@ -0,0 +1,138 @@
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 '@/features/cms/services/useAddCmsInstance';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/features/cms/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>
);
}
@@ -0,0 +1,112 @@
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 '@/features/cms/services/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>
);
}
@@ -0,0 +1,49 @@
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 '@/features/cms/mocks/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();
});
});
@@ -0,0 +1,187 @@
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 '@/features/cms/services/useUpdateCmsInstanceStatus';
import { NetworkError } from '@/lib/api-client';
import { setStatusSchema, type SetStatusFormData } from '@/features/cms/schemas/cms';
import type { CmsInstance, CmsInstanceStatus } from '@/features/cms/services/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>
);
}
@@ -0,0 +1,66 @@
import { http, HttpResponse } from 'msw';
import type { CmsInstance, UpdateStatusResult } from '@/features/cms/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
const seed: CmsInstance[] = [
{
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
name: 'Main CMS',
url: 'http://cms.example.com',
status: 'Available',
disableMessage: null,
lastContactedAt: '2026-06-30T10:00:00.000Z',
lastStatusPushedAt: '2026-06-30T10:00:00.000Z',
lastIntegrityCheckFailedAt: null,
},
{
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
name: 'Legacy CMS',
url: 'http://legacy.example.com',
status: 'Inactive',
disableMessage: null,
lastContactedAt: null,
lastStatusPushedAt: null,
lastIntegrityCheckFailedAt: null,
},
];
let mockCmsInstances: CmsInstance[] = [...seed];
export const resetMockCmsInstances = () => {
mockCmsInstances = [...seed];
};
export const getMockCmsInstances = () => mockCmsInstances;
export const cmsHandlers = [
http.get(`${API_BASE}/api/v1/CmsInstances`, () => HttpResponse.json(mockCmsInstances)),
http.post(`${API_BASE}/api/v1/CmsInstances`, async ({ request }) => {
const body = (await request.json()) as { name: string; url: string; apiKey: string };
const newInstance: CmsInstance = {
id: crypto.randomUUID(),
name: body.name,
url: body.url,
status: 'Available',
disableMessage: null,
lastContactedAt: null,
lastStatusPushedAt: null,
lastIntegrityCheckFailedAt: null,
};
mockCmsInstances = [...mockCmsInstances, newInstance];
return HttpResponse.json(newInstance, { status: 201 });
}),
http.put(`${API_BASE}/api/v1/CmsInstances/:id/status`, async ({ params, request }) => {
const { id } = params as { id: string };
const body = (await request.json()) as { status: CmsInstance['status']; disableMessage: string | null };
mockCmsInstances = mockCmsInstances.map((instance) =>
instance.id === id
? { ...instance, status: body.status, disableMessage: body.disableMessage }
: instance,
);
const result: UpdateStatusResult = { success: true, slaveContactSuccess: true };
return HttpResponse.json(result);
}),
];
@@ -0,0 +1,75 @@
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, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { resetMockCmsInstances } from '@/features/cms/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockCmsInstances();
});
const CMS_URL = `${API_BASE}/api/v1/CmsInstances`;
describe('CmsPage', () => {
it('renders the page title and Add button', async () => {
mockAuthenticated();
renderApp('/cms');
expect(await screen.findByTestId('cms-title', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('cms-add-button')).toBeInTheDocument();
});
it('renders list of CMS instances from seed data', async () => {
mockAuthenticated();
renderApp('/cms');
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 });
expect(rows.length).toBe(2);
});
it('renders empty state when no instances exist', async () => {
mockAuthenticated();
server.use(http.get(CMS_URL, () => HttpResponse.json([])));
renderApp('/cms');
expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument();
});
it('opens AddCmsInstanceDialog when Add button is clicked', async () => {
mockAuthenticated();
renderApp('/cms');
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('cms-add-button'));
expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument();
});
it('adds a CMS instance successfully', async () => {
mockAuthenticated();
renderApp('/cms');
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('cms-add-button'));
await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS');
await userEvent.type(screen.getByTestId('add-cms-url'), 'http://new.example.com');
await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key');
await userEvent.click(screen.getByTestId('add-cms-submit'));
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 });
expect(rows.length).toBe(3);
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/cms');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { CmsInstanceList } from '@/features/cms/components/CmsInstanceList';
import { AddCmsInstanceDialog } from '@/features/cms/components/AddCmsInstanceDialog';
import { SetStatusDialog } from '@/features/cms/components/SetStatusDialog';
import { useCmsInstances } from '@/features/cms/services/useCmsInstances';
import type { CmsInstance } from '@/features/cms/services/types';
export function CmsPage() {
const { t } = useTranslation();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [statusTarget, setStatusTarget] = useState<CmsInstance | null>(null);
const { data: instances, isPending, isError } = useCmsInstances();
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold" data-testid="cms-title">
{t('cms.title')}
</h1>
<Button
onClick={() => setAddDialogOpen(true)}
data-testid="cms-add-button"
>
{t('cms.addButton')}
</Button>
</div>
{isPending && (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
)}
{isError && (
<p className="text-sm text-destructive">{t('errors.generic')}</p>
)}
{!isPending && !isError && instances?.length === 0 && (
<div
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
data-testid="cms-empty-state"
>
<LayoutGrid className="size-12 text-muted-foreground" />
<h2 className="text-xl font-semibold">{t('cms.emptyState.heading')}</h2>
<p className="text-muted-foreground max-w-sm">{t('cms.emptyState.description')}</p>
<Button onClick={() => setAddDialogOpen(true)} data-testid="cms-empty-add-button">
{t('cms.addButton')}
</Button>
</div>
)}
{!isPending && !isError && instances && instances.length > 0 && (
<CmsInstanceList
instances={instances}
onSetStatus={(instance) => setStatusTarget(instance)}
/>
)}
<AddCmsInstanceDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
<SetStatusDialog
instance={statusTarget}
open={statusTarget !== null}
onOpenChange={(open) => { if (!open) setStatusTarget(null); }}
/>
</div>
);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { addCmsInstanceSchema, setStatusSchema } from './cms';
describe('addCmsInstanceSchema', () => {
it('accepts valid data', () => {
const result = addCmsInstanceSchema.safeParse({
name: 'My CMS',
url: 'http://192.168.1.5:8080',
apiKey: 'secret-key',
});
expect(result.success).toBe(true);
});
it('accepts https URLs', () => {
const result = addCmsInstanceSchema.safeParse({
name: 'My CMS',
url: 'https://cms.example.com',
apiKey: 'secret-key',
});
expect(result.success).toBe(true);
});
it('rejects missing name', () => {
const result = addCmsInstanceSchema.safeParse({ name: '', url: 'http://example.com', apiKey: 'k' });
expect(result.success).toBe(false);
});
it('rejects URL without protocol', () => {
const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: 'cms.example.com', apiKey: 'k' });
expect(result.success).toBe(false);
});
it('rejects bare IP without protocol', () => {
const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: '192.168.1.5:8080', apiKey: 'k' });
expect(result.success).toBe(false);
});
it('rejects missing apiKey', () => {
const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: 'http://example.com', apiKey: '' });
expect(result.success).toBe(false);
});
});
describe('setStatusSchema', () => {
it('accepts Available without disableMessage', () => {
const result = setStatusSchema.safeParse({ status: 'Available' });
expect(result.success).toBe(true);
});
it('accepts Inactive without disableMessage', () => {
const result = setStatusSchema.safeParse({ status: 'Inactive' });
expect(result.success).toBe(true);
});
it('accepts NotAvailable with a disableMessage', () => {
const result = setStatusSchema.safeParse({ status: 'NotAvailable', disableMessage: 'System down' });
expect(result.success).toBe(true);
});
it('rejects NotAvailable without disableMessage', () => {
const result = setStatusSchema.safeParse({ status: 'NotAvailable' });
expect(result.success).toBe(false);
});
it('rejects NotAvailable with blank disableMessage', () => {
const result = setStatusSchema.safeParse({ status: 'NotAvailable', disableMessage: ' ' });
expect(result.success).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const addCmsInstanceSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or fewer'),
url: z.string().url('Must be a valid URL (include http:// or https://)'),
apiKey: z.string().min(1, 'API key is required'),
});
export type AddCmsInstanceFormData = z.infer<typeof addCmsInstanceSchema>;
export const setStatusSchema = z
.object({
status: z.enum(['Available', 'NotAvailable', 'Inactive']),
disableMessage: z.string().optional(),
})
.refine(
(data) =>
data.status !== 'NotAvailable' || (data.disableMessage?.trim().length ?? 0) > 0,
{
message: 'Disable message is required when status is Not Available',
path: ['disableMessage'],
},
);
export type SetStatusFormData = z.infer<typeof setStatusSchema>;
@@ -0,0 +1,28 @@
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
export interface CmsInstance {
id: string;
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null;
lastStatusPushedAt: string | null;
lastIntegrityCheckFailedAt: string | null;
}
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean;
}
@@ -0,0 +1,54 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useAddCmsInstance } from './useAddCmsInstance';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const CMS_URL = `${API_BASE}/api/v1/CmsInstances`;
describe('useAddCmsInstance', () => {
it('returns created instance on success', async () => {
const { result } = renderHook(() => useAddCmsInstance(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
name: 'Test CMS',
url: 'http://test.example.com',
apiKey: 'secret',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('Test CMS');
});
it('calls onError callback on HTTP 400', async () => {
server.use(http.post(CMS_URL, () => HttpResponse.json({}, { status: 400 })));
const onError = vi.fn();
const { result } = renderHook(() => useAddCmsInstance({ onError }), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({ name: 'X', url: 'http://x.com', apiKey: 'k' });
} catch {
// expected
}
});
await waitFor(() => expect(onError).toHaveBeenCalled());
});
});
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CmsInstance, CreateCmsInstanceRequest } from './types';
export function useAddCmsInstance(
options?: Pick<UseMutationOptions<CmsInstance, Error, CreateCmsInstanceRequest>, 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<CmsInstance, Error, CreateCmsInstanceRequest>({
mutationFn: (data) => api.post<CmsInstance>('/api/v1/CmsInstances', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
...options,
});
}
@@ -0,0 +1,44 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockCmsInstances } from '@/features/cms/mocks/handlers';
import { useCmsInstances } from './useCmsInstances';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const CMS_URL = `${API_BASE}/api/v1/CmsInstances`;
describe('useCmsInstances', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
expect(result.current.isPending).toBe(true);
});
it('returns the instances list on success', async () => {
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toHaveLength(getMockCmsInstances().length);
expect(result.current.data?.[0].name).toBe(getMockCmsInstances()[0].name);
});
it('enters error state on network error', async () => {
server.use(http.get(CMS_URL, () => HttpResponse.json({}, { status: 500 })));
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CmsInstance } from './types';
export function useCmsInstances() {
return useQuery<CmsInstance[], Error>({
queryKey: ['cmsInstances'],
queryFn: () => api.get<CmsInstance[]>('/api/v1/CmsInstances'),
staleTime: 30_000,
});
}
@@ -0,0 +1,64 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useUpdateCmsInstanceStatus } from './useUpdateCmsInstanceStatus';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const INSTANCE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const STATUS_URL = `${API_BASE}/api/v1/CmsInstances/${INSTANCE_ID}/status`;
describe('useUpdateCmsInstanceStatus', () => {
it('returns UpdateStatusResult on success', async () => {
const onSuccess = vi.fn();
const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onSuccess }), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: INSTANCE_ID,
status: 'NotAvailable',
disableMessage: 'Under maintenance',
});
});
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
const callArg = onSuccess.mock.calls[0][0];
expect(callArg.success).toBe(true);
expect(callArg.slaveContactSuccess).toBe(true);
});
it('calls onError on network failure', async () => {
server.use(http.put(STATUS_URL, () => HttpResponse.json({}, { status: 500 })));
const onError = vi.fn();
const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onError }), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({
id: INSTANCE_ID,
status: 'Available',
disableMessage: null,
});
} catch {
// expected
}
});
await waitFor(() => expect(onError).toHaveBeenCalled());
});
});
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UpdateCmsInstanceStatusRequest, UpdateStatusResult } from './types';
type UpdateVariables = { id: string } & UpdateCmsInstanceStatusRequest;
export function useUpdateCmsInstanceStatus(
options?: Pick<UseMutationOptions<UpdateStatusResult, Error, UpdateVariables>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<UpdateStatusResult, Error, UpdateVariables>({
mutationFn: ({ id, ...body }) =>
api.put<UpdateStatusResult>(`/api/v1/CmsInstances/${id}/status`, body),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['cmsInstances'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -0,0 +1,68 @@
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, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('DashboardPage', () => {
it('renders title and welcome message for authenticated user', async () => {
mockAuthenticated();
renderApp('/dashboard');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-welcome')).toBeInTheDocument();
});
it('displays the availability status badge when data loads', async () => {
mockAuthenticated();
renderApp('/dashboard');
await screen.findByTestId('dashboard-title');
// Availability badge should eventually appear after status loads
expect(await screen.findByTestId('availability-badge')).toBeInTheDocument();
});
it('shows retry button when availability fetch fails', async () => {
server.use(http.get('*/Availability/status', () => HttpResponse.error()));
mockAuthenticated();
renderApp('/dashboard');
await screen.findByTestId('dashboard-title');
expect(await screen.findByTestId('availability-retry')).toBeInTheDocument();
});
it('refetches availability when retry button is clicked', async () => {
let callCount = 0;
server.use(
http.get('*/Availability/status', () => {
callCount += 1;
if (callCount === 1) return HttpResponse.error();
return HttpResponse.json({ status: 'Available', checkedAt: new Date().toISOString(), message: '' });
}),
);
mockAuthenticated();
renderApp('/dashboard');
const retryButton = await screen.findByTestId('availability-retry');
await userEvent.click(retryButton);
expect(callCount).toBeGreaterThanOrEqual(2);
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/dashboard');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/features/auth/context/auth-context';
import { useAvailabilityStatus } from '@/features/availability/services/useAvailability';
import { AvailabilityStatusBadge } from '@/features/availability/components/AvailabilityStatusBadge';
export function DashboardPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { data, isLoading, isError, refetch } = useAvailabilityStatus();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold" data-testid="dashboard-title">
{t('dashboard.title')}
</h1>
<p className="text-muted-foreground" data-testid="dashboard-welcome">
{t('dashboard.welcome', { name: user?.name ?? '' })}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t('availability.title')}</CardTitle>
</CardHeader>
<CardContent>
{isLoading && (
<div
data-testid="availability-skeleton"
className="h-8 w-32 animate-pulse rounded-full bg-muted"
/>
)}
{!isLoading && data && (
<AvailabilityStatusBadge
status={data.status}
message={data.message}
stale={isError}
/>
)}
{isError && (
<div
data-testid="availability-error"
className="mt-3 flex items-center gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<span>{t('availability.errorTitle')}</span>
<Button
variant="outline"
size="sm"
data-testid="availability-retry"
onClick={() => void refetch()}
>
{t('availability.retry')}
</Button>
</div>
)}
{!isLoading && !data && !isError && null}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,90 @@
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 '@/features/auth/mocks/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('');
});
});
@@ -0,0 +1,193 @@
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 '@/features/users/services/useUsers';
import { inviteUserSchema, type InviteUserFormData } from '@/features/users/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>
);
}
@@ -0,0 +1,44 @@
import { http, HttpResponse } from 'msw';
import { API_BASE } from '@/features/auth/mocks/fixtures';
export const invitationHandlers = [
http.get(`${API_BASE}/api/v1/Invitation/validate`, ({ request }) => {
const token = new URL(request.url).searchParams.get('token');
if (token === 'valid-token') {
return HttpResponse.json(
{ isValid: true, email: 'invited@example.com', name: null, errorCode: null },
{ status: 200 },
);
}
return HttpResponse.json(
{ isValid: false, email: '', name: null, errorCode: 'EXPIRED' },
{ status: 200 },
);
}),
http.post(`${API_BASE}/api/v1/Invitation/complete`, async ({ request }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = await request.json() as any;
if (!body.token || !body.name || !body.password) {
return HttpResponse.json(
{ type: 'about:blank', title: 'Bad Request', status: 400, detail: 'Missing required fields' },
{ status: 400 },
);
}
if (body.token !== 'valid-token') {
return HttpResponse.json(
{ type: 'about:blank', title: 'Bad Request', status: 400, detail: 'Invalid or expired token' },
{ status: 400 },
);
}
return HttpResponse.json(
{ message: 'Account created', user: { id: '2', name: body.name, email: 'invited@example.com', role: 'User' } },
{ status: 201 },
);
}),
];
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { setupUninitializedHandlers } from '@/mocks/index';
import { _resetSetupStatusCache } from '@/router';
// Reset module-level setup status cache before each test.
beforeEach(() => {
_resetSetupStatusCache();
});
describe('InviteCompletePage', () => {
it('shows a loading spinner on mount while validating the token', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/invite/complete?token=valid-token');
// Loading spinner should appear immediately.
expect(await screen.findByTestId('invite-loading')).toBeInTheDocument();
});
it('shows an error state for an expired token', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/invite/complete?token=expired-token');
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
expect(screen.queryByTestId('invite-name-input')).not.toBeInTheDocument();
});
it('shows an error state for a used token', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/invite/complete?token=used-token');
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
});
it('shows an error state when no token is provided', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/invite/complete');
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
});
it('shows the form with a read-only email for a valid token', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/invite/complete?token=valid-token');
expect(await screen.findByTestId('invite-name-input')).toBeInTheDocument();
const emailInput = screen.getByTestId('invite-email-input') as HTMLInputElement;
expect(emailInput.value).toBe('invited@example.com');
expect(emailInput.disabled).toBe(true);
});
it('shows validation errors when submitting an empty form', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/invite/complete?token=valid-token');
const submit = await screen.findByTestId('invite-submit-button');
await user.click(submit);
expect(await screen.findByTestId('invite-name-error')).toBeInTheDocument();
expect(screen.getByTestId('invite-password-error')).toBeInTheDocument();
});
it('shows success message after a valid submission', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/invite/complete?token=valid-token');
await user.type(await screen.findByTestId('invite-name-input'), 'Bob User');
await user.type(screen.getByTestId('invite-password-input'), 'ValidPass1!');
await user.type(screen.getByTestId('invite-confirmPassword-input'), 'ValidPass1!');
await user.click(screen.getByTestId('invite-submit-button'));
expect(await screen.findByTestId('invite-success')).toBeInTheDocument();
});
});
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { invitationCompleteFormSchema, type InvitationCompleteFormData } from '@/features/auth/schemas/auth';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useValidateInvitation, useCompleteInvitation } from '@/features/invitation/services/useInvitation';
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
interface FormError {
message: string;
}
type LoadingState = 'loading' | 'ready' | 'error' | 'submitting' | 'success';
export function InviteCompletePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const search = useSearch({ strict: false }) as { token?: string };
const [serverError, setServerError] = useState<FormError | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
const token = search.token || null;
const validationQuery = useValidateInvitation(token);
const completeMutation = useCompleteInvitation();
// Handle validation query state changes
if (!token) {
if (loadingState === 'loading') {
setLoadingState('error');
}
} else if (validationQuery.isPending && loadingState !== 'submitting') {
// Still loading
} else if (validationQuery.error || (validationQuery.data && !validationQuery.data.isValid)) {
if (loadingState === 'loading') {
setLoadingState('error');
}
} else if (validationQuery.data?.isValid && loadingState === 'loading') {
setLoadingState('ready');
}
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<InvitationCompleteFormData>({
resolver: zodResolver(invitationCompleteFormSchema),
mode: 'onBlur',
defaultValues: {
name: '',
password: '',
confirmPassword: ''
}
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
setLoadingState('submitting');
try {
if (!token) {
setServerError({ message: t('errors.invalidInvitationToken') });
setLoadingState('error');
return;
}
const { confirmPassword: _, ...fields } = values;
await completeMutation.mutateAsync({ token, ...fields });
setLoadingState('success');
setTimeout(() => {
navigate({ to: '/login' });
}, 2000);
} catch (err) {
setLoadingState('ready');
if (err instanceof ProblemDetailsError) {
setServerError({ message: err.problem.detail || err.message || t('errors.generic') });
} else if (err instanceof NetworkError) {
setServerError({ message: t('errors.network') });
} else {
setServerError({ message: t('errors.generic') });
}
}
});
if (loadingState === 'loading') {
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-loading">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary"
/>
<p className="text-sm text-muted-foreground">
{t('inviteComplete.loadingMessage')}
</p>
</div>
</CardContent>
</Card>
</div>
);
}
if (loadingState === 'error') {
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-error">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-destructive">{t('errors.setupRequired')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('inviteComplete.invalidTokenMessage')}
</p>
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: '/' })}
>
{t('inviteComplete.requestNewInvitationLink')}
</Button>
</CardContent>
</Card>
</div>
);
}
if (loadingState === 'success') {
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-success">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="text-green-600 text-lg font-medium">
{t('inviteComplete.successMessage')}
</div>
<p className="text-sm text-muted-foreground">
{t('login.subtitle')}
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const email = validationQuery.data?.email || '';
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('inviteComplete.title')}</CardTitle>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} noValidate className="space-y-4" data-testid="invite-complete-form">
<FormErrorBanner
error={serverError || (completeMutation.error ? { message: completeMutation.error.message } : null)}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="email">{t('inviteComplete.emailLabel')}</Label>
<Input
id="email"
type="email"
value={email}
disabled
data-testid="invite-email-input"
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">{t('inviteComplete.nameLabel')}</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
data-testid="invite-name-input"
aria-invalid={errors.name !== undefined}
{...register('name')}
/>
{errors.name && <FieldError message={errors.name.message} testId="invite-name-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('inviteComplete.passwordLabel')}</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
data-testid="invite-password-input"
aria-invalid={errors.password !== undefined}
{...register('password')}
/>
{errors.password && <FieldError message={errors.password.message} testId="invite-password-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t('inviteComplete.confirmPasswordLabel')}</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
data-testid="invite-confirmPassword-input"
aria-invalid={errors.confirmPassword !== undefined}
{...register('confirmPassword')}
/>
{errors.confirmPassword && <FieldError message={errors.confirmPassword.message} />}
</div>
<Button
type="submit"
disabled={isSubmitting || completeMutation.isPending}
className="w-full"
data-testid="invite-submit-button"
>
{isSubmitting || completeMutation.isPending ? '...' : t('inviteComplete.submitButton')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,12 @@
export interface InvitationValidation {
email: string;
name: string | null;
isValid: boolean;
errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null;
}
export interface InviteCompleteRequest {
token: string;
name: string;
password: string;
}
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useValidateInvitation, useCompleteInvitation } from '@/features/invitation/services/useInvitation';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
describe('useValidateInvitation', () => {
it('returns isValid=true for a valid token', async () => {
const { result } = renderHook(() => useValidateInvitation('valid-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.data?.isValid).toBe(true);
expect(result.current.data?.email).toBe('invited@example.com');
});
it('returns isValid=false for an expired or unknown token', async () => {
const { result } = renderHook(() => useValidateInvitation('expired-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.data?.isValid).toBe(false);
});
it('does not fetch when token is null', () => {
const { result } = renderHook(() => useValidateInvitation(null), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe('idle');
});
it('enters error state on server error', async () => {
server.use(
http.get(`${API_BASE}/api/v1/Invitation/validate`, () =>
HttpResponse.json({}, { status: 500 }),
),
);
const { result } = renderHook(() => useValidateInvitation('any-token'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
describe('useCompleteInvitation', () => {
it('succeeds with valid token, name, and password', async () => {
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
token: 'valid-token',
name: 'New User',
password: 'Password1!',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('enters error state when token is invalid', async () => {
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current
.mutateAsync({ token: 'bad-token', name: 'User', password: 'Password1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
it('enters error state on server error', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Invitation/complete`, () =>
HttpResponse.json({}, { status: 500 }),
),
);
const { result } = renderHook(() => useCompleteInvitation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current
.mutateAsync({ token: 'valid-token', name: 'User', password: 'Password1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
@@ -0,0 +1,21 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { InvitationValidation, InviteCompleteRequest } from './types';
export function useValidateInvitation(token: string | null) {
return useQuery<InvitationValidation, Error>({
queryKey: ['invitation', 'validate', token],
queryFn: () =>
api.get<InvitationValidation>(
`/api/v1/Invitation/validate?token=${encodeURIComponent(token!)}`,
),
enabled: !!token,
retry: false,
});
}
export function useCompleteInvitation() {
return useMutation<void, Error, InviteCompleteRequest>({
mutationFn: (data) => api.post('/api/v1/Invitation/complete', data),
});
}
@@ -0,0 +1,120 @@
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, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('ProfilePage', () => {
it('renders the page title and profile form', async () => {
mockAuthenticated();
renderApp('/profile');
expect(await screen.findByTestId('profile-title', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('profile-name-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-email-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument();
});
it('pre-fills name and email from authenticated user', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId<HTMLInputElement>('profile-name-input');
const emailInput = screen.getByTestId<HTMLInputElement>('profile-email-input');
expect(nameInput.value).toBe('Test Owner');
expect(emailInput.value).toBe('owner@example.com');
});
it('save button is disabled when form is pristine', async () => {
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('profile-name-input');
expect(screen.getByTestId('profile-save-button')).toBeDisabled();
});
it('save button enables after editing a field', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId('profile-name-input');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Name');
expect(screen.getByTestId('profile-save-button')).not.toBeDisabled();
});
it('saves profile successfully and shows success toast', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId('profile-name-input');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'New Name');
await userEvent.click(screen.getByTestId('profile-save-button'));
expect(await screen.findByText(/profile updated/i, {}, { timeout: 10000 })).toBeInTheDocument();
});
it('shows validation error for invalid email', async () => {
mockAuthenticated();
renderApp('/profile');
// Type invalid email — this makes form dirty and triggers validation on submit
const emailInput = await screen.findByTestId('profile-email-input');
await userEvent.tripleClick(emailInput);
await userEvent.type(emailInput, 'not-an-email');
const saveButton = screen.getByTestId('profile-save-button');
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
expect(await screen.findByTestId('profile-email-error')).toBeInTheDocument();
});
it('opens change password dialog when button is clicked', async () => {
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('change-password-button');
await userEvent.click(screen.getByTestId('change-password-button'));
expect(await screen.findByTestId('change-password-dialog')).toBeInTheDocument();
});
it('shows error in dialog when current password is wrong', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Auth/change-password`, () =>
HttpResponse.json({ title: 'Bad Request' }, { status: 400 }),
),
);
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('change-password-button');
await userEvent.click(screen.getByTestId('change-password-button'));
await screen.findByTestId('change-password-dialog');
// PasswordField auto-generates testid as ${id}-input
await userEvent.type(screen.getByTestId('currentPassword-input'), 'WrongPass1!');
await userEvent.type(screen.getByTestId('newPassword-input'), 'NewPassword1!');
await userEvent.type(screen.getByTestId('confirmPassword-input'), 'NewPassword1!');
await userEvent.click(screen.getByTestId('change-password-submit'));
expect(await screen.findByTestId('current-password-error')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/profile');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,287 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useAuth } from '@/features/auth/context/auth-context';
import { useUpdateProfile, useChangePassword } from '@/features/profile/services/useProfile';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { PasswordField } from '@/components/ui/PasswordField';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ProblemDetailsError } from '@/lib/api-client';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Enter a valid email address'),
});
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Required'),
newPassword: z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'At least 1 uppercase letter')
.regex(/[a-z]/, 'At least 1 lowercase letter')
.regex(/[0-9]/, 'At least 1 digit')
.regex(/[^a-zA-Z0-9]/, 'At least 1 special character'),
confirmPassword: z.string().min(1, 'Required'),
})
.refine((d) => d.newPassword === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type ProfileFormData = z.infer<typeof profileSchema>;
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
function ChangePasswordDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const changePassword = useChangePassword();
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
const onSubmit = async (data: ChangePasswordFormData) => {
try {
await changePassword.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
toast.success(t('profile.changePasswordDialog.success'));
reset();
onClose();
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
setError('currentPassword', {
message: t('profile.changePasswordDialog.errorCurrent'),
});
} else {
setError('root', { message: t('errors.generic') });
}
}
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent data-testid="change-password-dialog">
<DialogHeader>
<DialogTitle>{t('profile.changePasswordDialog.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{errors.root && (
<p className="text-sm text-destructive" data-testid="change-password-error">
{errors.root.message}
</p>
)}
<div className="space-y-1">
<Label htmlFor="currentPassword">
{t('profile.changePasswordDialog.current')}
</Label>
<PasswordField
id="currentPassword"
data-testid="current-password-input"
{...register('currentPassword')}
/>
{errors.currentPassword && (
<p className="text-sm text-destructive" data-testid="current-password-error">
{errors.currentPassword.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="newPassword">
{t('profile.changePasswordDialog.new')}
</Label>
<PasswordField
id="newPassword"
data-testid="new-password-input"
{...register('newPassword')}
/>
{errors.newPassword && (
<p className="text-sm text-destructive" data-testid="new-password-error">
{errors.newPassword.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="confirmPassword">
{t('profile.changePasswordDialog.confirm')}
</Label>
<PasswordField
id="confirmPassword"
data-testid="confirm-password-input"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive" data-testid="confirm-password-error">
{errors.confirmPassword.message}
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
data-testid="change-password-cancel"
>
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="change-password-submit"
>
{isSubmitting
? t('profile.changePasswordDialog.submitting')
: t('profile.changePasswordDialog.submit')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export function ProfilePage() {
const { t } = useTranslation();
const { user } = useAuth();
const updateProfile = useUpdateProfile();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
});
const onSubmit = async (data: ProfileFormData) => {
try {
await updateProfile.mutateAsync(data);
toast.success(t('profile.saveSuccess'));
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('errors.generic'));
} else {
toast.error(t('errors.generic'));
}
}
};
return (
<div className="space-y-6 max-w-lg">
<h1 className="text-2xl font-semibold" data-testid="profile-title">
{t('profile.title')}
</h1>
{/* Profile info card */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('profile.title')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">{t('profile.name')}</Label>
<Input
id="name"
data-testid="profile-name-input"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive" data-testid="profile-name-error">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="email">{t('profile.email')}</Label>
<Input
id="email"
type="email"
data-testid="profile-email-input"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive" data-testid="profile-email-error">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-1">
<Label>{t('profile.role')}</Label>
<div>
{user?.role
? <RoleBadge role={user.role} data-testid="profile-role-badge" />
: <span data-testid="profile-role-badge"></span>}
</div>
</div>
<Button
type="submit"
disabled={isSubmitting || !isDirty}
data-testid="profile-save-button"
>
{isSubmitting ? t('profile.saving') : t('profile.save')}
</Button>
</form>
</CardContent>
</Card>
{/* Security card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Security</CardTitle>
</CardHeader>
<CardContent>
<Button
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
data-testid="change-password-button"
>
{t('profile.changePassword')}
</Button>
</CardContent>
</Card>
<ChangePasswordDialog
open={passwordDialogOpen}
onClose={() => setPasswordDialogOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,86 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createElement } from 'react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useUpdateProfile, useChangePassword } from '@/features/profile/services/useProfile';
import { AuthProvider } from '@/features/auth/context/AuthProvider';
import i18n from '@/i18n/config';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: ReactNode }) =>
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
I18nextProvider,
{ i18n },
createElement(AuthProvider, null, children),
),
);
}
describe('useUpdateProfile', () => {
it('succeeds and returns updated user', async () => {
const { result } = renderHook(() => useUpdateProfile(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ name: 'New Name', email: 'new@example.com' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('New Name');
});
it('enters error state on server failure', async () => {
server.use(
http.put(`${API_BASE}/api/v1/Users/me`, () =>
HttpResponse.json({ title: 'Bad Request', status: 400 }, { status: 400 }),
),
);
const { result } = renderHook(() => useUpdateProfile(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ name: 'Bad', email: 'bad@example.com' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useChangePassword', () => {
it('succeeds with correct current password', async () => {
const { result } = renderHook(() => useChangePassword(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
currentPassword: 'Password123!',
newPassword: 'NewPassword1!',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('enters error state when current password is wrong', async () => {
const { result } = renderHook(() => useChangePassword(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ currentPassword: 'WrongPassword!', newPassword: 'NewPassword1!' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
@@ -0,0 +1,31 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { useAuth } from '@/features/auth/context/auth-context';
import type { UserListItem } from '@/features/users/services/types';
export interface UpdateProfilePayload {
name: string;
email: string;
}
export interface ChangePasswordPayload {
currentPassword: string;
newPassword: string;
}
export function useUpdateProfile() {
const { refresh } = useAuth();
return useMutation<UserListItem, Error, UpdateProfilePayload>({
mutationFn: (data) => api.put<UserListItem>('/api/v1/Users/me', data),
onSuccess: () => {
// Sync AuthContext with updated name/email via token refresh
void refresh();
},
});
}
export function useChangePassword() {
return useMutation<void, Error, ChangePasswordPayload>({
mutationFn: (data) => api.post<void>('/api/v1/Auth/change-password', data),
});
}
@@ -0,0 +1,136 @@
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, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('SettingsPage', () => {
it('renders the page title', async () => {
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('settings-title', {}, { timeout: 10000 })).toBeInTheDocument();
});
it('shows the current availability status badge', async () => {
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('availability-badge', {}, { timeout: 10000 })).toBeInTheDocument();
});
it('renders all three availability mode buttons', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 });
expect(screen.getByTestId('mode-option-Available')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument();
});
it('selecting a mode highlights that mode button', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 });
const maintenanceBtn = screen.getByTestId('mode-option-Maintenance');
await userEvent.click(maintenanceBtn);
expect(maintenanceBtn.className).toContain('bg-primary');
});
it('saves availability and shows success toast', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/availability updated/i, {}, { timeout: 10000 })).toBeInTheDocument();
});
it('shows error toast when save fails', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Availability/admin/status`, () =>
HttpResponse.json({ title: 'Error' }, { status: 500 }),
),
);
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 10000 })).toBeInTheDocument();
});
it('disables the availability controls and shows a banner when master-controlled', async () => {
server.use(
http.get(`${API_BASE}/api/v1/Availability/status`, () =>
HttpResponse.json({
status: 'NotAvailable',
checkedAt: new Date().toISOString(),
message: 'Uitgeschakeld door master',
isMasterControlled: true,
}),
),
);
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('mode-option-Available')).toBeDisabled();
expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled();
expect(screen.getByTestId('availability-reason-input')).toBeDisabled();
expect(screen.getByTestId('availability-save-button')).toBeDisabled();
});
it('does not show the master-controlled banner when not master-controlled', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument();
expect(screen.getByTestId('availability-save-button')).not.toBeDisabled();
});
it('shows a master-controlled error toast on a 409 conflict from the server', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Availability/admin/status`, () =>
HttpResponse.json({ title: 'Conflict' }, { status: 409 }),
),
);
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 10000 })).toBeInTheDocument();
});
it('renders all placeholder sections', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('settings-title', {}, { timeout: 10000 });
expect(screen.getByTestId('placeholder-settings.modules.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/settings');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAvailabilityStatus, useUpdateAvailability } from '@/features/availability/services/useAvailability';
import { AvailabilityStatusBadge } from '@/features/availability/components/AvailabilityStatusBadge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ProblemDetailsError } from '@/lib/api-client';
import type { AvailabilityStatus } from '@/features/availability/services/types';
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
const { t } = useTranslation();
return (
<Card className="opacity-60">
<CardHeader className="flex flex-row items-center gap-2">
<Lock className="size-4 text-muted-foreground" />
<CardTitle className="text-base">{t(titleKey)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground" data-testid={`placeholder-${titleKey}`}>
{t(comingSoonKey)}
</p>
</CardContent>
</Card>
);
}
export function SettingsPage() {
const { t } = useTranslation();
const { data: availability, isLoading } = useAvailabilityStatus();
const updateAvailability = useUpdateAvailability();
const [selectedMode, setSelectedMode] = useState<AvailabilityStatus>('Available');
const [reason, setReason] = useState('');
// Adjusts local edit state when the fetched availability changes, without an Effect
// (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
const [syncedAvailability, setSyncedAvailability] = useState(availability);
if (availability !== syncedAvailability) {
setSyncedAvailability(availability);
if (availability) {
setSelectedMode(availability.status);
setReason(availability.message ?? '');
}
}
const isMasterControlled = availability?.isMasterControlled ?? false;
const handleSave = async () => {
try {
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
toast.success(t('settings.availability.saveSuccess'));
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 409) {
toast.error(t('settings.availability.masterControlledSaveError'));
} else {
toast.error(t('errors.generic'));
}
}
};
const modes: AvailabilityStatus[] = ['Available', 'Maintenance', 'NotAvailable'];
return (
<div className="space-y-6 max-w-xl">
<h1 className="text-2xl font-semibold" data-testid="settings-title">
{t('settings.title')}
</h1>
{/* Availability section */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.availability.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : availability ? (
<AvailabilityStatusBadge
status={availability.status}
message={availability.message}
/>
) : null}
{isMasterControlled && (
<div
data-testid="availability-master-controlled-banner"
className="flex items-start gap-2 rounded-md border border-amber-400/50 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
<Lock className="size-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('settings.availability.masterControlledTitle')}</p>
<p>{t('settings.availability.masterControlled')}</p>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('settings.availability.mode')}</Label>
<div className="flex gap-2 flex-wrap" data-testid="availability-mode-selector">
{modes.map((mode) => (
<button
key={mode}
type="button"
onClick={() => setSelectedMode(mode)}
disabled={isMasterControlled}
data-testid={`mode-option-${mode}`}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
selectedMode === mode
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background hover:bg-accent'
}`}
>
{t(`settings.availability.modes.${mode}`)}
</button>
))}
</div>
</div>
<div className="space-y-1">
<Label htmlFor="availability-reason">
{t('settings.availability.reason')}
</Label>
<textarea
id="availability-reason"
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={isMasterControlled}
data-testid="availability-reason-input"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('settings.availability.reason')}
/>
</div>
<Button
onClick={handleSave}
disabled={updateAvailability.isPending || isMasterControlled}
data-testid="availability-save-button"
>
{updateAvailability.isPending
? t('settings.availability.saving')
: t('settings.availability.save')}
</Button>
</CardContent>
</Card>
<PlaceholderCard
titleKey="settings.modules.title"
comingSoonKey="settings.modules.comingSoon"
/>
<PlaceholderCard
titleKey="settings.systemConfig.title"
comingSoonKey="settings.systemConfig.comingSoon"
/>
<PlaceholderCard
titleKey="settings.branding.title"
comingSoonKey="settings.branding.comingSoon"
/>
</div>
);
}
@@ -0,0 +1,61 @@
import { http, HttpResponse } from 'msw';
import type { SetupStatus } from '@/features/setup/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
/** Setup status mock (used by public bootstrap guards). */
export const setupHandlers = [
http.get(`${API_BASE}/api/v1/Setup/status`, () =>
HttpResponse.json<SetupStatus>({ initialized: true }),
),
http.post(`${API_BASE}/api/v1/Setup/owner`, async ({ request }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = await request.json() as any;
if (!body.email || !body.password || !body.name) {
return HttpResponse.json(
{
type: 'about:blank',
title: 'Bad Request',
status: 400,
detail: 'Missing required fields'
},
{ status: 400 }
);
}
return HttpResponse.json(
{
message: 'System initialized',
user: {
id: '1',
name: body.name,
email: body.email,
role: 'Owner'
}
},
{ status: 201 }
);
}),
];
/** Override: simulates network error on setup status — backend unreachable. */
export const setupNetworkErrorHandlers = [
http.get(`${API_BASE}/api/v1/Setup/status`, () => HttpResponse.error()),
];
/** Override: returns not-initialized status — use in tests for InitGuard. */
export const setupUninitializedHandlers = [
http.get(`${API_BASE}/api/v1/Setup/status`, () =>
HttpResponse.json<SetupStatus>({ initialized: false }),
),
];
/** Override: POST /Setup/owner returns 409 — system already initialized. */
export const setupConflictHandlers = [
...setupUninitializedHandlers,
http.post(`${API_BASE}/api/v1/Setup/owner`, () =>
HttpResponse.json(
{ title: 'Conflict', detail: 'System has already been initialized.', status: 409 },
{ status: 409 },
),
),
];
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { setupUninitializedHandlers, setupConflictHandlers } from '@/mocks/index';
import { _resetSetupStatusCache } from '@/router';
// Each test starts with a fresh setup status cache so the MSW handler controls
// what the InitGuard fetches (BR-U2-08).
beforeEach(() => {
_resetSetupStatusCache();
});
describe('SetupPage', () => {
it('renders all five form fields when system is uninitialized', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
renderApp('/setup');
expect(await screen.findByTestId('setup-name-input')).toBeInTheDocument();
expect(screen.getByTestId('setup-email-input')).toBeInTheDocument();
expect(screen.getByTestId('setup-password-input')).toBeInTheDocument();
expect(screen.getByTestId('setup-confirmPassword-input')).toBeInTheDocument();
expect(screen.getByTestId('setup-locale-select')).toBeInTheDocument();
expect(screen.getByTestId('setup-submit-button')).toBeInTheDocument();
});
it('shows inline validation errors when submitting an empty form', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/setup');
const submit = await screen.findByTestId('setup-submit-button');
await user.click(submit);
expect(await screen.findByTestId('setup-name-error')).toBeInTheDocument();
expect(screen.getByTestId('setup-email-error')).toBeInTheDocument();
expect(screen.getByTestId('setup-password-error')).toBeInTheDocument();
});
it('shows password error for a weak password (no uppercase)', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/setup');
const passwordInput = await screen.findByTestId('setup-password-input');
await user.type(passwordInput, 'weakpassword1!');
await user.tab();
expect(await screen.findByTestId('setup-password-error')).toBeInTheDocument();
});
it('shows confirm-password error when passwords do not match', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/setup');
const passwordInput = await screen.findByTestId('setup-password-input');
const confirmInput = screen.getByTestId('setup-confirmPassword-input');
await user.type(passwordInput, 'ValidPass1!');
await user.type(confirmInput, 'DifferentPass1!');
await user.tab();
expect(await screen.findByTestId('setup-confirm-error')).toBeInTheDocument();
});
it('shows success message after a valid submission', async () => {
server.use(...setupUninitializedHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/setup');
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
await user.click(screen.getByTestId('setup-submit-button'));
expect(await screen.findByTestId('setup-success')).toBeInTheDocument();
});
it('shows "already initialized" banner on 409 response', async () => {
server.use(...setupConflictHandlers);
mockGuest();
const user = userEvent.setup();
renderApp('/setup');
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
await user.click(screen.getByTestId('setup-submit-button'));
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
});
it('redirects to /login when system is already initialized', async () => {
// Default setupHandlers return { initialized: true } — InitGuard redirects.
mockGuest();
renderApp('/setup');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,186 @@
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { setupFormSchema, type SetupFormData } from '@/features/auth/schemas/auth';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useSetup } from '@/features/setup/services/useSetup';
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
import { changeLanguage } from '@/i18n/config';
import { _markSystemInitialized } from '@/router';
interface FormError {
message: string;
}
export function SetupPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [serverError, setServerError] = useState<FormError | null>(null);
const [successMessage, setSuccessMessage] = useState(false);
const setupMutation = useSetup();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
control
} = useForm<SetupFormData>({
resolver: zodResolver(setupFormSchema),
mode: 'onBlur',
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
language: 'nl'
}
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
const { confirmPassword: _, ...payload } = values;
await setupMutation.mutateAsync(payload);
await changeLanguage(values.language);
_markSystemInitialized();
setSuccessMessage(true);
setTimeout(() => {
navigate({ to: '/login' });
}, 2000);
} catch (err) {
if (err instanceof ProblemDetailsError) {
setServerError({ message: err.problem.detail || err.message || t('errors.generic') });
} else if (err instanceof NetworkError) {
setServerError({ message: t('errors.network') });
} else {
setServerError({ message: t('errors.generic') });
}
}
});
if (successMessage) {
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="setup-success">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="text-green-600 text-lg font-medium">
{t('setup.successMessage')}
</div>
<p className="text-sm text-muted-foreground">
{t('login.subtitle')}
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('setup.title')}</CardTitle>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} noValidate className="space-y-4" data-testid="setup-form">
<FormErrorBanner
error={serverError || (setupMutation.error ? { message: setupMutation.error.message } : null)}
onDismiss={() => setServerError(null)}
/>
<div className="space-y-2">
<Label htmlFor="name">{t('setup.nameLabel')}</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
data-testid="setup-name-input"
aria-invalid={errors.name !== undefined}
{...register('name')}
/>
{errors.name && <FieldError message={errors.name.message} testId="setup-name-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('setup.emailLabel')}</Label>
<Input
id="email"
type="email"
placeholder="owner@example.com"
data-testid="setup-email-input"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && <FieldError message={errors.email.message} testId="setup-email-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('setup.passwordLabel')}</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
data-testid="setup-password-input"
aria-invalid={errors.password !== undefined}
{...register('password')}
/>
{errors.password && <FieldError message={errors.password.message} testId="setup-password-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t('setup.confirmPasswordLabel')}</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
data-testid="setup-confirmPassword-input"
aria-invalid={errors.confirmPassword !== undefined}
{...register('confirmPassword')}
/>
{errors.confirmPassword && <FieldError message={errors.confirmPassword.message} testId="setup-confirm-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="language">{t('setup.languageLabel')}</Label>
<Controller
name="language"
control={control}
render={({ field }) => (
<select
{...field}
id="language"
data-testid="setup-locale-select"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="nl">Nederlands</option>
<option value="en">English</option>
</select>
)}
/>
</div>
<Button
type="submit"
disabled={isSubmitting || setupMutation.isPending}
className="w-full"
data-testid="setup-submit-button"
>
{isSubmitting || setupMutation.isPending ? '...' : t('setup.submitButton')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,10 @@
// Setup status (used by guards during bootstrap).
export interface SetupStatus {
initialized: boolean;
}
export interface SetupRequest {
name: string;
email: string;
password: string;
}
@@ -0,0 +1,78 @@
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useInitGuard, _resetSetupStatusCache } from '@/features/setup/services/useInitGuard';
const SETUP_STATUS_URL = `${API_BASE}/Setup/status`;
beforeEach(() => {
_resetSetupStatusCache();
});
describe('useInitGuard', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useInitGuard());
expect(result.current.isLoading).toBe(true);
expect(result.current.initialized).toBeNull();
expect(result.current.error).toBeNull();
});
it('returns initialized=true when system is set up', async () => {
server.use(
http.get(SETUP_STATUS_URL, () => HttpResponse.json({ initialized: true })),
);
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.initialized).toBe(true);
expect(result.current.error).toBeNull();
});
it('returns initialized=false when system is not yet set up', async () => {
server.use(
http.get(SETUP_STATUS_URL, () => HttpResponse.json({ initialized: false })),
);
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.initialized).toBe(false);
expect(result.current.error).toBeNull();
});
it('sets error when the API request fails', async () => {
server.use(http.get(SETUP_STATUS_URL, () => HttpResponse.error()));
const { result } = renderHook(() => useInitGuard());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).not.toBeNull();
expect(result.current.initialized).toBeNull();
});
it('uses cached result on second call without re-fetching', async () => {
let callCount = 0;
server.use(
http.get(SETUP_STATUS_URL, () => {
callCount += 1;
return HttpResponse.json({ initialized: true });
}),
);
const { result: first } = renderHook(() => useInitGuard());
await waitFor(() => expect(first.current.isLoading).toBe(false));
const { result: second } = renderHook(() => useInitGuard());
await waitFor(() => expect(second.current.isLoading).toBe(false));
expect(callCount).toBe(1); // Only one fetch due to cache
expect(second.current.initialized).toBe(true);
});
});
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { api } from '@/lib/api-client';
interface SetupStatus {
initialized: boolean;
createdAt?: string;
}
let _setupStatusCache: SetupStatus | null = null;
let _setupStatusPromise: Promise<SetupStatus> | null = null;
export function useInitGuard() {
const [initialized, setInitialized] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchSetupStatus = async () => {
if (_setupStatusCache !== null) {
setInitialized(_setupStatusCache.initialized);
setIsLoading(false);
return;
}
if (_setupStatusPromise === null) {
_setupStatusPromise = api
.get<SetupStatus>('/Setup/status')
.then((s) => {
_setupStatusCache = s;
return s;
})
.catch((err) => {
_setupStatusPromise = null;
throw err;
});
}
try {
const status = await _setupStatusPromise;
setInitialized(status.initialized);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
setInitialized(null);
} finally {
setIsLoading(false);
}
};
fetchSetupStatus();
}, []);
return {
initialized,
isLoading,
error
};
}
export function _resetSetupStatusCache(): void {
_setupStatusCache = null;
_setupStatusPromise = null;
}
@@ -0,0 +1,91 @@
import { act, renderHook } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { useSetup } from '@/features/setup/services/useSetup';
const validSetupData = {
name: 'Test Owner',
email: 'owner@example.com',
password: 'Password1!',
language: 'nl' as const,
};
describe('useSetup', () => {
it('starts with isPending=false and no error', () => {
const { result } = renderHook(() => useSetup());
expect(result.current.isPending).toBe(false);
expect(result.current.error).toBeNull();
});
it('returns setup response on success', async () => {
const { result } = renderHook(() => useSetup());
let response: Awaited<ReturnType<typeof result.current.mutateAsync>> | undefined;
await act(async () => {
response = await result.current.mutateAsync(validSetupData);
});
expect(response?.message).toBe('System initialized');
expect(response?.user.role).toBe('Owner');
expect(result.current.isPending).toBe(false);
expect(result.current.error).toBeNull();
});
it('sets error and rethrows when API fails', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Setup/owner`, () =>
HttpResponse.json(
{ title: 'Conflict', detail: 'Already initialized', status: 409 },
{ status: 409 },
),
),
);
const { result } = renderHook(() => useSetup());
let thrown: Error | undefined;
await act(async () => {
try {
await result.current.mutateAsync(validSetupData);
} catch (err) {
thrown = err as Error;
}
});
expect(thrown).toBeDefined();
expect(result.current.error).not.toBeNull();
expect(result.current.isPending).toBe(false);
});
it('resets error state on subsequent successful call', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Setup/owner`, () =>
HttpResponse.json({ title: 'Error', status: 500 }, { status: 500 }),
),
);
const { result } = renderHook(() => useSetup());
await act(async () => {
try {
await result.current.mutateAsync(validSetupData);
} catch {
// expected
}
});
expect(result.current.error).not.toBeNull();
// Restore success handler and retry
server.resetHandlers();
await act(async () => {
await result.current.mutateAsync(validSetupData);
});
expect(result.current.error).toBeNull();
});
});
@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react';
import { api } from '@/lib/api-client';
interface SetupRequest {
name: string;
email: string;
password: string;
language: 'en' | 'nl';
}
interface SetupResponse {
message: string;
user: {
id: string;
name: string;
email: string;
role: string;
};
}
export function useSetup() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<Error | null>(null);
const mutateAsync = useCallback(async (data: SetupRequest): Promise<SetupResponse> => {
setIsPending(true);
setError(null);
try {
const response = await api.post('/api/v1/Setup/owner', data);
return response as SetupResponse;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
throw error;
} finally {
setIsPending(false);
}
}, []);
return {
mutateAsync,
isPending,
error
};
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSystemCapabilities } from '@/features/system/services/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>
);
}
@@ -0,0 +1,9 @@
import { http, HttpResponse } from 'msw';
export const systemHandlers = [
http.get('*/System/capabilities', () =>
HttpResponse.json({
modules: ['Availability', 'Identity', 'Master'],
}),
),
];
@@ -0,0 +1,6 @@
// Which optional modules (e.g. "Master") this backend instance has loaded —
// lets the frontend tell a master-only feature apart from a slave instance
// without that module (local master/slave dev setup).
export interface SystemCapabilities {
modules: string[];
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { SystemCapabilities } from './types';
// Backend may return PascalCase (Modules) if no camelCase policy is set.
type RawSystemCapabilities = { modules?: string[]; Modules?: string[] };
export function useSystemCapabilities() {
return useQuery<SystemCapabilities, Error>({
queryKey: ['system', 'capabilities'],
queryFn: async () => {
const raw = await api.get<RawSystemCapabilities>('/api/v1/System/capabilities');
return { modules: raw.modules ?? raw.Modules ?? [] };
},
// Which modules a backend has loaded is fixed for the lifetime of that
// backend process — no need to ever refetch within a session.
staleTime: Infinity,
});
}
@@ -0,0 +1,63 @@
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 '@/features/users/services/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>
);
}
@@ -0,0 +1,118 @@
import { http, HttpResponse } from 'msw';
import type { UserListItem } from '@/features/users/services/types';
import { API_BASE, mockUser } from '@/features/auth/mocks/fixtures';
let mockUsers: UserListItem[] = [
{
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
},
];
export const resetMockUsers = () => {
mockUsers = [
{
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
},
];
};
export const getMockUsers = () => mockUsers;
export const userHandlers = [
http.get(`${API_BASE}/api/v1/Users`, () => HttpResponse.json(mockUsers)),
http.post(`${API_BASE}/api/v1/Users/invite`, async ({ request }) => {
const body = (await request.json()) as { email: string; role: string };
const token = `mock-token-${Date.now()}`;
const inviteLink = `/invite/complete?token=${token}`;
const newUser: UserListItem = {
id: crypto.randomUUID(),
email: body.email,
name: body.email,
role: body.role as UserListItem['role'],
isActive: true,
createdAt: new Date().toISOString(),
invitationPending: true,
inviteLink,
};
mockUsers = [...mockUsers, newUser];
return HttpResponse.json({ inviteLink });
}),
http.put(`${API_BASE}/api/v1/Users/:userId/role`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/:userId/active`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/me`, async ({ request }) => {
const body = (await request.json()) as { name: string; email: string };
const owner = mockUsers.find((u) => u.role === 'Owner') ?? mockUsers[0];
const updated: UserListItem = {
...owner,
name: body.name,
email: body.email,
};
mockUsers = mockUsers.map((u) => (u.id === owner.id ? updated : u));
return HttpResponse.json(updated);
}),
http.delete(`${API_BASE}/api/v1/Users/:userId`, ({ params }) => {
const { userId } = params as { userId: string };
const ownerUser = mockUsers.find((u) => u.role === 'Owner');
if (userId === ownerUser?.id) {
return HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
);
}
mockUsers = mockUsers.filter((u) => u.id !== userId);
return new HttpResponse(null, { status: 204 });
}),
];
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, within } 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 '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('UsersPage', () => {
it('renders the page title and invite button', async () => {
mockAuthenticated();
renderApp('/users');
expect(await screen.findByTestId('users-page', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('users-invite-button')).toBeInTheDocument();
});
it('renders the users table with all mock users', async () => {
mockAuthenticated();
renderApp('/users');
const table = await screen.findByTestId('users-table');
expect(table).toBeInTheDocument();
for (const user of getMockUsers()) {
expect(screen.getByTestId(`user-row-${user.id}`)).toBeInTheDocument();
}
});
it('shows "Pending invite" badge for invitation-pending users', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const row = screen.getByTestId(`user-row-${pendingUser.id}`);
expect(within(row).getByText('Pending invite')).toBeInTheDocument();
});
it('shows copy invite link action for pending user', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const actionsBtn = screen.getByTestId(`user-actions-${pendingUser.id}`);
await userEvent.click(actionsBtn);
expect(screen.getByTestId(`user-copy-link-${pendingUser.id}`)).toBeInTheDocument();
});
it('shows an error message when the API fails', async () => {
mockAuthenticated();
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
renderApp('/users');
await screen.findByTestId('users-page');
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
it('opens the invite dialog when the invite button is clicked', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-invite-button');
await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
});
});
@@ -0,0 +1,441 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { MoreHorizontal, Link as LinkIcon, Trash2, UserX, UserCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/features/invitation/components/InviteUserDialog';
import { DeleteUserDialog } from '@/features/users/components/DeleteUserDialog';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/features/users/services/useUsers';
import { useAuth } from '@/features/auth/context/auth-context';
import { Trans } from 'react-i18next';
import { ProblemDetailsError } from '@/lib/api-client';
import type { UserRole } from '@/features/auth/services/types';
import type { UserListItem } from '@/features/users/services/types';
function statusBadgeVariant(user: UserListItem) {
if (user.invitationPending) return 'outline';
if (!user.isActive) return 'destructive';
return 'secondary';
}
function statusLabel(user: UserListItem, t: (key: string) => string) {
if (user.invitationPending) return t('users.status.pendingInvite');
if (!user.isActive) return t('users.status.inactive');
return t('users.status.active');
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' });
}
export function UsersPage() {
const { t } = useTranslation();
const { user: currentUser, logout } = useAuth();
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserListItem | null>(null);
const [selfRoleChangeTarget, setSelfRoleChangeTarget] = useState<{
userId: string;
newRole: UserRole;
} | null>(null);
const [ownerAssignTarget, setOwnerAssignTarget] = useState<{
userId: string;
userName: string;
} | null>(null);
const { data: users, isPending, isError } = useUsers();
const changeRole = useChangeRole();
const setUserActive = useSetUserActive();
const deleteUser = useDeleteUser();
const availableRolesFor = (user: UserListItem): UserRole[] => {
const isSelf = user.id === currentUser?.id;
if (currentUser?.role === 'Owner') return ['Owner', 'Administrator', 'User'];
if (currentUser?.role === 'Administrator') {
// Admins can demote themselves to User; they cannot touch other Admins or Owners
if (isSelf) return ['Administrator', 'User'];
if (user.role === 'User') return ['Administrator'];
}
return [];
};
const isOwnerAction = (user: UserListItem) =>
currentUser?.role === 'Owner' && user.id !== currentUser.id;
const canToggleActive = (user: UserListItem) =>
isOwnerAction(user) && !user.invitationPending;
const canDelete = (user: UserListItem) => isOwnerAction(user);
const hasAnyActions = (user: UserListItem) =>
user.invitationPending ||
availableRolesFor(user).length > 0 ||
canToggleActive(user) ||
canDelete(user);
const handleCopyInviteLink = async (user: UserListItem) => {
if (!user.inviteLink) return;
await navigator.clipboard.writeText(`${window.location.origin}${user.inviteLink}`);
toast.success(t('users.actions.linkCopied'));
};
const handleRoleChange = (user: UserListItem, newRole: UserRole) => {
if (user.id === currentUser?.id) {
setSelfRoleChangeTarget({ userId: user.id, newRole });
return;
}
if (newRole === 'Owner') {
setOwnerAssignTarget({ userId: user.id, userName: user.name });
return;
}
void executeRoleChange(user.id, newRole);
};
const executeRoleChange = async (userId: string, newRole: UserRole) => {
try {
await changeRole.mutateAsync({ userId, newRole });
} catch (err) {
if (err instanceof ProblemDetailsError) {
if (err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSelfRoleChangeConfirm = async () => {
if (!selfRoleChangeTarget) return;
try {
await changeRole.mutateAsync(selfRoleChangeTarget);
setSelfRoleChangeTarget(null);
await logout();
} catch (err) {
setSelfRoleChangeTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleOwnerAssignConfirm = async () => {
if (!ownerAssignTarget) return;
try {
await changeRole.mutateAsync({ userId: ownerAssignTarget.userId, newRole: 'Owner' });
setOwnerAssignTarget(null);
} catch (err) {
setOwnerAssignTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err instanceof ProblemDetailsError && err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSetActive = async (user: UserListItem, isActive: boolean) => {
try {
await setUserActive.mutateAsync({ userId: user.id, isActive });
toast.success(
isActive
? t('users.active.successActivated', { name: user.name })
: t('users.active.successDeactivated', { name: user.name }),
);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await deleteUser.mutateAsync(deleteTarget.id);
toast.success(t('users.delete.success', { name: deleteTarget.name }));
setDeleteTarget(null);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
setDeleteTarget(null);
} else {
toast.error(t('errors.generic'));
}
}
};
return (
<div className="space-y-4" data-testid="users-page">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold" data-testid="users-title">
{t('users.title')}
</h1>
<Button onClick={() => setInviteDialogOpen(true)} data-testid="users-invite-button">
{t('users.inviteButton')}
</Button>
</div>
{isPending && (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
)}
{isError && (
<p className="text-sm text-destructive">{t('errors.generic')}</p>
)}
{users && (
<Table data-testid="users-table">
<TableHeader>
<TableRow>
<TableHead>{t('users.table.name')}</TableHead>
<TableHead>{t('users.table.email')}</TableHead>
<TableHead>{t('users.table.role')}</TableHead>
<TableHead>{t('users.table.status')}</TableHead>
<TableHead>{t('users.table.createdAt')}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const roles = availableRolesFor(user);
const toggleActive = canToggleActive(user);
const deletable = canDelete(user);
const showActions = hasAnyActions(user);
return (
<TableRow key={user.id} data-testid={`user-row-${user.id}`}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<RoleBadge role={user.role} />
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(user)}>
{statusLabel(user, t)}
</Badge>
</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell>
{showActions && <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('users.table.actions')}
data-testid={`user-actions-${user.id}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t('users.table.actions')}
</DropdownMenuLabel>
{user.invitationPending && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleCopyInviteLink(user)}
data-testid={`user-copy-link-${user.id}`}
>
<LinkIcon className="mr-2 h-4 w-4" />
{t('users.actions.copyInviteLink')}
</DropdownMenuItem>
</>
)}
{roles.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={`user-change-role-${user.id}`}
>
{t('users.actions.changeRole')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{roles.map((role) => (
<DropdownMenuItem
key={role}
disabled={role === user.role}
onClick={() =>
handleRoleChange(user, role)
}
>
{t(`users.roles.${role}`)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{toggleActive && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleSetActive(user, !user.isActive)
}
data-testid={`user-toggle-active-${user.id}`}
>
{user.isActive ? (
<>
<UserX className="mr-2 h-4 w-4" />
{t('users.actions.deactivate')}
</>
) : (
<>
<UserCheck className="mr-2 h-4 w-4" />
{t('users.actions.activate')}
</>
)}
</DropdownMenuItem>
</>
)}
{deletable && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(user)}
data-testid={`user-delete-${user.id}`}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('users.actions.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<InviteUserDialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen} />
<Dialog
open={selfRoleChangeTarget !== null}
onOpenChange={(open) => { if (!open) setSelfRoleChangeTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.selfRoleChange.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.selfRoleChange.description"
values={{
role: selfRoleChangeTarget
? t(`users.roles.${selfRoleChangeTarget.newRole}`)
: '',
}}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSelfRoleChangeTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSelfRoleChangeConfirm}
disabled={changeRole.isPending}
>
{t('users.selfRoleChange.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={ownerAssignTarget !== null}
onOpenChange={(open) => { if (!open) setOwnerAssignTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.ownerAssign.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.ownerAssign.description"
values={{ name: ownerAssignTarget?.userName ?? '' }}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOwnerAssignTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleOwnerAssignConfirm}
disabled={changeRole.isPending}
>
{t('users.ownerAssign.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteUserDialog
user={deleteTarget}
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm}
isPending={deleteUser.isPending}
/>
</div>
);
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { inviteUserSchema } from './users';
describe('inviteUserSchema', () => {
it('accepts valid email and role', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Administrator' });
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = inviteUserSchema.safeParse({ email: 'not-an-email', role: 'User' });
expect(result.success).toBe(false);
});
it('rejects missing role', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com' });
expect(result.success).toBe(false);
});
it('rejects invalid role value', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Owner' });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const inviteUserSchema = z.object({
email: z.string().email(),
role: z.enum(['Administrator', 'User']),
});
export type InviteUserFormData = z.infer<typeof inviteUserSchema>;
@@ -0,0 +1,20 @@
import type { User, UserRole } from '@/features/auth/services/types';
export interface UserListItem extends User {
createdAt: string;
invitationPending: boolean;
inviteLink: string | null;
}
export interface InviteUserPayload {
email: string;
role: Exclude<UserRole, 'Owner'>;
}
export interface InviteUserResponse {
inviteLink: string;
}
export interface ChangeRolePayload {
newRole: UserRole;
}
@@ -0,0 +1,168 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { useUsers, useInviteUser, useChangeRole, useSetUserActive, useDeleteUser } from './useUsers';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('useUsers', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
expect(result.current.isPending).toBe(true);
});
it('returns the users list on success', async () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toHaveLength(getMockUsers().length);
expect(result.current.data?.[0].email).toBe(getMockUsers()[0].email);
});
it('enters error state on 5xx', async () => {
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
describe('useInviteUser', () => {
it('returns inviteLink on successful mutation', async () => {
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'new@example.com', role: 'User' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.inviteLink).toContain('/invite/complete');
});
it('fails when server returns 400', async () => {
server.use(
http.post(`${USERS_URL}/invite`, () =>
HttpResponse.json({ detail: 'already exists' }, { status: 400 }),
),
);
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'dup@example.com', role: 'User' }).catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useChangeRole', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
newRole: 'User',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 403', async () => {
server.use(
http.put(`${USERS_URL}/:userId/role`, () => new HttpResponse(null, { status: 403 })),
);
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', newRole: 'Administrator' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useSetUserActive', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
isActive: false,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400', async () => {
server.use(
http.put(`${USERS_URL}/:userId/active`, () => new HttpResponse(null, { status: 400 })),
);
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', isActive: false })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useDeleteUser', () => {
it('succeeds when server returns 204', async () => {
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync('22222222-2222-2222-2222-222222222222');
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400 (last owner)', async () => {
server.use(
http.delete(`${USERS_URL}/:userId`, () =>
HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
),
),
);
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync('11111111-1111-1111-1111-111111111111')
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
@@ -0,0 +1,49 @@
import { useQuery, useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload } from './types';
import type { UserRole } from '@/features/auth/services/types';
export function useUsers() {
return useQuery<UserListItem[], Error>({
queryKey: ['users'],
queryFn: () => api.get<UserListItem[]>('/api/v1/Users'),
staleTime: 30_000,
});
}
export function useInviteUser(
options?: Pick<UseMutationOptions<InviteUserResponse, Error, InviteUserPayload>, 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<InviteUserResponse, Error, InviteUserPayload>({
mutationFn: (data) => api.post<InviteUserResponse>('/api/v1/Users/invite', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
...options,
});
}
export function useChangeRole() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; newRole: UserRole }>({
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
api.put<void>(`/api/v1/Users/${userId}/role`, { newRole } satisfies ChangeRolePayload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useSetUserActive() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; isActive: boolean }>({
mutationFn: ({ userId, isActive }) =>
api.put<void>(`/api/v1/Users/${userId}/active`, { isActive }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId: string) => api.delete<void>(`/api/v1/Users/${userId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}