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:
parent
e9143f8c27
commit
b5ceedc097
5
portal/frontend/.prettierignore
Normal file
5
portal/frontend/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
package-lock.json
|
||||||
8
portal/frontend/.prettierrc.json
Normal file
8
portal/frontend/.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
55
portal/frontend/eslint.config.js
Normal file
55
portal/frontend/eslint.config.js
Normal 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,
|
||||||
|
);
|
||||||
3456
portal/frontend/package-lock.json
generated
3456
portal/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,6 +72,7 @@ export default function App() {
|
|||||||
<ThemedRoot>
|
<ThemedRoot>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
@ -79,11 +123,15 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemedRoot>
|
</ThemedRoot>
|
||||||
</BrandingProvider>
|
</BrandingProvider>
|
||||||
</AppInfoProvider>
|
</AppInfoProvider>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
portal/frontend/src/api/client.test.ts
Normal file
32
portal/frontend/src/api/client.test.ts
Normal 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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
81
portal/frontend/src/api/measurements.test.ts
Normal file
81
portal/frontend/src/api/measurements.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
47
portal/frontend/src/components/RequireRole.test.tsx
Normal file
47
portal/frontend/src/components/RequireRole.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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.';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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.';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 && dashboards.some((d) => d.uid === data.defaultDashboardUid)
|
||||||
? data.defaultDashboardUid
|
? data.defaultDashboardUid
|
||||||
: dashboards[0]?.uid ?? null;
|
: (dashboards[0]?.uid ?? null);
|
||||||
if (initial) setSelected(initial);
|
if (initial) setSelected(initial);
|
||||||
}, [data, dashboards, selected]);
|
}, [data, dashboards, selected]);
|
||||||
|
|
||||||
|
|||||||
81
portal/frontend/src/pages/LoginPage.test.tsx
Normal file
81
portal/frontend/src/pages/LoginPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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.';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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.';
|
|
||||||
}
|
|
||||||
|
|||||||
37
portal/frontend/src/test/setup.ts
Normal file
37
portal/frontend/src/test/setup.ts
Normal 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;
|
||||||
@ -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.
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user