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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user