Adds profile and settings pages

This commit is contained in:
2026-06-22 23:59:04 +02:00
parent 6976eb4337
commit 2544e20b3c
49 changed files with 2741 additions and 34 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ export interface ChangeRolePayload {
newRole: UserRole;
}
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export interface AvailabilityResponse {
status: AvailabilityStatus;
+15 -2
View File
@@ -1,6 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { AvailabilityResponse } from './types';
import type { AvailabilityResponse, AvailabilityStatus } from './types';
export interface UpdateAvailabilityPayload {
newStatus: AvailabilityStatus;
reason: string;
}
export function useAvailabilityStatus() {
return useQuery<AvailabilityResponse, Error>({
@@ -9,3 +14,11 @@ export function useAvailabilityStatus() {
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'] }),
});
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { useAuth } from '@/contexts/auth-context';
import type { UserListItem } from './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),
});
}
+13 -1
View File
@@ -1,13 +1,18 @@
import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router';
import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { Sidebar } from '@/components/layout/Sidebar';
import { MobileBar } from '@/components/layout/MobileBar';
import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
export function AppLayout() {
const { isAuthenticated, status } = useAuth();
const navigate = useNavigate();
const { location } = useRouterState();
const { data: availability } = useAvailabilityStatus();
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
@@ -16,6 +21,13 @@ export function AppLayout() {
}
}, [status, navigate]);
useEffect(() => {
if (availability?.status === 'NotAvailable') {
const allowed = ALLOWED_WHEN_UNAVAILABLE.some(p => location.pathname.startsWith(p));
if (!allowed) void navigate({ to: '/dashboard' });
}
}, [availability?.status, location.pathname, navigate]);
if (!isAuthenticated) {
return null;
}
+40 -1
View File
@@ -4,6 +4,7 @@ import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
@@ -18,13 +19,18 @@ interface NavItem {
roles?: Role[];
}
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
];
const SETTINGS_ITEM: NavItem = {
to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'],
};
interface SidebarProps {
onClose?: () => void;
}
@@ -32,11 +38,14 @@ interface SidebarProps {
export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation();
const { user } = useAuth();
const { data: availability } = useAvailabilityStatus();
const role = user?.role as Role | undefined;
const systemUnavailable = availability?.status === 'NotAvailable';
const visibleItems = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role))
);
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
return (
<aside
@@ -64,6 +73,19 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
<nav className="flex-1 space-y-1 p-3">
{visibleItems.map((item) => {
const Icon = item.icon;
const blocked = systemUnavailable && !ALLOWED_WHEN_UNAVAILABLE.some(p => item.to.startsWith(p));
if (blocked) {
return (
<span
key={item.to}
data-testid={item.testId}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium opacity-40 select-none"
>
<Icon className="size-4" />
{t(item.labelKey)}
</span>
);
}
return (
<Link
key={item.to}
@@ -82,6 +104,23 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
})}
</nav>
{showSettings && (
<div className="border-t border-border p-3">
<Link
to={SETTINGS_ITEM.to}
data-testid={SETTINGS_ITEM.testId}
onClick={onClose}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
activeProps={{
className: cn('bg-accent text-accent-foreground'),
}}
>
<Settings className="size-4" />
{t(SETTINGS_ITEM.labelKey)}
</Link>
</div>
)}
<div className="flex items-center gap-1 border-t border-border p-3">
<div className="flex-1 min-w-0">
<UserMenu />
@@ -25,7 +25,7 @@ describe('AvailabilityStatusBadge', () => {
});
it('renders Unavailable status with red styling', () => {
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
const label = screen.getByTestId('availability-status-label');
expect(label).toHaveTextContent('Unavailable');
@@ -70,7 +70,7 @@ describe('AvailabilityStatusBadge', () => {
it('Unavailable shows a custom reason when provided', () => {
renderWithProviders(
<AvailabilityStatusBadge status="Unavailable" message="Emergency shutdown in progress" />,
<AvailabilityStatusBadge status="NotAvailable" message="Emergency shutdown in progress" />,
);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
@@ -79,7 +79,7 @@ describe('AvailabilityStatusBadge', () => {
});
it('Unavailable falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is currently unavailable.',
@@ -33,7 +33,7 @@ const STATUS_CONFIG: Record<
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
Icon: AlertTriangle,
},
Unavailable: {
NotAvailable: {
labelKey: 'availability.unavailable',
defaultMessageKey: 'availability.messageUnavailable',
alwaysDefault: false,
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { UserRole } from '@/api/types';
function roleBadgeVariant(role: UserRole): 'default' | 'secondary' | 'outline' {
if (role === 'Owner') return 'default';
if (role === 'Administrator') return 'secondary';
return 'outline';
}
interface RoleBadgeProps {
role: UserRole;
'data-testid'?: string;
}
export function RoleBadge({ role, 'data-testid': testId }: RoleBadgeProps) {
const { t } = useTranslation();
return (
<Badge variant={roleBadgeVariant(role)} data-testid={testId}>
{t(`users.roles.${role}`)}
</Badge>
);
}
@@ -144,6 +144,60 @@
"insufficientPermissions": "You do not have permission to perform this action."
}
},
"profile": {
"title": "My Profile",
"name": "Full name",
"email": "Email address",
"role": "Role",
"save": "Save changes",
"saving": "Saving…",
"saveSuccess": "Profile updated successfully",
"changePassword": "Change password",
"changePasswordDialog": {
"title": "Change Password",
"current": "Current password",
"new": "New password",
"confirm": "Confirm new password",
"submit": "Change password",
"submitting": "Changing…",
"success": "Password changed successfully",
"errorCurrent": "Current password is incorrect"
}
},
"settings": {
"title": "System Settings",
"availability": {
"title": "System Availability",
"mode": "Availability mode",
"reason": "Message (optional)",
"save": "Update availability",
"saving": "Updating…",
"saveSuccess": "Availability updated",
"modes": {
"Available": "Available",
"Maintenance": "Maintenance",
"NotAvailable": "Unavailable"
}
},
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
"branding": { "title": "Branding / Theme", "comingSoon": "Coming soon" }
},
"cms": {
"title": "Content Management System",
"description": "This is where you will manage your CMS content. This feature is coming soon."
},
"error": {
"403": {
"title": "Access Denied",
"message": "You don't have permission to view this page."
},
"404": {
"title": "Page Not Found",
"message": "The page you're looking for doesn't exist."
},
"backToDashboard": "Back to Dashboard"
},
"errors": {
"network": "Unable to reach the server. Check your connection and try again.",
"generic": "Something went wrong. Please try again.",
@@ -144,6 +144,60 @@
"insufficientPermissions": "Je hebt geen toestemming om deze actie uit te voeren."
}
},
"profile": {
"title": "Mijn profiel",
"name": "Volledige naam",
"email": "E-mailadres",
"role": "Rol",
"save": "Wijzigingen opslaan",
"saving": "Opslaan…",
"saveSuccess": "Profiel succesvol bijgewerkt",
"changePassword": "Wachtwoord wijzigen",
"changePasswordDialog": {
"title": "Wachtwoord wijzigen",
"current": "Huidig wachtwoord",
"new": "Nieuw wachtwoord",
"confirm": "Nieuw wachtwoord bevestigen",
"submit": "Wachtwoord wijzigen",
"submitting": "Bezig…",
"success": "Wachtwoord succesvol gewijzigd",
"errorCurrent": "Huidig wachtwoord is onjuist"
}
},
"settings": {
"title": "Systeeminstellingen",
"availability": {
"title": "Systeembeschikbaarheid",
"mode": "Beschikbaarheidsmodus",
"reason": "Bericht (optioneel)",
"save": "Beschikbaarheid bijwerken",
"saving": "Bijwerken…",
"saveSuccess": "Beschikbaarheid bijgewerkt",
"modes": {
"Available": "Beschikbaar",
"Maintenance": "Onderhoud",
"NotAvailable": "Niet beschikbaar"
}
},
"modules": { "title": "Modulebeheer", "comingSoon": "Binnenkort beschikbaar" },
"systemConfig": { "title": "Systeemconfiguratie", "comingSoon": "Binnenkort beschikbaar" },
"branding": { "title": "Huisstijl / Thema", "comingSoon": "Binnenkort beschikbaar" }
},
"cms": {
"title": "Content Management Systeem",
"description": "Hier beheert u straks uw CMS-inhoud. Deze functie is binnenkort beschikbaar."
},
"error": {
"403": {
"title": "Toegang geweigerd",
"message": "Je hebt geen toestemming om deze pagina te bekijken."
},
"404": {
"title": "Pagina niet gevonden",
"message": "De pagina die je zoekt bestaat niet."
},
"backToDashboard": "Terug naar dashboard"
},
"errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
+11
View File
@@ -34,4 +34,15 @@ export const authHandlers = [
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 },
);
}),
];
@@ -8,4 +8,6 @@ export const availabilityHandlers = [
message: '',
}),
),
http.post('*/Availability/admin/status', () => new HttpResponse(null, { status: 200 })),
];
+12
View File
@@ -91,6 +91,18 @@ export const userHandlers = [
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');
@@ -0,0 +1,34 @@
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 { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('AccessDeniedPage', () => {
it('renders the 403 title and message', async () => {
renderApp('/403');
expect(await screen.findByTestId('access-denied-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('access-denied-message')).toBeInTheDocument();
});
it('renders the back to dashboard button', async () => {
renderApp('/403');
expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('navigates to login when back button is clicked as guest', async () => {
mockGuest();
renderApp('/403');
const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 });
await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { ShieldOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
export function AccessDeniedPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<ShieldOff className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold" data-testid="access-denied-title">
{t('error.403.title')}
</h1>
<p className="text-muted-foreground max-w-sm" data-testid="access-denied-message">
{t('error.403.message')}
</p>
<Button
onClick={() => void navigate({ to: '/dashboard' })}
data-testid="access-denied-back-button"
>
{t('error.backToDashboard')}
</Button>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('CmsPage', () => {
it('renders the CMS page title and placeholder', async () => {
mockAuthenticated();
renderApp('/cms');
expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('cms-placeholder')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/cms');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+7 -4
View File
@@ -1,14 +1,17 @@
import { LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** Placeholder; CMS management (Owner-only, US-18/US-20) arrives in a later unit. */
export function CmsPage() {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<LayoutGrid className="size-12 text-muted-foreground" />
<h1 className="text-2xl font-semibold" data-testid="cms-title">
{t('nav.cms')}
{t('cms.title')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
<p className="text-muted-foreground max-w-sm" data-testid="cms-placeholder">
{t('cms.description')}
</p>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
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 { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('NotFoundPage', () => {
it('renders for an unknown route', async () => {
renderApp('/this-route-does-not-exist');
expect(await screen.findByTestId('not-found-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('not-found-message')).toBeInTheDocument();
});
it('renders the back to dashboard button', async () => {
renderApp('/some/nonexistent/path');
expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('navigates to login when back button is clicked as guest', async () => {
mockGuest();
renderApp('/this-does-not-exist');
const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 });
await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { FileQuestion } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
export function NotFoundPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<FileQuestion className="size-12 text-muted-foreground" />
<h1 className="text-2xl font-semibold" data-testid="not-found-title">
{t('error.404.title')}
</h1>
<p className="text-muted-foreground max-w-sm" data-testid="not-found-message">
{t('error.404.message')}
</p>
<Button
onClick={() => void navigate({ to: '/dashboard' })}
data-testid="not-found-back-button"
>
{t('error.backToDashboard')}
</Button>
</div>
);
}
+120
View File
@@ -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 '@/mocks/auth/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: 5000 })).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: 5000 })).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();
});
});
+277 -3
View File
@@ -1,13 +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 '@/contexts/auth-context';
import { useUpdateProfile, useChangePassword } from '@/api/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-2">
<div className="space-y-6 max-w-lg">
<h1 className="text-2xl font-semibold" data-testid="profile-title">
{t('nav.profile')}
{t('profile.title')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
{/* 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>
);
}
+90
View File
@@ -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, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/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: 5000 })).toBeInTheDocument();
});
it('shows the current availability status badge', async () => {
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('availability-badge', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('renders all three availability mode buttons', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 });
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: 5000 });
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: 5000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/availability updated/i, {}, { timeout: 5000 })).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: 5000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('renders all placeholder sections', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('settings-title', {}, { timeout: 5000 });
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();
});
});
+128 -3
View File
@@ -1,13 +1,138 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAvailabilityStatus, useUpdateAvailability } from '@/api/useAvailability';
import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusBadge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import type { AvailabilityStatus } from '@/api/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('');
useEffect(() => {
if (availability) {
setSelectedMode(availability.status);
setReason(availability.message ?? '');
}
}, [availability]);
const handleSave = async () => {
try {
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
toast.success(t('settings.availability.saveSuccess'));
} catch {
toast.error(t('errors.generic'));
}
};
const modes: AvailabilityStatus[] = ['Available', 'Maintenance', 'NotAvailable'];
return (
<div className="space-y-2">
<div className="space-y-6 max-w-xl">
<h1 className="text-2xl font-semibold" data-testid="settings-title">
{t('nav.settings')}
{t('settings.title')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
{/* 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}
<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)}
data-testid={`mode-option-${mode}`}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
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)}
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"
placeholder={t('settings.availability.reason')}
/>
</div>
<Button
onClick={handleSave}
disabled={updateAvailability.isPending}
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>
);
}
+2 -9
View File
@@ -33,18 +33,13 @@ import {
} from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/components/users/InviteUserDialog';
import { DeleteUserDialog } from '@/components/users/DeleteUserDialog';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/api/useUsers';
import { useAuth } from '@/contexts/auth-context';
import { Trans } from 'react-i18next';
import { ProblemDetailsError } from '@/lib/api-client';
import type { UserRole, UserListItem } from '@/api/types';
function roleBadgeVariant(role: UserRole) {
if (role === 'Owner') return 'default';
if (role === 'Administrator') return 'secondary';
return 'outline';
}
function statusBadgeVariant(user: UserListItem) {
if (user.invitationPending) return 'outline';
if (!user.isActive) return 'destructive';
@@ -248,9 +243,7 @@ export function UsersPage() {
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={roleBadgeVariant(user.role)}>
{t(`users.roles.${user.role}`)}
</Badge>
<RoleBadge role={user.role} />
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(user)}>
+9
View File
@@ -11,6 +11,7 @@ import type { SetupStatus } from '@/api/types';
import { api } from '@/lib/api-client';
import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage';
import { NotFoundPage } from '@/pages/NotFoundPage';
import { RoleGuard } from '@/components/auth/RoleGuard';
export interface RouterContext {
@@ -92,6 +93,7 @@ function lazyPage<P extends Record<string, never>>(
const rootRoute = createRootRouteWithContext<RouterContext>()({
pendingComponent: BootstrapSplash,
pendingMs: 0,
notFoundComponent: NotFoundPage,
beforeLoad: async ({ location }) => {
let status: SetupStatus;
try {
@@ -205,11 +207,18 @@ const profileRoute = createRoute({
component: lazyPage(() => import('@/pages/ProfilePage'), 'ProfilePage'),
});
const accessDeniedRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/403',
component: lazyPage(() => import('@/pages/AccessDeniedPage'), 'AccessDeniedPage'),
});
export const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
setupRoute,
inviteCompleteRoute,
accessDeniedRoute,
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
]);
+5 -1
View File
@@ -1,10 +1,14 @@
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import { cleanup, configure } from '@testing-library/react';
import { server } from '@/mocks/server';
import { api } from '@/lib/api-client';
import '@/i18n/config';
// Increase findBy/waitFor timeout to handle parallel test environments
// where lazy-loaded route components may take >1000ms to resolve.
configure({ asyncUtilTimeout: 8000 });
// jsdom is missing a few browser APIs that Radix/sonner touch.
const globalAny = globalThis as unknown as {
matchMedia?: unknown;
+2
View File
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nextProvider } from 'react-i18next';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { routeTree } from '@/router';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
@@ -68,6 +69,7 @@ export function renderApp(initialPath = '/') {
<I18nextProvider i18n={i18n}>
<AuthProvider>
<AppHarness initialPath={initialPath} />
<Toaster />
</AuthProvider>
</I18nextProvider>
</QueryClientProvider>,
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
testTimeout: 15000,
css: true,
coverage: {
provider: 'v8',