@johnnyboy11234/esg-idea-contest 0.2.1 → 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 (49) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +17 -1
  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/csrf.go +212 -0
  6. package/backend/internal/auth/csrf_test.go +281 -0
  7. package/backend/internal/auth/internal/store/session.go +63 -5
  8. package/backend/internal/auth/logging_integration_test.go +88 -0
  9. package/backend/internal/auth/login.go +252 -0
  10. package/backend/internal/auth/login_integration_test.go +421 -0
  11. package/backend/internal/auth/login_test.go +68 -0
  12. package/backend/internal/auth/logout.go +68 -0
  13. package/backend/internal/auth/logout_integration_test.go +124 -0
  14. package/backend/internal/auth/me.go +40 -0
  15. package/backend/internal/auth/me_integration_test.go +70 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +20 -1
  19. package/backend/internal/server/api.go +19 -0
  20. package/backend/internal/server/public.go +37 -0
  21. package/backend/internal/server/public_test.go +73 -0
  22. package/backend/internal/server/server.go +7 -3
  23. package/backend/internal/shared/config/config.go +23 -4
  24. package/backend/internal/shared/config/config_test.go +14 -8
  25. package/backend/internal/shared/web/problem.go +9 -0
  26. package/backend/internal/shared/web/retryafter_test.go +31 -0
  27. package/frontend/src/app/App.test.tsx +13 -0
  28. package/frontend/src/app/App.tsx +4 -0
  29. package/frontend/src/app/index.css +102 -102
  30. package/frontend/src/app/layouts.tsx +75 -18
  31. package/frontend/src/app/pages/auth.tsx +52 -78
  32. package/frontend/src/app/pages/login.test.tsx +218 -0
  33. package/frontend/src/app/pages/register.test.tsx +231 -0
  34. package/frontend/src/app/router.tsx +83 -34
  35. package/frontend/src/app/session.ts +71 -18
  36. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  38. package/frontend/src/features/auth/login.ts +23 -0
  39. package/frontend/src/features/auth/register.ts +105 -0
  40. package/frontend/src/shared/api/csrf.ts +51 -0
  41. package/frontend/src/shared/api/http.test.tsx +209 -0
  42. package/frontend/src/shared/api/http.ts +91 -0
  43. package/frontend/src/shared/api/index.ts +3 -0
  44. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  45. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  46. package/frontend/src/shared/text/index.ts +20 -0
  47. package/frontend/src/shared/text/text.test.ts +28 -1
  48. package/frontend/vite.config.ts +5 -1
  49. package/package.json +1 -1
@@ -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
  ];
@@ -1,26 +1,79 @@
1
- import { useQuery } from '@tanstack/react-query';
2
- import { parseApiError } from '@/shared/api';
3
- import type { Role } from './routes';
4
-
5
- /** GET /auth/me body (docs/API/response-bodies.md); `email` is null for Admins. */
6
- export interface Me {
7
- empNo: string;
8
- name: string;
9
- email: string | null;
10
- role: Role;
11
- }
1
+ import { useQuery, type QueryClient } from "@tanstack/react-query";
2
+ import type { DataRouter } from "react-router";
3
+ import { ApiError, apiFetch } from "@/shared/api";
4
+ import { findRoute } from "./access";
5
+ import type { Me } from "@/features/auth/login";
6
+
7
+ export type { Me };
12
8
 
13
- export const meKey = ['me'] as const;
9
+ export const meKey = ["me"] as const;
14
10
 
15
11
  /** A 401 means visitor → null (Frontend.md §6.3 rule 1). */
16
12
  export async function fetchMe(signal?: AbortSignal): Promise<Me | null> {
17
- const res = await fetch(`${location.origin}/api/v1/auth/me`, { headers: { Accept: 'application/json' }, signal });
18
- if (res.status === 401) return null;
19
- if (!res.ok) throw await parseApiError(res);
20
- return (await res.json()) as Me;
13
+ try {
14
+ const res = await apiFetch("/auth/me", {
15
+ signal,
16
+ allowUnauthenticated: true,
17
+ });
18
+ return (await res.json()) as Me;
19
+ } catch (e) {
20
+ if (e instanceof ApiError && e.status === 401) return null;
21
+ throw e;
22
+ }
21
23
  }
22
24
 
23
- export function useMe() {
24
- return useQuery({ queryKey: meKey, queryFn: ({ signal }) => fetchMe(signal), retry: false, staleTime: 60_000 });
25
+ /**
26
+ * Why `me` became null. The route gate redirects a visitor on a protected route to `/login?next=…`
27
+ * in the same render that clears `me`, racing the explicit navigation of the code that cleared it.
28
+ * Recording the reason lets the gate send the same destination, so the race has one outcome.
29
+ */
30
+ let leaving: "signedOut" | "sessionEnded" | null = null;
31
+ export const leavingReason = () => leaving;
32
+
33
+ /**
34
+ * A new identity (login) or none (logout): nothing cached for the previous one may survive.
35
+ * `me` itself is kept as a query so the observers of the route gate see the new value.
36
+ */
37
+ export function replaceIdentity(client: QueryClient, me: Me | null): void {
38
+ leaving = me === null ? "signedOut" : null;
39
+ client.removeQueries({ predicate: (q) => q.queryKey[0] !== meKey[0] });
40
+ client.setQueryData(meKey, me);
41
+ }
42
+
43
+ /** Router state a 401 redirect leaves for the login page to show "your session ended" (Frontend.md §5.2). */
44
+ export interface SessionEndedState {
45
+ notice: "sessionEnded";
25
46
  }
26
47
 
48
+ /** Router state `/register` leaves for the login page to confirm the account (not in the URL, Frontend.md §6). */
49
+ export interface RegisteredState {
50
+ notice: "registered";
51
+ }
52
+
53
+ /**
54
+ * Global 401 (Frontend.md §5.2): forget the user and go to `/login?next=<current>`.
55
+ * Already on a public route (login, register) there is nothing to leave, so only `me` is cleared.
56
+ */
57
+ export function handleSessionEnded(
58
+ client: QueryClient,
59
+ router: DataRouter,
60
+ ): void {
61
+ leaving = "sessionEnded";
62
+ client.setQueryData(meKey, null);
63
+ const { pathname, search } = router.state.location;
64
+ if (findRoute(pathname)?.access === "public") return;
65
+ const state: SessionEndedState = { notice: "sessionEnded" };
66
+ void router.navigate(`/login?next=${encodeURIComponent(pathname + search)}`, {
67
+ replace: true,
68
+ state,
69
+ });
70
+ }
71
+
72
+ export function useMe() {
73
+ return useQuery({
74
+ queryKey: meKey,
75
+ queryFn: ({ signal }) => fetchMe(signal),
76
+ retry: false,
77
+ staleTime: 60_000,
78
+ });
79
+ }
@@ -0,0 +1,145 @@
1
+ import { useEffect, useRef, useState, type FormEvent, type Ref } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { Link } from "react-router";
4
+ import { ApiError } from "@/shared/api";
5
+ import { Button } from "@/shared/ui/button";
6
+ import {
7
+ Field,
8
+ FieldDescription,
9
+ FieldGroup,
10
+ FieldLabel,
11
+ } from "@/shared/ui/field";
12
+ import { Input } from "@/shared/ui/input";
13
+ import { login, type Me } from "./login";
14
+
15
+ /**
16
+ * The login form. Every credential failure is the same 401 `INVALID_CREDENTIALS` (PRD §6.5), so
17
+ * it is one form-level message, never attached to a field. Focus moves to the message.
18
+ */
19
+ export function LoginForm({
20
+ onLoggedIn,
21
+ notice,
22
+ heading,
23
+ }: {
24
+ onLoggedIn: (me: Me) => void;
25
+ notice?: string;
26
+ heading?: Ref<HTMLHeadingElement>;
27
+ }) {
28
+ const { t } = useTranslation();
29
+ const [empNo, setEmpNo] = useState("");
30
+ const [password, setPassword] = useState("");
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [errorKey, setErrorKey] = useState(0);
33
+ const [pending, setPending] = useState(false);
34
+ const alert = useRef<HTMLParagraphElement>(null);
35
+
36
+ // after the message is in the DOM (WCAG 3.3.1)
37
+ useEffect(() => {
38
+ if (errorKey > 0) alert.current?.focus();
39
+ }, [errorKey]);
40
+
41
+ const onSubmit = async (e: FormEvent) => {
42
+ e.preventDefault();
43
+ if (pending) return;
44
+ setPending(true);
45
+ setError(null);
46
+ try {
47
+ onLoggedIn(await login({ empNo: empNo.trim(), password }));
48
+ } catch (err) {
49
+ const apiErr =
50
+ err instanceof ApiError
51
+ ? err
52
+ : new ApiError({ status: 0, code: "UNKNOWN" });
53
+ setError(
54
+ t(`errors.${apiErr.code}`) +
55
+ (apiErr.requestId
56
+ ? ` ${t("common.requestId", { id: apiErr.requestId })}`
57
+ : ""),
58
+ );
59
+ setErrorKey((k) => k + 1);
60
+ setPassword("");
61
+ } finally {
62
+ setPending(false);
63
+ }
64
+ };
65
+
66
+ return (
67
+ <form
68
+ className="flex w-full max-w-xs flex-col gap-6"
69
+ onSubmit={(e) => void onSubmit(e)}
70
+ >
71
+ <FieldGroup>
72
+ <div className="flex flex-col items-center gap-1 text-center">
73
+ <h1
74
+ ref={heading}
75
+ tabIndex={-1}
76
+ className="text-2xl font-bold outline-none"
77
+ >
78
+ {t("pages.login.title")}
79
+ </h1>
80
+ <p className="text-sm text-balance text-muted-foreground">
81
+ {t("pages.login.description")}
82
+ </p>
83
+ </div>
84
+ {notice && (
85
+ <p
86
+ role="status"
87
+ className="rounded-md border border-primary p-3 text-sm"
88
+ >
89
+ {notice}
90
+ </p>
91
+ )}
92
+ {error !== null && (
93
+ <p
94
+ key={errorKey}
95
+ ref={alert}
96
+ role="alert"
97
+ tabIndex={-1}
98
+ className="rounded-md border border-destructive p-3 text-sm text-destructive outline-none"
99
+ >
100
+ {error}
101
+ </p>
102
+ )}
103
+ <Field>
104
+ <FieldLabel htmlFor="empNo">{t("fields.empNo")}</FieldLabel>
105
+ <Input
106
+ id="empNo"
107
+ name="empNo"
108
+ autoComplete="username"
109
+ inputMode="numeric"
110
+ required
111
+ value={empNo}
112
+ onChange={(e) => setEmpNo(e.target.value)}
113
+ />
114
+ </Field>
115
+ <Field>
116
+ <FieldLabel htmlFor="password">{t("fields.password")}</FieldLabel>
117
+ <Input
118
+ id="password"
119
+ name="password"
120
+ type="password"
121
+ autoComplete="current-password"
122
+ required
123
+ value={password}
124
+ onChange={(e) => setPassword(e.target.value)}
125
+ />
126
+ </Field>
127
+ <Field>
128
+ <Button
129
+ type="submit"
130
+ disabled={pending}
131
+ aria-busy={pending || undefined}
132
+ >
133
+ {pending ? t("pages.login.submitting") : t("nav.signIn")}
134
+ </Button>
135
+ <FieldDescription className="text-center">
136
+ {t("pages.login.noAccount")}{" "}
137
+ <Link to="/register" className="underline underline-offset-4">
138
+ {t("nav.register")}
139
+ </Link>
140
+ </FieldDescription>
141
+ </Field>
142
+ </FieldGroup>
143
+ </form>
144
+ );
145
+ }