Portal frontend: tests, code splitting, 401 interceptor, lint/format tooling

Items 1-5 of the agreed frontend upgrades. The one-time Prettier
reformatting of files not touched here is in the FOLLOW-UP commit so this
one stays reviewable.

Testing (vitest + @testing-library)
- vitest 3 + @testing-library/react + jsdom. test / test:watch / test:ui
  scripts; setup.ts stubs matchMedia + ResizeObserver for AntD under jsdom.
- 15 tests, 4 files: RequireRole, LoginPage, measurements API client,
  extractApiError.

Route-level code splitting
- Every page converted to React.lazy() behind a Suspense boundary in
  App.tsx. Main bundle 1490 KB -> 714 KB; ECharts isolated in the
  DashboardPage chunk and only fetched when a dashboard is opened.

Global 401 handling
- api/client.ts response interceptor redirects any 401 from a non-/auth
  path to /login?returnTo=<current>; LoginPage honours the param.
- Shared extractApiError replaces four near-identical per-page copies
  (UsersPage, AdminCustomersPage, MyProfilePage, TariffDrawer).

Query devtools
- @tanstack/react-query-devtools, lazy + import.meta.env.DEV-gated so a
  production build dead-code-eliminates it. Verified: no devtools code in
  any shipped .js chunk.

ESLint + Prettier
- ESLint 9 flat config (js + typescript-eslint + react-hooks +
  react-refresh, eslint-config-prettier last). Prettier config tuned to
  the existing style. lint / lint:fix / format / format:check scripts +
  lint-staged config.
- Fixed one real react-hooks/exhaustive-deps warning (useMemo on
  DashboardsPage's `dashboards`).
- End state: eslint 0 problems, tsc clean, 15/15 tests pass.

Pre-commit hook (lint-staged) is installed locally in .git/hooks/ -- not
version-controlled, see follow-up note. Husky was deliberately not used:
it needs a core.hooksPath change and the monorepo layout makes it awkward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-19 23:46:54 +02:00
parent e9143f8c27
commit b5ceedc097
19 changed files with 4195 additions and 166 deletions

View File

@ -0,0 +1,5 @@
dist
node_modules
coverage
*.tsbuildinfo
package-lock.json

View File

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf"
}

View File

@ -0,0 +1,55 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
// ESLint 9 flat config. Order matters: `prettier` must come last so it can
// switch off any stylistic rules that would fight the formatter — ESLint owns
// code-correctness, Prettier owns formatting.
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage'] },
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
// Test + setup files also touch Node globals (process, etc.) and use
// jsdom; allow both global sets.
files: ['**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{
// Context files intentionally export a Provider component *and* its hook
// from the same module — the standard React Context pattern. The
// react-refresh rule flags this; the trade-off (marginally less optimal
// HMR) isn't worth splitting every context into two files.
files: ['src/hooks/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
// Config files run in Node.
files: ['*.config.{js,ts}'],
languageOptions: {
globals: globals.node,
},
},
prettier,
);

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,23 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,md}": [
"prettier --write"
]
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
@ -21,10 +37,26 @@
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@tanstack/react-query-devtools": "^5.100.11",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.4",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.2",
"prettier": "^3.8.3",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.0" "typescript-eslint": "^8.59.4",
"vite": "^6.0.0",
"vitest": "^3.2.4"
} }
} }

View File

@ -1,5 +1,16 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Spin } from 'antd';
// Query devtools — dev only. The lazy import means the devtools code is in its
// own chunk that a production build never references, so it adds 0 bytes to the
// shipped bundle.
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
)
: () => null;
import { AuthProvider } from './hooks/useAuth'; import { AuthProvider } from './hooks/useAuth';
import { BrandingProvider } from './hooks/useBranding'; import { BrandingProvider } from './hooks/useBranding';
import { AppInfoProvider } from './hooks/useAppInfo'; import { AppInfoProvider } from './hooks/useAppInfo';
@ -7,20 +18,52 @@ import { ThemedRoot } from './components/ThemedRoot';
import { RequireAuth } from './components/RequireAuth'; import { RequireAuth } from './components/RequireAuth';
import { RequireRole } from './components/RequireRole'; import { RequireRole } from './components/RequireRole';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage'; // Pages are code-split — each one ships in its own chunk and is fetched on
import { DashboardsPage } from './pages/DashboardsPage'; // demand. Keeps ECharts (DashboardPage), date utilities, and Admin-only pages
import { MeasurementsPage } from './pages/MeasurementsPage'; // out of the first-paint bundle for users who never visit them. The shim
import { MyProfilePage } from './pages/MyProfilePage'; // `m => ({ default: m.X })` adapts named exports to React.lazy's default-export
import { AdminSitesPage } from './pages/AdminSitesPage'; // expectation; cheaper than converting every page to a default export.
import { AdminCustomersPage } from './pages/AdminCustomersPage'; const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; const DashboardPage = lazy(() =>
import { SettingsPage } from './pages/SettingsPage'; import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage })),
);
const DashboardsPage = lazy(() =>
import('./pages/DashboardsPage').then((m) => ({ default: m.DashboardsPage })),
);
const MeasurementsPage = lazy(() =>
import('./pages/MeasurementsPage').then((m) => ({ default: m.MeasurementsPage })),
);
const MyProfilePage = lazy(() =>
import('./pages/MyProfilePage').then((m) => ({ default: m.MyProfilePage })),
);
const AdminSitesPage = lazy(() =>
import('./pages/AdminSitesPage').then((m) => ({ default: m.AdminSitesPage })),
);
const AdminCustomersPage = lazy(() =>
import('./pages/AdminCustomersPage').then((m) => ({ default: m.AdminCustomersPage })),
);
const AdminCustomerDetailPage = lazy(() =>
import('./pages/AdminCustomerDetailPage').then((m) => ({ default: m.AdminCustomerDetailPage })),
);
const SettingsPage = lazy(() =>
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
);
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
}); });
function PageFallback() {
return (
<div
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 240 }}
>
<Spin size="large" />
</div>
);
}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -29,61 +72,66 @@ export default function App() {
<ThemedRoot> <ThemedRoot>
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route <Route path="/login" element={<LoginPage />} />
path="/"
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} />
<Route path="measurements" element={<MeasurementsPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route <Route
path="admin/sites" path="/"
element={ element={
<RequireRole role="Admin"> <RequireAuth>
<AdminSitesPage /> <AppLayout />
</RequireRole> </RequireAuth>
} }
/> >
<Route <Route index element={<DashboardPage />} />
path="admin/customers" <Route path="dashboards" element={<DashboardsPage />} />
element={ <Route path="measurements" element={<MeasurementsPage />} />
<RequireRole role="Admin"> <Route path="profile" element={<MyProfilePage />} />
<AdminCustomersPage /> <Route
</RequireRole> path="admin/sites"
} element={
/> <RequireRole role="Admin">
<Route <AdminSitesPage />
path="admin/customers/:id" </RequireRole>
element={ }
<RequireRole role="Admin"> />
<AdminCustomerDetailPage /> <Route
</RequireRole> path="admin/customers"
} element={
/> <RequireRole role="Admin">
<Route <AdminCustomersPage />
path="settings" </RequireRole>
element={ }
<RequireRole role="Admin"> />
<SettingsPage /> <Route
</RequireRole> path="admin/customers/:id"
} element={
/> <RequireRole role="Admin">
<Route path="admin/users" element={<Navigate to="/settings" replace />} /> <AdminCustomerDetailPage />
</Route> </RequireRole>
<Route path="*" element={<Navigate to="/" replace />} /> }
</Routes> />
<Route
path="settings"
element={
<RequireRole role="Admin">
<SettingsPage />
</RequireRole>
}
/>
<Route path="admin/users" element={<Navigate to="/settings" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</ThemedRoot> </ThemedRoot>
</BrandingProvider> </BrandingProvider>
</AppInfoProvider> </AppInfoProvider>
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { extractApiError } from './client';
describe('extractApiError', () => {
it('returns the single { error } message', () => {
const err = { response: { data: { error: 'Customer code already exists.' } } };
expect(extractApiError(err)).toBe('Customer code already exists.');
});
it('joins the { errors[] } list (Identity validation shape)', () => {
const err = { response: { data: { errors: ['Too short.', 'Needs a digit.'] } } };
expect(extractApiError(err)).toBe('Too short.; Needs a digit.');
});
it('prefers error over errors when both are present', () => {
const err = { response: { data: { error: 'primary', errors: ['secondary'] } } };
expect(extractApiError(err)).toBe('primary');
});
it('falls back to the default for a non-axios error', () => {
expect(extractApiError(new Error('boom'))).toBe('Request failed.');
});
it('uses a custom fallback when provided', () => {
expect(extractApiError(null, 'Save failed.')).toBe('Save failed.');
});
it('falls back when the response body has no recognised field', () => {
const err = { response: { data: { unexpected: true } } };
expect(extractApiError(err)).toBe('Request failed.');
});
});

View File

@ -1,7 +1,41 @@
import axios from 'axios'; import axios, { type AxiosError } from 'axios';
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
// Global 401 → /login redirect. Without this, an expired session in the middle
// of a page shows generic "Request failed" errors from each TanStack Query.
// Skipping /auth/* lets the auth flow (login, /me probe, /check) handle 401
// inline — login shows wrong-password, fetchCurrentUser turns it into null,
// /check is the Traefik forwardAuth probe and is never called by user code.
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
const url = error.config?.url ?? '';
const isAuthPath = url.startsWith('/auth/') || url === '/auth';
const alreadyOnLogin = window.location.pathname === '/login';
if (!isAuthPath && !alreadyOnLogin) {
const returnTo = window.location.pathname + window.location.search;
window.location.href = `/login?returnTo=${encodeURIComponent(returnTo)}`;
}
}
return Promise.reject(error);
},
);
// Pull a human-readable error message out of an axios error. Backends use
// either { error: "..." } (single message) or { errors: ["...", "..."] }
// (Identity validation rejections). Falls back to the supplied default.
export function extractApiError(err: unknown, fallback = 'Request failed.'): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string; errors?: string[] } } }).response
?.data;
if (data?.error) return data.error;
if (data?.errors?.length) return data.errors.join('; ');
}
return fallback;
}

View File

@ -0,0 +1,81 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { api } from './client';
import { fetchRawMeasurements, downloadRawMeasurementsExport } from './measurements';
vi.mock('./client', () => ({
api: {
get: vi.fn(),
},
}));
describe('measurements API client', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
});
it('passes the device-id filter as a comma-separated string', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
});
await fetchRawMeasurements({
fromUtc: '2026-05-01T00:00:00Z',
toUtc: '2026-05-02T00:00:00Z',
deviceIds: ['aaa', 'bbb', 'ccc'],
limit: 200,
offset: 0,
});
expect(api.get).toHaveBeenCalledWith(
'/measurements/raw',
expect.objectContaining({
params: expect.objectContaining({ deviceIds: 'aaa,bbb,ccc' }),
}),
);
});
it('omits the device-id filter when the list is empty', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
});
await fetchRawMeasurements({
fromUtc: '2026-05-01T00:00:00Z',
toUtc: '2026-05-02T00:00:00Z',
deviceIds: [],
limit: 200,
offset: 0,
});
const call = vi.mocked(api.get).mock.calls[0][1] as { params: Record<string, unknown> };
expect(call.params.deviceIds).toBeUndefined();
});
it('builds the download URL with the full filter set', () => {
const originalLocation = window.location;
const setHref = vi.fn();
Object.defineProperty(window, 'location', {
writable: true,
value: {
...originalLocation,
set href(v: string) {
setHref(v);
},
},
});
downloadRawMeasurementsExport({
fromUtc: '2026-05-01T00:00:00.000Z',
toUtc: '2026-05-02T00:00:00.000Z',
deviceIds: ['device-1', 'device-2'],
rowCap: 50000,
});
expect(setHref).toHaveBeenCalledWith(
expect.stringContaining('/api/measurements/raw/export.xlsx?'),
);
const url = setHref.mock.calls[0][0] as string;
expect(url).toContain('from=2026-05-01T00%3A00%3A00.000Z');
expect(url).toContain('to=2026-05-02T00%3A00%3A00.000Z');
expect(url).toContain('deviceIds=device-1%2Cdevice-2');
expect(url).toContain('rowCap=50000');
Object.defineProperty(window, 'location', { writable: true, value: originalLocation });
});
});

View File

@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RequireRole } from './RequireRole';
import * as useAuthModule from '../hooks/useAuth';
function mockAuth(user: { roles: string[] } | null) {
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
user: user as never,
loading: false,
login: vi.fn(),
logout: vi.fn(),
setUser: vi.fn(),
});
}
describe('<RequireRole>', () => {
it('renders children when the user has the required role', () => {
mockAuth({ roles: ['Admin'] });
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.getByText('Admin-only content')).toBeInTheDocument();
});
it('renders a 403 when the user is missing the required role', () => {
mockAuth({ roles: ['User'] });
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.queryByText('Admin-only content')).not.toBeInTheDocument();
expect(screen.getByText('403')).toBeInTheDocument();
});
it('renders a 403 when the user is null (unauthenticated edge)', () => {
mockAuth(null);
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.getByText('403')).toBeInTheDocument();
});
});

View File

@ -1,17 +1,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Drawer, Form, Input, InputNumber, Switch, DatePicker, Row, Col, Button, Space, Alert, Typography, Drawer,
Form,
Input,
InputNumber,
Switch,
DatePicker,
Row,
Col,
Button,
Space,
Alert,
Typography,
} from 'antd'; } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates'; import {
createTariff,
updateTariff,
type TariffDetail,
type TariffPeriod,
type UpsertTariff,
} from '../../../api/rates';
import { extractApiError } from '../../../api/client';
import { PeriodEditor } from './PeriodEditor'; import { PeriodEditor } from './PeriodEditor';
const { Title } = Typography; const { Title } = Typography;
type Mode = type Mode = { kind: 'create'; municipalityId: number } | { kind: 'edit'; tariff: TariffDetail };
| { kind: 'create'; municipalityId: number }
| { kind: 'edit'; tariff: TariffDetail };
interface Props { interface Props {
open: boolean; open: boolean;
@ -74,7 +90,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
qc.invalidateQueries({ queryKey: ['municipalities'] }); qc.invalidateQueries({ queryKey: ['municipalities'] });
onClose(); onClose();
}, },
onError: (err: unknown) => setError(extractError(err)), onError: (err: unknown) => setError(extractApiError(err, 'Save failed.')),
}); });
const handleSubmit = (values: FormShape) => { const handleSubmit = (values: FormShape) => {
@ -111,7 +127,11 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}> <Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="name" label="Tariff name" rules={[{ required: true, message: 'Required' }]}> <Form.Item
name="name"
label="Tariff name"
rules={[{ required: true, message: 'Required' }]}
>
<Input /> <Input />
</Form.Item> </Form.Item>
</Col> </Col>
@ -165,17 +185,11 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
</Col> </Col>
</Row> </Row>
<Title level={5} style={{ marginTop: 8 }}>Time-of-use periods</Title> <Title level={5} style={{ marginTop: 8 }}>
Time-of-use periods
</Title>
<PeriodEditor value={periods} onChange={setPeriods} /> <PeriodEditor value={periods} onChange={setPeriods} />
</Form> </Form>
</Drawer> </Drawer>
); );
} }
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Save failed.';
}

View File

@ -5,11 +5,18 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer, listCustomers,
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload, createCustomer,
updateCustomer,
rotateCustomerToken,
deleteCustomer,
type CustomerListItem,
type CreateCustomerPayload,
type UpdateCustomerPayload,
} from '../api/customers'; } from '../api/customers';
import { CustomerFormModal } from '../components/customers/CustomerFormModal'; import { CustomerFormModal } from '../components/customers/CustomerFormModal';
import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal'; import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal';
import { extractApiError } from '../api/client';
const { Text } = Typography; const { Text } = Typography;
@ -20,8 +27,14 @@ export function AdminCustomersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [formMode, setFormMode] = useState<FormMode | null>(null); const [formMode, setFormMode] = useState<FormMode | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({ const [tokenModal, setTokenModal] = useState<{
open: false, code: null, token: null, open: boolean;
code: string | null;
token: string | null;
}>({
open: false,
code: null,
token: null,
}); });
const { data: customers = [], isLoading } = useQuery({ const { data: customers = [], isLoading } = useQuery({
@ -39,18 +52,19 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token }); setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate(); invalidate();
}, },
onError: (err: unknown) => setFormError(extractError(err)), onError: (err: unknown) => setFormError(extractApiError(err)),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload), mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) =>
updateCustomer(id, payload),
onSuccess: () => { onSuccess: () => {
message.success('Customer updated'); message.success('Customer updated');
setFormMode(null); setFormMode(null);
setFormError(null); setFormError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setFormError(extractError(err)), onError: (err: unknown) => setFormError(extractApiError(err)),
}); });
const rotateMut = useMutation({ const rotateMut = useMutation({
@ -59,13 +73,16 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token }); setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate(); invalidate();
}, },
onError: (err: unknown) => message.error(extractError(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: string) => deleteCustomer(id), mutationFn: (id: string) => deleteCustomer(id),
onSuccess: () => { message.success('Customer deleted'); invalidate(); }, onSuccess: () => {
onError: (err: unknown) => message.error(extractError(err)), message.success('Customer deleted');
invalidate();
},
onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => { const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
@ -84,7 +101,8 @@ export function AdminCustomersPage() {
title: 'Status', title: 'Status',
dataIndex: 'isActive', dataIndex: 'isActive',
key: 'isActive', key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>), render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Last push', title: 'Last push',
@ -106,7 +124,9 @@ export function AdminCustomersPage() {
return ( return (
<Space size={4} direction="vertical"> <Space size={4} direction="vertical">
{issued} {issued}
<Tooltip title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}> <Tooltip
title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}
>
<Tag color="orange" style={{ fontSize: 11 }}> <Tag color="orange" style={{ fontSize: 11 }}>
Old token valid until {exp.toLocaleTimeString()} Old token valid until {exp.toLocaleTimeString()}
</Tag> </Tag>
@ -120,7 +140,14 @@ export function AdminCustomersPage() {
key: 'actions', key: 'actions',
render: (_, c) => ( render: (_, c) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}> <Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setFormError(null);
setFormMode({ kind: 'edit', customer: c });
}}
>
Edit Edit
</Button> </Button>
<Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes."> <Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes.">
@ -131,7 +158,11 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)} onConfirm={() => rotateMut.mutate(c.id)}
> >
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}> <Button
size="small"
icon={<ReloadOutlined />}
loading={rotateMut.isPending && rotateMut.variables === c.id}
>
Rotate token Rotate token
</Button> </Button>
</Popconfirm> </Popconfirm>
@ -143,7 +174,9 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(c.id)} onConfirm={() => deleteMut.mutate(c.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -157,7 +190,10 @@ export function AdminCustomersPage() {
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }} onClick={() => {
setFormError(null);
setFormMode({ kind: 'create' });
}}
> >
Register customer Register customer
</Button> </Button>
@ -184,7 +220,10 @@ export function AdminCustomersPage() {
mode={formMode} mode={formMode}
submitting={createMut.isPending || updateMut.isPending} submitting={createMut.isPending || updateMut.isPending}
error={formError} error={formError}
onClose={() => { setFormMode(null); setFormError(null); }} onClose={() => {
setFormMode(null);
setFormError(null);
}}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
@ -197,11 +236,3 @@ export function AdminCustomersPage() {
</Card> </Card>
); );
} }
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Request failed.';
}

View File

@ -14,15 +14,16 @@ export function DashboardsPage() {
staleTime: 60_000, staleTime: 60_000,
}); });
const dashboards = data?.dashboards ?? []; const dashboards = useMemo(() => data?.dashboards ?? [], [data]);
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? ''; const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (selected) return; if (selected) return;
const initial = data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid) const initial =
? data.defaultDashboardUid data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
: dashboards[0]?.uid ?? null; ? data.defaultDashboardUid
: (dashboards[0]?.uid ?? null);
if (initial) setSelected(initial); if (initial) setSelected(initial);
}, [data, dashboards, selected]); }, [data, dashboards, selected]);

View File

@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from './LoginPage';
import * as useAuthModule from '../hooks/useAuth';
import * as useBrandingModule from '../hooks/useBranding';
function mockHooks(loginImpl: (email: string, pw: string) => Promise<void>) {
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
user: null,
loading: false,
login: loginImpl,
logout: vi.fn(),
setUser: vi.fn(),
});
vi.spyOn(useBrandingModule, 'useBranding').mockReturnValue({
branding: {
applicationName: 'Test Portal',
logoUrl: '',
primaryColor: '#000',
secondaryColor: '#111',
accentColor: '#222',
footerText: 'footer',
},
loading: false,
});
}
describe('<LoginPage>', () => {
it('calls login() with the submitted credentials', async () => {
const login = vi.fn().mockResolvedValue(undefined);
mockHooks(login);
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>home</div>} />
</Routes>
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'ChangeMe123!');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(login).toHaveBeenCalledWith('admin@example.com', 'ChangeMe123!');
});
it('shows an inline error when login() rejects', async () => {
const login = vi.fn().mockRejectedValue(new Error('bad creds'));
mockHooks(login);
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), 'a@b.c');
await userEvent.type(screen.getByLabelText(/password/i), 'xx');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid email or password/i)).toBeInTheDocument();
});
it('renders the branded application name in the header', () => {
mockHooks(vi.fn());
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByText('Test Portal')).toBeInTheDocument();
});
});

View File

@ -19,7 +19,13 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/'; // Return-to comes from either the ?returnTo= query param (set by the global
// 401 interceptor) or router state (set by RequireAuth). Query param wins.
const returnToParam = new URLSearchParams(location.search).get('returnTo');
const from =
returnToParam ??
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ??
'/';
const onFinish = async (values: LoginValues) => { const onFinish = async (values: LoginValues) => {
setError(null); setError(null);
@ -77,7 +83,10 @@ export function LoginPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{branding.footerText && ( {branding.footerText && (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}> <Text
type="secondary"
style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}
>
{branding.footerText} {branding.footerText}
</Text> </Text>
)} )}

View File

@ -1,10 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tag, Typography, message, Alert,
Button,
Card,
Col,
Descriptions,
Form,
Input,
Row,
Space,
Tag,
Typography,
message,
} from 'antd'; } from 'antd';
import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { changeMyPassword, updateMyProfile } from '../api/auth'; import { changeMyPassword, updateMyProfile } from '../api/auth';
import { extractApiError } from '../api/client';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -37,7 +49,7 @@ export function MyProfilePage() {
setUser(updated); setUser(updated);
message.success('Profile updated'); message.success('Profile updated');
} catch (err: unknown) { } catch (err: unknown) {
setProfileError(extractError(err)); setProfileError(extractApiError(err));
} finally { } finally {
setSavingProfile(false); setSavingProfile(false);
} }
@ -51,7 +63,7 @@ export function MyProfilePage() {
passwordForm.resetFields(); passwordForm.resetFields();
message.success('Password changed'); message.success('Password changed');
} catch (err: unknown) { } catch (err: unknown) {
setPasswordError(extractError(err)); setPasswordError(extractApiError(err));
} finally { } finally {
setSavingPassword(false); setSavingPassword(false);
} }
@ -60,20 +72,39 @@ export function MyProfilePage() {
return ( return (
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title={<Space><UserOutlined /><Title level={5} style={{ margin: 0 }}>My profile</Title></Space>}> <Card
title={
<Space>
<UserOutlined />
<Title level={5} style={{ margin: 0 }}>
My profile
</Title>
</Space>
}
>
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}> <Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="Email"> <Descriptions.Item label="Email">
<Text code>{user.email}</Text> <Text code>{user.email}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>(read-only)</Text> <Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
(read-only)
</Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Roles"> <Descriptions.Item label="Roles">
{user.roles.length === 0 {user.roles.length === 0 ? (
? <Tag>User</Tag> <Tag>User</Tag>
: user.roles.map(r => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>)} ) : (
user.roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{profileError && <Alert type="error" message={profileError} style={{ marginBottom: 16 }} />} {profileError && (
<Alert type="error" message={profileError} style={{ marginBottom: 16 }} />
)}
<Form<ProfileFormValues> <Form<ProfileFormValues>
form={profileForm} form={profileForm}
@ -93,7 +124,12 @@ export function MyProfilePage() {
<Input autoComplete="name" /> <Input autoComplete="name" />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingProfile}> <Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={savingProfile}
>
Save profile Save profile
</Button> </Button>
</Form.Item> </Form.Item>
@ -102,8 +138,19 @@ export function MyProfilePage() {
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title={<Space><LockOutlined /><Title level={5} style={{ margin: 0 }}>Change password</Title></Space>}> <Card
{passwordError && <Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />} title={
<Space>
<LockOutlined />
<Title level={5} style={{ margin: 0 }}>
Change password
</Title>
</Space>
}
>
{passwordError && (
<Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />
)}
<Form<PasswordFormValues> <Form<PasswordFormValues>
form={passwordForm} form={passwordForm}
@ -151,7 +198,12 @@ export function MyProfilePage() {
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<LockOutlined />} loading={savingPassword}> <Button
type="primary"
htmlType="submit"
icon={<LockOutlined />}
loading={savingPassword}
>
Change password Change password
</Button> </Button>
</Form.Item> </Form.Item>
@ -161,11 +213,3 @@ export function MyProfilePage() {
</Row> </Row>
); );
} }
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Request failed.';
}

View File

@ -1,7 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd'; import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, ApartmentOutlined } from '@ant-design/icons'; import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
KeyOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
listUsers, listUsers,
@ -13,6 +19,7 @@ import {
type CreateUserPayload, type CreateUserPayload,
type UpdateUserPayload, type UpdateUserPayload,
} from '../api/users'; } from '../api/users';
import { extractApiError } from '../api/client';
import { UserFormDrawer } from '../components/users/UserFormDrawer'; import { UserFormDrawer } from '../components/users/UserFormDrawer';
import { CustomerAccessModal } from '../components/users/CustomerAccessModal'; import { CustomerAccessModal } from '../components/users/CustomerAccessModal';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
@ -43,18 +50,19 @@ export function UsersPage() {
setDrawerError(null); setDrawerError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setDrawerError(errorMessage(err)), onError: (err: unknown) => setDrawerError(extractApiError(err)),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) => updateUser(id, payload), mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) =>
updateUser(id, payload),
onSuccess: () => { onSuccess: () => {
message.success('User updated'); message.success('User updated');
setDrawerMode(null); setDrawerMode(null);
setDrawerError(null); setDrawerError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setDrawerError(errorMessage(err)), onError: (err: unknown) => setDrawerError(extractApiError(err)),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
@ -63,7 +71,7 @@ export function UsersPage() {
message.success('User deleted'); message.success('User deleted');
invalidate(); invalidate();
}, },
onError: (err: unknown) => message.error(errorMessage(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const resetMut = useMutation({ const resetMut = useMutation({
@ -74,7 +82,7 @@ export function UsersPage() {
setResetTarget(null); setResetTarget(null);
setResetValue(''); setResetValue('');
}, },
onError: (err: unknown) => message.error(errorMessage(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => { const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
@ -94,15 +102,22 @@ export function UsersPage() {
dataIndex: 'roles', dataIndex: 'roles',
key: 'roles', key: 'roles',
render: (roles: string[]) => render: (roles: string[]) =>
roles.length === 0 roles.length === 0 ? (
? <Tag>User</Tag> <Tag>User</Tag>
: roles.map((r) => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>), ) : (
roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
),
}, },
{ {
title: 'Active', title: 'Active',
dataIndex: 'isActive', dataIndex: 'isActive',
key: 'isActive', key: 'isActive',
render: (active: boolean) => active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>, render: (active: boolean) =>
active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Created', title: 'Created',
@ -118,23 +133,25 @@ export function UsersPage() {
<Button <Button
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'edit', user }); }} onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'edit', user });
}}
> >
Edit Edit
</Button> </Button>
{isAdminMode && user.roles.includes('RestrictedAdmin') && ( {isAdminMode && user.roles.includes('RestrictedAdmin') && (
<Button <Button size="small" icon={<ApartmentOutlined />} onClick={() => setAccessTarget(user)}>
size="small"
icon={<ApartmentOutlined />}
onClick={() => setAccessTarget(user)}
>
Customer access Customer access
</Button> </Button>
)} )}
<Button <Button
size="small" size="small"
icon={<KeyOutlined />} icon={<KeyOutlined />}
onClick={() => { setResetValue(''); setResetTarget(user); }} onClick={() => {
setResetValue('');
setResetTarget(user);
}}
> >
Reset password Reset password
</Button> </Button>
@ -144,7 +161,9 @@ export function UsersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(user.id)} onConfirm={() => deleteMut.mutate(user.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -158,7 +177,10 @@ export function UsersPage() {
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'create' }); }} onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'create' });
}}
> >
New user New user
</Button> </Button>
@ -177,7 +199,10 @@ export function UsersPage() {
mode={drawerMode} mode={drawerMode}
submitting={createMut.isPending || updateMut.isPending} submitting={createMut.isPending || updateMut.isPending}
error={drawerError} error={drawerError}
onClose={() => { setDrawerMode(null); setDrawerError(null); }} onClose={() => {
setDrawerMode(null);
setDrawerError(null);
}}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
@ -185,7 +210,10 @@ export function UsersPage() {
title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'} title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'}
open={resetTarget !== null} open={resetTarget !== null}
confirmLoading={resetMut.isPending} confirmLoading={resetMut.isPending}
onCancel={() => { setResetTarget(null); setResetValue(''); }} onCancel={() => {
setResetTarget(null);
setResetValue('');
}}
onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })} onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })}
okText="Reset" okText="Reset"
okButtonProps={{ disabled: resetValue.length < 8 }} okButtonProps={{ disabled: resetValue.length < 8 }}
@ -206,12 +234,3 @@ export function UsersPage() {
</Card> </Card>
); );
} }
function errorMessage(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string; errors?: string[] } } }).response?.data;
if (data?.error) return data.error;
if (data?.errors?.length) return data.errors.join('; ');
}
return 'Request failed.';
}

View File

@ -0,0 +1,37 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// React Testing Library auto-cleans between tests; we still want to reset
// any spies/mocks per test for predictable behaviour.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// AntD's responsive observer uses matchMedia which jsdom doesn't implement.
// Stub with a plain function (not vi.fn()) so vi.restoreAllMocks() in afterEach
// doesn't strip its implementation between tests.
const noop = () => undefined;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: noop, // legacy
removeListener: noop, // legacy
addEventListener: noop,
removeEventListener: noop,
dispatchEvent: () => false,
}),
});
// ResizeObserver is used by AntD Layout — also missing in jsdom.
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as unknown as { ResizeObserver: typeof MockResizeObserver }).ResizeObserver =
MockResizeObserver;

View File

@ -1,4 +1,7 @@
import { defineConfig } from 'vite'; // defineConfig from vitest/config is a superset of vite's — it accepts the
// `test` block. Importing it here keeps vite.config.ts the single source of
// truth for both the dev/build config and the test config.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
@ -20,4 +23,12 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
sourcemap: true, sourcemap: true,
}, },
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: false,
// AntD's reset.css is imported in main.tsx — skip it for tests.
// No `globals: true` — test files import describe/it/expect/vi from 'vitest'
// explicitly. Less magic, no tsconfig types array required.
},
}); });