Adds profile and settings pages
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 })),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user