@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +10 -2
  3. package/backend/internal/account/logging_integration_test.go +70 -0
  4. package/backend/internal/api/api.gen.go +125 -0
  5. package/backend/internal/auth/internal/store/session.go +63 -5
  6. package/backend/internal/auth/logging_integration_test.go +88 -0
  7. package/backend/internal/auth/login.go +252 -0
  8. package/backend/internal/auth/login_integration_test.go +421 -0
  9. package/backend/internal/auth/login_test.go +68 -0
  10. package/backend/internal/auth/logout.go +68 -0
  11. package/backend/internal/auth/logout_integration_test.go +124 -0
  12. package/backend/internal/auth/me.go +40 -0
  13. package/backend/internal/auth/me_integration_test.go +70 -0
  14. package/backend/internal/auth/session.go +20 -1
  15. package/backend/internal/server/api.go +14 -0
  16. package/backend/internal/server/public.go +3 -1
  17. package/backend/internal/shared/web/problem.go +9 -0
  18. package/backend/internal/shared/web/retryafter_test.go +31 -0
  19. package/frontend/src/app/index.css +102 -102
  20. package/frontend/src/app/layouts.tsx +75 -18
  21. package/frontend/src/app/pages/auth.tsx +52 -78
  22. package/frontend/src/app/pages/login.test.tsx +218 -0
  23. package/frontend/src/app/pages/register.test.tsx +231 -0
  24. package/frontend/src/app/router.tsx +83 -34
  25. package/frontend/src/app/session.ts +53 -22
  26. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  27. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  28. package/frontend/src/features/auth/login.ts +23 -0
  29. package/frontend/src/features/auth/register.ts +105 -0
  30. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  31. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  32. package/frontend/src/shared/text/index.ts +20 -0
  33. package/frontend/src/shared/text/text.test.ts +28 -1
  34. package/frontend/vite.config.ts +5 -1
  35. package/package.json +1 -1
@@ -0,0 +1,218 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { http, HttpResponse } from "msw";
5
+ import { createMemoryRouter } from "react-router";
6
+ import { axe } from "vitest-axe";
7
+ import { clearCsrfToken } from "@/shared/api";
8
+ import { problem } from "@/shared/api/mocks/problem";
9
+ import { server } from "@/shared/api/mocks/server";
10
+ import { initI18n } from "@/shared/i18n";
11
+ import { App } from "../App";
12
+ import { routes } from "../router";
13
+
14
+ const API = "http://localhost:3000/api/v1";
15
+ const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
16
+ const ADMIN = { empNo: "000001", name: "Root", email: null, role: "ADMIN" };
17
+
18
+ /** A visitor on `path`; `account` is who a successful login returns. `GET /auth/me` follows the session. */
19
+ async function open(path: string, account: typeof USER | typeof ADMIN = USER) {
20
+ clearCsrfToken();
21
+ let session: typeof USER | typeof ADMIN | null = null;
22
+ const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
23
+ login: [],
24
+ logoutCsrf: [],
25
+ };
26
+ server.use(
27
+ http.get(`${API}/auth/me`, () =>
28
+ session ? HttpResponse.json(session) : problem(401, "UNAUTHENTICATED"),
29
+ ),
30
+ http.get(`${API}/deadline`, () =>
31
+ HttpResponse.json({
32
+ deadline: null,
33
+ state: "OPEN",
34
+ serverTime: "2026-11-08T00:00:00.000Z",
35
+ }),
36
+ ),
37
+ http.get(`${API}/auth/csrf`, () =>
38
+ HttpResponse.json({ token: session ? "tok-session" : "tok-anon" }),
39
+ ),
40
+ http.post(`${API}/auth/login`, async ({ request }) => {
41
+ calls.login.push(await request.json());
42
+ session = account;
43
+ return HttpResponse.json(account);
44
+ }),
45
+ http.post(`${API}/auth/logout`, ({ request }) => {
46
+ calls.logoutCsrf.push(request.headers.get("X-CSRF-Token"));
47
+ session = null;
48
+ return new HttpResponse(null, { status: 204 });
49
+ }),
50
+ );
51
+ await initI18n("en");
52
+ const router = createMemoryRouter(routes, { initialEntries: [path] });
53
+ const view = render(<App router={router} client={new QueryClient()} />);
54
+ await screen.findByRole("heading", {
55
+ level: 1,
56
+ name: "Sign in to your account",
57
+ });
58
+ return { router, calls, user: userEvent.setup(), ...view };
59
+ }
60
+
61
+ async function signIn(
62
+ user: ReturnType<typeof userEvent.setup>,
63
+ empNo = "089290",
64
+ password = "secret",
65
+ ) {
66
+ await user.type(screen.getByLabelText("Employee ID"), empNo);
67
+ await user.type(screen.getByLabelText("Password"), password);
68
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
69
+ }
70
+
71
+ describe("Login page", () => {
72
+ it("has labelled, autocompleted fields and no axe violations", async () => {
73
+ const { container } = await open("/login");
74
+ expect(screen.getByLabelText("Employee ID")).toHaveAttribute(
75
+ "autocomplete",
76
+ "username",
77
+ );
78
+ expect(screen.getByLabelText("Password")).toHaveAttribute(
79
+ "autocomplete",
80
+ "current-password",
81
+ );
82
+ expect((await axe(container)).violations).toEqual([]);
83
+ });
84
+
85
+ it("signs a User in and lands on /ideas", async () => {
86
+ const { router, calls, user } = await open("/login");
87
+ await signIn(user, " 089290 ", "secret");
88
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
89
+ expect(calls.login).toEqual([{ empNo: "089290", password: "secret" }]);
90
+ expect(await screen.findByText("Wang")).toBeInTheDocument();
91
+ expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("signs an Admin in, to the same page, with the Administrator badge", async () => {
95
+ const { router, user } = await open("/login", ADMIN);
96
+ await signIn(user, "000001");
97
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
98
+ expect(await screen.findByText("Administrator")).toBeInTheDocument();
99
+ });
100
+
101
+ it("follows a role-allowed next", async () => {
102
+ const { router, user } = await open("/login?next=%2Fjoin-requests");
103
+ await signIn(user);
104
+ await waitFor(() =>
105
+ expect(router.state.location.pathname).toBe("/join-requests"),
106
+ );
107
+ });
108
+
109
+ it.each([
110
+ "//evil.example",
111
+ "https://evil.example/x",
112
+ "/admin/deadline",
113
+ "/nope",
114
+ "\\\\evil.example",
115
+ ])("ignores next=%s", async (next) => {
116
+ const { router, user } = await open(
117
+ `/login?next=${encodeURIComponent(next)}`,
118
+ );
119
+ await signIn(user);
120
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
121
+ expect(router.state.location.search).toBe("");
122
+ });
123
+
124
+ it("shows one message for a wrong password, clears the password and focuses the message", async () => {
125
+ const { user, container } = await open("/login");
126
+ server.use(
127
+ http.post(`${API}/auth/login`, () => problem(401, "INVALID_CREDENTIALS")),
128
+ );
129
+ await signIn(user, "089290", "wrong");
130
+ const alert = await screen.findByRole("alert");
131
+ expect(alert).toHaveTextContent(
132
+ "The employee ID or password is incorrect.",
133
+ );
134
+ expect(alert).toHaveFocus();
135
+ expect(screen.getByLabelText("Password")).toHaveValue("");
136
+ expect(screen.getByLabelText("Employee ID")).toHaveValue("089290");
137
+ expect((await axe(container)).violations).toEqual([]);
138
+ });
139
+
140
+ it("shows the throttle message", async () => {
141
+ const { user } = await open("/login");
142
+ server.use(
143
+ http.post(`${API}/auth/login`, () =>
144
+ problem(429, "LOGIN_THROTTLED", {}, { "Retry-After": "60" }),
145
+ ),
146
+ );
147
+ await signIn(user);
148
+ expect(await screen.findByRole("alert")).toHaveTextContent(
149
+ "Too many sign-in attempts",
150
+ );
151
+ });
152
+
153
+ it("shows the registered and session-ended notices", async () => {
154
+ const router = createMemoryRouter(routes, {
155
+ initialEntries: [
156
+ { pathname: "/login", state: { notice: "sessionEnded" } },
157
+ ],
158
+ });
159
+ server.use(
160
+ http.get(`${API}/auth/me`, () => problem(401, "UNAUTHENTICATED")),
161
+ http.get(`${API}/deadline`, () =>
162
+ HttpResponse.json({
163
+ deadline: null,
164
+ state: "OPEN",
165
+ serverTime: "2026-11-08T00:00:00.000Z",
166
+ }),
167
+ ),
168
+ );
169
+ await initI18n("en");
170
+ render(<App router={router} client={new QueryClient()} />);
171
+ expect(await screen.findByRole("status", { name: "" })).toBeInTheDocument();
172
+ expect(
173
+ await screen.findByText("Your session has ended. Please sign in again."),
174
+ ).toBeInTheDocument();
175
+ });
176
+ });
177
+
178
+ describe("Sign out", () => {
179
+ it("ends the session with the session-bound CSRF token and returns to a clean /login", async () => {
180
+ const { router, calls, user } = await open("/login");
181
+ await signIn(user);
182
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
183
+ await screen.findByRole("heading", {
184
+ level: 1,
185
+ name: "Sign in to your account",
186
+ });
187
+ expect(calls.logoutCsrf).toEqual(["tok-session"]);
188
+ expect(router.state.location.pathname).toBe("/login");
189
+ expect(router.state.location.search).toBe("");
190
+ expect(
191
+ screen.queryByText("Your session has ended. Please sign in again."),
192
+ ).not.toBeInTheDocument();
193
+ expect(screen.queryByText("Wang")).not.toBeInTheDocument();
194
+ });
195
+
196
+ it("a second login after logout uses a fresh CSRF token", async () => {
197
+ const { router, user } = await open("/login");
198
+ await signIn(user);
199
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
200
+ await screen.findByRole("heading", {
201
+ level: 1,
202
+ name: "Sign in to your account",
203
+ });
204
+ await signIn(user);
205
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
206
+ });
207
+
208
+ it("tells the user when sign-out fails and keeps them signed in", async () => {
209
+ const { router, user } = await open("/login");
210
+ await signIn(user);
211
+ server.use(
212
+ http.post(`${API}/auth/logout`, () => problem(500, "INTERNAL_ERROR")),
213
+ );
214
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
215
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
216
+ expect(router.state.location.pathname).toBe("/ideas");
217
+ });
218
+ });
@@ -0,0 +1,231 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { http, HttpResponse } from 'msw';
5
+ import { createMemoryRouter } from 'react-router';
6
+ import { axe } from 'vitest-axe';
7
+ import { clearCsrfToken } from '@/shared/api';
8
+ import { problem } from '@/shared/api/mocks/problem';
9
+ import { server } from '@/shared/api/mocks/server';
10
+ import { initI18n, type Locale } from '@/shared/i18n';
11
+ import { App } from '../App';
12
+ import { routes } from '../router';
13
+
14
+ const API = 'http://localhost:3000/api/v1';
15
+
16
+ interface Seen {
17
+ body: unknown;
18
+ csrf: string | null;
19
+ cookieSet: boolean;
20
+ }
21
+
22
+ /** Visitor on /register; returns what POST /auth/register received. */
23
+ async function open(locale: Locale = 'en') {
24
+ clearCsrfToken();
25
+ const seen: Seen[] = [];
26
+ server.use(
27
+ http.get(`${API}/auth/me`, () => problem(401, 'UNAUTHENTICATED')),
28
+ http.get(`${API}/deadline`, () => HttpResponse.json({ deadline: null, state: 'OPEN', serverTime: '2026-11-08T00:00:00.000Z' })),
29
+ http.get(`${API}/auth/csrf`, () => HttpResponse.json({ token: 'tok' })),
30
+ );
31
+ await initI18n(locale);
32
+ const router = createMemoryRouter(routes, { initialEntries: ['/register'] });
33
+ const view = render(<App router={router} client={new QueryClient()} />);
34
+ await screen.findByRole('heading', { level: 1, name: locale === 'en' ? 'Create an account' : '建立帳號' });
35
+ return { router, seen, user: userEvent.setup(), ...view };
36
+ }
37
+
38
+ function registerReturns(seen: Seen[], respond: () => Response | Promise<Response>) {
39
+ server.use(
40
+ http.post(`${API}/auth/register`, async ({ request }) => {
41
+ seen.push({ body: await request.json(), csrf: request.headers.get('X-CSRF-Token'), cookieSet: false });
42
+ return respond();
43
+ }),
44
+ );
45
+ }
46
+
47
+ async function fill(user: ReturnType<typeof userEvent.setup>, v: Partial<Record<'Employee ID' | 'Name' | 'Email' | 'Password' | 'Confirm password', string>>) {
48
+ for (const [label, value] of Object.entries(v)) await user.type(screen.getByLabelText(label), value);
49
+ }
50
+
51
+ const VALID = { 'Employee ID': '089290', Name: ' Wang Xiao Ming ', Email: ' Wang@Example.COM ', Password: 'Correct-Horse-9', 'Confirm password': 'Correct-Horse-9' };
52
+
53
+ describe('Register page: markup', () => {
54
+ it('labels every field and sets autocomplete (WCAG 1.3.1, 1.3.5)', async () => {
55
+ await open();
56
+ const expected: [string, string][] = [
57
+ ['Employee ID', 'username'],
58
+ ['Name', 'name'],
59
+ ['Email', 'email'],
60
+ ['Password', 'new-password'],
61
+ ['Confirm password', 'new-password'],
62
+ ];
63
+ for (const [label, ac] of expected) {
64
+ const input = screen.getByLabelText(label);
65
+ expect(input).toHaveAttribute('autocomplete', ac);
66
+ expect(input).toBeRequired();
67
+ }
68
+ expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
69
+ expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'email');
70
+ });
71
+
72
+ it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), also with errors shown', async (locale) => {
73
+ const { container, user } = await open(locale);
74
+ await screen.findByRole('region', { name: locale === 'en' ? 'Submission status' : /.+/ });
75
+ expect((await axe(container)).violations).toEqual([]);
76
+ await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Create account' : '建立帳號' }));
77
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
78
+ expect((await axe(container)).violations).toEqual([]);
79
+ });
80
+ });
81
+
82
+ describe('Register page: pre-check and error summary', () => {
83
+ it('blocks an empty submit, focuses the summary and sends no request', async () => {
84
+ const { user } = await open();
85
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
86
+ const summary = await screen.findByRole('alert');
87
+ await waitFor(() => expect(summary).toHaveFocus());
88
+ const links = within(summary).getAllByRole('link');
89
+ expect(links.map((l) => l.textContent)).toEqual([
90
+ 'Employee ID: This field is required.',
91
+ 'Name: This field is required.',
92
+ 'Email: This field is required.',
93
+ 'Password: This field is required.',
94
+ ]);
95
+ // each field points at its error text and is marked invalid
96
+ const empNo = screen.getByLabelText('Employee ID');
97
+ expect(empNo).toHaveAttribute('aria-invalid', 'true');
98
+ expect(empNo.getAttribute('aria-describedby')).toContain('empNo-error');
99
+ expect(document.getElementById('empNo-error')).toHaveTextContent('This field is required.');
100
+ });
101
+
102
+ it('a summary link moves focus to its field (keyboard: Enter)', async () => {
103
+ const { user } = await open();
104
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
105
+ const summary = await screen.findByRole('alert');
106
+ await waitFor(() => expect(summary).toHaveFocus());
107
+ await user.tab();
108
+ expect(within(summary).getAllByRole('link')[0]).toHaveFocus();
109
+ await user.tab();
110
+ await user.keyboard('{Enter}');
111
+ expect(screen.getByLabelText('Name')).toHaveFocus();
112
+ });
113
+
114
+ it.each([
115
+ ['12345', 'The format is not valid.'],
116
+ ['12345a', 'The format is not valid.'],
117
+ ['123456', 'The format is not valid.'],
118
+ ])('employee ID %j', async (empNo, msg) => {
119
+ const { user } = await open();
120
+ await fill(user, { ...VALID, 'Employee ID': empNo });
121
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
122
+ expect(await screen.findByText(`Employee ID: ${msg}`)).toBeInTheDocument();
123
+ });
124
+
125
+ it('email with a space is rejected before any request (PRD §6.3)', async () => {
126
+ const { user } = await open();
127
+ await fill(user, { ...VALID, Email: 'a b@x.com' });
128
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
129
+ expect(await screen.findByText('Email: The format is not valid.')).toBeInTheDocument();
130
+ });
131
+
132
+ it('name with a forbidden character and a mismatching confirmation', async () => {
133
+ const { user } = await open();
134
+ await fill(user, { ...VALID, Name: 'a/b', 'Confirm password': 'other' });
135
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
136
+ expect(await screen.findByText('Name: The name contains characters that are not allowed.')).toBeInTheDocument();
137
+ expect(screen.getByText('Confirm password: The passwords do not match.')).toBeInTheDocument();
138
+ });
139
+ });
140
+
141
+ describe('Register page: submit', () => {
142
+ it('sends the cleaned values with the CSRF token, then goes to /login with a notice and no login', async () => {
143
+ const { user, seen, router } = await open();
144
+ registerReturns(seen, () => HttpResponse.json({ empNo: '089290', name: 'Wang Xiao Ming', email: 'wang@example.com' }, { status: 201 }));
145
+ let loginCalls = 0;
146
+ server.use(http.post(`${API}/auth/login`, () => (loginCalls++, problem(500, 'INTERNAL_ERROR'))));
147
+ await fill(user, VALID);
148
+ await user.keyboard('{Enter}'); // submit from the keyboard
149
+ await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
150
+ expect(seen).toEqual([
151
+ { body: { empNo: '089290', name: 'Wang Xiao Ming', email: 'wang@example.com', password: 'Correct-Horse-9' }, csrf: 'tok', cookieSet: false },
152
+ ]);
153
+ expect(router.state.location.search).toBe('');
154
+ expect(router.state.location.state).toEqual({ notice: 'registered' });
155
+ expect(await screen.findByRole('status', { name: '' })).toBeDefined();
156
+ expect(screen.getByText('Your account was created. Sign in to continue.')).toBeInTheDocument();
157
+ expect(loginCalls).toBe(0);
158
+ });
159
+
160
+ it('disables the button while the request is pending (no double submit)', async () => {
161
+ const { user, seen, router } = await open();
162
+ let release!: () => void;
163
+ const gate = new Promise<void>((r) => (release = r));
164
+ registerReturns(seen, async () => {
165
+ await gate;
166
+ return HttpResponse.json({ empNo: '089290', name: 'W', email: 'w@x.io' }, { status: 201 });
167
+ });
168
+ await fill(user, VALID);
169
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
170
+ const busy = await screen.findByRole('button', { name: 'Creating account…' });
171
+ expect(busy).toBeDisabled();
172
+ release();
173
+ await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
174
+ expect(seen).toHaveLength(1);
175
+ });
176
+
177
+ it.each([
178
+ ['EMP_NO_TAKEN', 'Employee ID', 'Employee ID: This employee ID is already registered.'],
179
+ ['EMAIL_TAKEN', 'Email', 'Email: This email address is already registered.'],
180
+ ] as const)('409 %s is shown on its field and the user stays on the form', async (code, label, summaryText) => {
181
+ const { user, seen, router } = await open();
182
+ registerReturns(seen, () => problem(409, code));
183
+ await fill(user, VALID);
184
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
185
+ expect(await screen.findByRole('link', { name: summaryText })).toBeInTheDocument();
186
+ expect(screen.getByLabelText(label)).toHaveAttribute('aria-invalid', 'true');
187
+ expect(router.state.location.pathname).toBe('/register');
188
+ expect(screen.getByLabelText('Password')).toHaveValue('Correct-Horse-9'); // nothing the user typed is lost
189
+ });
190
+
191
+ it('422 field errors map to their fields; PASSWORD_POLICY shows the policy message', async () => {
192
+ const { user, seen } = await open();
193
+ registerReturns(seen, () =>
194
+ problem(422, 'PASSWORD_POLICY', {
195
+ errors: [
196
+ { field: 'name', code: 'OUT_OF_RANGE' },
197
+ { field: 'password', code: 'LENGTH' },
198
+ { field: 'password', code: 'COMPLEXITY' },
199
+ ],
200
+ }),
201
+ );
202
+ await fill(user, VALID);
203
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
204
+ expect(await screen.findByRole('link', { name: 'Name: The value is out of range.' })).toBeInTheDocument();
205
+ expect(screen.getByRole('link', { name: 'Password: The password does not meet the requirements.' })).toBeInTheDocument();
206
+ expect(screen.getByLabelText('Name')).toHaveAttribute('aria-invalid', 'true');
207
+ });
208
+
209
+ it('a failure without a field (503) is a form-level message with the request ID', async () => {
210
+ const { user, seen, router } = await open();
211
+ registerReturns(seen, () => problem(503, 'SERVICE_UNAVAILABLE'));
212
+ await fill(user, VALID);
213
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
214
+ const summary = await screen.findByRole('alert');
215
+ expect(summary).toHaveTextContent('The service is temporarily unavailable. Please try again later.');
216
+ expect(summary).toHaveTextContent('Request ID: 6f1c2c1e-0000-4000-8000-000000000000');
217
+ expect(within(summary).queryAllByRole('link')).toHaveLength(0);
218
+ expect(router.state.location.pathname).toBe('/register');
219
+ expect(screen.getByRole('button', { name: 'Create account' })).toBeEnabled();
220
+ });
221
+
222
+ it('retries once after CSRF_INVALID (shared client behaviour) and still succeeds', async () => {
223
+ const { user, router } = await open();
224
+ let n = 0;
225
+ server.use(http.post(`${API}/auth/register`, () => (n++ === 0 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({ empNo: '089290', name: 'W', email: 'w@x.io' }, { status: 201 }))));
226
+ await fill(user, VALID);
227
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
228
+ await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
229
+ expect(n).toBe(2);
230
+ });
231
+ });
@@ -1,29 +1,39 @@
1
- import { lazy, Suspense, type ComponentType } from 'react';
2
- import { useTranslation } from 'react-i18next';
3
- import { Navigate, useLocation, useSearchParams, type RouteObject } from 'react-router';
4
- import { decide, safeNext } from './access';
5
- import { AppShell, PublicLayout } from './layouts';
6
- import { NotFound } from './pages/NotFound';
7
- import { Login, Register } from './pages/auth';
8
- import { IdeaCreate, IdeaDetail, IdeaEdit, IdeaList } from './pages/ideas';
9
- import { JoinRequests } from './pages/JoinRequests';
10
- import { ROUTES } from './routes';
11
- import { useMe } from './session';
1
+ import { lazy, Suspense, useEffect, type ComponentType } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import {
4
+ Navigate,
5
+ useLocation,
6
+ useSearchParams,
7
+ type RouteObject,
8
+ } from "react-router";
9
+ import { decide, safeNext } from "./access";
10
+ import { AppShell, PublicLayout } from "./layouts";
11
+ import { NotFound } from "./pages/NotFound";
12
+ import { Login, Register } from "./pages/auth";
13
+ import { IdeaCreate, IdeaDetail, IdeaEdit, IdeaList } from "./pages/ideas";
14
+ import { JoinRequests } from "./pages/JoinRequests";
15
+ import { ROUTES } from "./routes";
16
+ import { ApiError } from "@/shared/api";
17
+ import { leavingReason, useMe, type SessionEndedState } from "./session";
12
18
 
13
- const AdminDeadline = lazy(() => import('./pages/admin').then((m) => ({ default: m.AdminDeadline })));
14
- const AdminStats = lazy(() => import('./pages/admin').then((m) => ({ default: m.AdminStats })));
19
+ const AdminDeadline = lazy(() =>
20
+ import("./pages/admin").then((m) => ({ default: m.AdminDeadline })),
21
+ );
22
+ const AdminStats = lazy(() =>
23
+ import("./pages/admin").then((m) => ({ default: m.AdminStats })),
24
+ );
15
25
 
16
26
  /** Page per route path; keys must match `ROUTES` (checked by a test). */
17
27
  export const PAGES: Record<string, ComponentType> = {
18
- '/login': Login,
19
- '/register': Register,
20
- '/ideas': IdeaList,
21
- '/ideas/new': IdeaCreate,
22
- '/ideas/:id': IdeaDetail,
23
- '/ideas/:id/edit': IdeaEdit,
24
- '/join-requests': JoinRequests,
25
- '/admin/deadline': AdminDeadline,
26
- '/admin/stats': AdminStats,
28
+ "/login": Login,
29
+ "/register": Register,
30
+ "/ideas": IdeaList,
31
+ "/ideas/new": IdeaCreate,
32
+ "/ideas/:id": IdeaDetail,
33
+ "/ideas/:id/edit": IdeaEdit,
34
+ "/join-requests": JoinRequests,
35
+ "/admin/deadline": AdminDeadline,
36
+ "/admin/stats": AdminStats,
27
37
  };
28
38
 
29
39
  /** Wraps a page with identity, the access decision (§6.3) and the right layout. `route` undefined = 404. */
@@ -31,35 +41,74 @@ function Gate({ route }: { route?: (typeof ROUTES)[number] }) {
31
41
  const { t } = useTranslation();
32
42
  const location = useLocation();
33
43
  const [params] = useSearchParams();
34
- const { data: me, isPending, isError } = useMe();
44
+ const { data: me, isPending, isError, error } = useMe();
45
+ useEffect(() => {
46
+ if (!isError) return;
47
+ if (error instanceof ApiError)
48
+ console.error(
49
+ "GET /auth/me failed",
50
+ error.status,
51
+ error.code,
52
+ error.requestId,
53
+ );
54
+ else
55
+ console.error(
56
+ "GET /auth/me failed (network error or non-JSON response; is the backend / dev proxy up?)",
57
+ error,
58
+ );
59
+ }, [isError, error]);
35
60
 
36
- if (isPending) return <p role="status">{t('common.loading')}</p>;
37
- if (isError) return <p role="alert">{t('errors.UNKNOWN')}</p>;
61
+ if (isPending) return <p role="status">{t("common.loading")}</p>;
62
+ if (isError) return <p role="alert">{t("errors.UNKNOWN")}</p>;
38
63
 
39
64
  const role = me?.role ?? null;
40
- const d = route ? decide(route.access, role, location) : ({ kind: 'notFound' } as const);
41
- if (d.kind === 'login') return <Navigate to={`/login?next=${encodeURIComponent(d.next)}`} replace />;
42
- if (d.kind === 'home') return <Navigate to={role ? safeNext(params.get('next'), role) : '/login'} replace />;
65
+ const d = route
66
+ ? decide(route.access, role, location)
67
+ : ({ kind: "notFound" } as const);
68
+ if (d.kind === "login") {
69
+ const why = leavingReason();
70
+ if (why === "signedOut") return <Navigate to="/login" replace />;
71
+ const state: SessionEndedState | undefined =
72
+ why === "sessionEnded" ? { notice: "sessionEnded" } : undefined;
73
+ return (
74
+ <Navigate
75
+ to={`/login?next=${encodeURIComponent(d.next)}`}
76
+ replace
77
+ state={state}
78
+ />
79
+ );
80
+ }
81
+ if (d.kind === "home")
82
+ return (
83
+ <Navigate
84
+ to={role ? safeNext(params.get("next"), role) : "/login"}
85
+ replace
86
+ />
87
+ );
43
88
 
44
- const Page = d.kind === 'allow' && route ? PAGES[route.path] : undefined;
89
+ const Page = d.kind === "allow" && route ? PAGES[route.path] : undefined;
45
90
  const page = Page ? (
46
- <Suspense fallback={<p role="status">{t('common.loading')}</p>}>
91
+ <Suspense fallback={<p role="status">{t("common.loading")}</p>}>
47
92
  <Page />
48
93
  </Suspense>
49
94
  ) : (
50
95
  <NotFound />
51
96
  );
52
- return me ? <AppShell me={me}>{page}</AppShell> : <PublicLayout>{page}</PublicLayout>;
97
+ return me ? (
98
+ <AppShell me={me}>{page}</AppShell>
99
+ ) : (
100
+ <PublicLayout>{page}</PublicLayout>
101
+ );
53
102
  }
54
103
 
55
104
  function RootRedirect() {
56
105
  const { data: me, isPending } = useMe();
57
106
  if (isPending) return null;
58
- return <Navigate to={me ? '/ideas' : '/login'} replace />;
107
+ return <Navigate to={me ? "/ideas" : "/login"} replace />;
59
108
  }
60
109
 
61
110
  export const routes: RouteObject[] = [
62
- { path: '/', element: <RootRedirect /> },
111
+ { path: "/", element: <RootRedirect /> },
63
112
  ...ROUTES.map((r) => ({ path: r.path, element: <Gate route={r} /> })),
64
- { path: '*', element: <Gate /> },
113
+ { path: "*", element: <Gate /> },
65
114
  ];