@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2

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 (138) hide show
  1. package/backend/api/openapi.yaml +119 -0
  2. package/backend/cmd/server/main.go +36 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  13. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  14. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  15. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  16. package/backend/internal/account/keyboard.go +75 -0
  17. package/backend/internal/account/keyboard_test.go +43 -0
  18. package/backend/internal/account/logging_integration_test.go +1 -1
  19. package/backend/internal/account/password.go +131 -0
  20. package/backend/internal/account/sequence.go +54 -0
  21. package/backend/internal/account/sequence_test.go +38 -0
  22. package/backend/internal/account/validate.go +4 -21
  23. package/backend/internal/api/api.gen.go +262 -0
  24. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  25. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  26. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  27. package/backend/internal/auth/internal/store/session.go +15 -7
  28. package/backend/internal/auth/login.go +16 -4
  29. package/backend/internal/auth/login_integration_test.go +14 -1
  30. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  31. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  32. package/backend/internal/auth/login_test.go +15 -0
  33. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  34. package/backend/internal/auth/purge.go +93 -0
  35. package/backend/internal/auth/purge_integration_test.go +220 -0
  36. package/backend/internal/auth/purge_test.go +51 -0
  37. package/backend/internal/auth/session.go +0 -6
  38. package/backend/internal/auth/session_integration_test.go +3 -3
  39. package/backend/internal/auth/throttle.go +122 -0
  40. package/backend/internal/deadline/gate.go +80 -0
  41. package/backend/internal/deadline/gate_integration_test.go +67 -0
  42. package/backend/internal/deadline/gate_test.go +61 -0
  43. package/backend/internal/idea/create.go +356 -0
  44. package/backend/internal/idea/create_integration_test.go +680 -0
  45. package/backend/internal/idea/download.go +193 -0
  46. package/backend/internal/idea/download_integration_test.go +311 -0
  47. package/backend/internal/idea/download_test.go +25 -0
  48. package/backend/internal/idea/handler.go +151 -0
  49. package/backend/internal/idea/handler_integration_test.go +107 -0
  50. package/backend/internal/idea/integration_test.go +149 -0
  51. package/backend/internal/idea/internal/store/list.go +146 -0
  52. package/backend/internal/idea/internal/store/list_test.go +33 -0
  53. package/backend/internal/idea/internal/store/store.go +166 -0
  54. package/backend/internal/idea/list.go +166 -0
  55. package/backend/internal/idea/list_integration_test.go +252 -0
  56. package/backend/internal/idea/list_test.go +96 -0
  57. package/backend/internal/idea/service.go +54 -0
  58. package/backend/internal/server/api.go +22 -0
  59. package/backend/internal/server/binding.go +4 -0
  60. package/backend/internal/server/public.go +1 -0
  61. package/backend/internal/server/server.go +20 -1
  62. package/backend/internal/server/slides.go +21 -0
  63. package/backend/internal/server/slides_test.go +82 -0
  64. package/backend/internal/shared/config/config.go +44 -1
  65. package/backend/internal/shared/config/config_test.go +21 -13
  66. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  67. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  68. package/backend/internal/shared/dbtest/faults.go +162 -0
  69. package/backend/internal/shared/job/job.go +54 -0
  70. package/backend/internal/shared/job/job_test.go +105 -0
  71. package/backend/internal/shared/web/multipart.go +175 -0
  72. package/backend/internal/shared/web/multipart_test.go +169 -0
  73. package/backend/internal/shared/web/problem.go +1 -0
  74. package/backend/internal/shared/web/request.go +42 -1
  75. package/backend/internal/shared/web/transport.go +4 -3
  76. package/backend/internal/shared/web/web_test.go +36 -0
  77. package/backend/internal/storage/expectedfilename.go +43 -0
  78. package/backend/internal/storage/expectedfilename_test.go +16 -0
  79. package/backend/internal/storage/internal/store/pending.go +41 -0
  80. package/backend/internal/storage/minio.go +139 -0
  81. package/backend/internal/storage/minio_integration_test.go +87 -0
  82. package/backend/internal/storage/minio_test.go +84 -0
  83. package/backend/internal/storage/pending.go +144 -0
  84. package/backend/internal/storage/pending_integration_test.go +324 -0
  85. package/backend/internal/storage/pending_test.go +23 -0
  86. package/backend/internal/storage/validator.go +100 -0
  87. package/backend/internal/storage/validator_test.go +95 -0
  88. package/backend/test/arch/arch_test.go +5 -1
  89. package/backend/test/contract/expected_filename_test.go +60 -0
  90. package/backend/test/contract/password_test.go +46 -0
  91. package/backend/test/matrix/load_test.go +12 -0
  92. package/backend/test/matrix/matrix_integration_test.go +92 -5
  93. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  94. package/docs/API/api-endpoints.md +1 -1
  95. package/docs/API/error-codes.md +1 -1
  96. package/docs/Architecture/Architecture.md +1 -1
  97. package/docs/Architecture/Backend.md +2 -1
  98. package/frontend/Dockerfile +2 -0
  99. package/frontend/package-lock.json +1 -0
  100. package/frontend/package.json +1 -0
  101. package/frontend/src/app/App.tsx +11 -2
  102. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  103. package/frontend/src/app/pages/ideas.tsx +23 -9
  104. package/frontend/src/app/pages/login.test.tsx +38 -1
  105. package/frontend/src/app/pages/register.test.tsx +32 -0
  106. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  107. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  108. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  109. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  110. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  111. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  112. package/frontend/src/features/auth/common-passwords.ts +31 -0
  113. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  114. package/frontend/src/features/auth/password-policy.ts +168 -0
  115. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  116. package/frontend/src/features/auth/useCountdown.ts +28 -0
  117. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  118. package/frontend/src/features/idea/create.ts +123 -0
  119. package/frontend/src/shared/api/activity.ts +20 -0
  120. package/frontend/src/shared/api/http.ts +2 -0
  121. package/frontend/src/shared/api/index.ts +1 -0
  122. package/frontend/src/shared/api/types.generated.ts +319 -1
  123. package/frontend/src/shared/i18n/locales/en.ts +46 -4
  124. package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
  125. package/frontend/src/shared/slides/index.ts +74 -0
  126. package/frontend/src/shared/slides/slides.test.ts +51 -0
  127. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  128. package/frontend/src/shared/ui/native-select.tsx +27 -0
  129. package/frontend/src/shared/ui/textarea.tsx +19 -0
  130. package/frontend/src/test-setup.ts +37 -2
  131. package/frontend/vite.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/testdata/expected-filename.json +93 -0
  134. package/testdata/password.json +582 -0
  135. package/testdata/permission-matrix.json +71 -0
  136. package/testdata/schema/expected-filename.schema.json +32 -0
  137. package/testdata/schema/password.schema.json +59 -0
  138. package/testdata/schema/permission-matrix.schema.json +22 -1
@@ -1,7 +1,9 @@
1
1
  import type { FormEvent, ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { Link } from 'react-router';
4
- import { useDeadline } from '@/features/deadline/useDeadline';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { Link, useNavigate } from 'react-router';
5
+ import { deadlineKey, useDeadline } from '@/features/deadline/useDeadline';
6
+ import { CreateIdeaForm } from '@/features/idea/CreateIdeaForm';
5
7
  import { Button } from '@/shared/ui/button';
6
8
  import { Field } from '@/shared/ui/form-field';
7
9
  import { PageHeading } from '../layouts';
@@ -81,17 +83,29 @@ export function IdeaList() {
81
83
 
82
84
  export function IdeaCreate() {
83
85
  const { t } = useTranslation();
86
+ const { data: me } = useMe();
84
87
  const { data: deadline } = useDeadline();
85
- const open = deadline?.state === 'OPEN'; // Frontend.md §6.3 rule 6: a banner and disabled controls, not a guard
88
+ const client = useQueryClient();
89
+ const navigate = useNavigate();
90
+ const open = deadline?.state === 'OPEN'; // Frontend.md §6.3 rule 6: a hint and disabled controls, not a guard
86
91
  return (
87
92
  <>
88
93
  <PageHeading title={t('pages.ideaCreate.title')} />
89
- <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
90
- <IdeaFields withSlides />
91
- <Button type="submit" disabled={!open}>
92
- {t('pages.ideaCreate.submit')}
93
- </Button>
94
- </form>
94
+ <div className="max-w-2xl rounded-xl border bg-card p-6">
95
+ {me && (
96
+ <CreateIdeaForm
97
+ leader={{ name: me.name, empNo: me.empNo }}
98
+ canSubmit={open}
99
+ onSubmissionRejected={() => void client.invalidateQueries({ queryKey: deadlineKey })}
100
+ onCreated={(idea) => {
101
+ // the response is the detail: cache it, no follow-up GET (Frontend.md §6.7)
102
+ client.setQueryData(['idea', idea.id], idea);
103
+ void client.invalidateQueries({ queryKey: ['ideas'] });
104
+ void navigate(`/ideas/${idea.id}`);
105
+ }}
106
+ />
107
+ )}
108
+ </div>
95
109
  </>
96
110
  );
97
111
  }
@@ -1,5 +1,6 @@
1
1
  import { QueryClient } from "@tanstack/react-query";
2
- import { render, screen, waitFor } from "@testing-library/react";
2
+ import { act, render, screen, waitFor } from "@testing-library/react";
3
+ import { vi } from "vitest";
3
4
  import userEvent from "@testing-library/user-event";
4
5
  import { http, HttpResponse } from "msw";
5
6
  import { createMemoryRouter } from "react-router";
@@ -141,6 +142,42 @@ describe("Login page", () => {
141
142
  );
142
143
  });
143
144
 
145
+ it("disables sign-in for Retry-After seconds, then re-enables it", async () => {
146
+ // only the interval and the monotonic clock are faked; userEvent and msw keep real timers
147
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
148
+ let now = 1_000;
149
+ vi.spyOn(performance, "now").mockImplementation(() => now);
150
+ const advance = (ms: number) =>
151
+ act(() => {
152
+ now += ms;
153
+ vi.advanceTimersByTime(ms);
154
+ });
155
+ try {
156
+ const { user } = await open("/login");
157
+ server.use(
158
+ http.post(`${API}/auth/login`, () =>
159
+ problem(429, "LOGIN_THROTTLED", {}, { "Retry-After": "3" }),
160
+ ),
161
+ );
162
+ await signIn(user);
163
+ expect(
164
+ await screen.findByRole("button", { name: "Try again in 3 s" }),
165
+ ).toBeDisabled();
166
+ advance(1000);
167
+ expect(
168
+ screen.getByRole("button", { name: "Try again in 2 s" }),
169
+ ).toBeDisabled();
170
+ advance(2000);
171
+ expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
172
+ expect(screen.getByRole("status")).toHaveTextContent(
173
+ "You can sign in again.",
174
+ );
175
+ } finally {
176
+ vi.restoreAllMocks();
177
+ vi.useRealTimers();
178
+ }
179
+ });
180
+
144
181
  it("shows the registered and session-ended notices", async () => {
145
182
  const router = createMemoryRouter(routes, {
146
183
  initialEntries: [
@@ -138,6 +138,38 @@ describe('Register page: pre-check and error summary', () => {
138
138
  });
139
139
  });
140
140
 
141
+ describe('Register page: live password checklist (F1b-01)', () => {
142
+ const status = (rule: string) => document.querySelector(`li[data-rule="${rule}"]`)?.getAttribute('data-status');
143
+
144
+ it('the password field is described by the checklist, which follows typing and the employee ID', async () => {
145
+ const { user } = await open();
146
+ const password = screen.getByLabelText('Password');
147
+ expect(password.getAttribute('aria-describedby')).toContain('password-rules');
148
+ expect(document.getElementById('password-rules')).toHaveTextContent('Your password must:');
149
+ expect(status('LENGTH')).toBe('idle');
150
+
151
+ await fill(user, { 'Employee ID': '089290', Password: 'Password089290' });
152
+ await waitFor(() => expect(status('COMMON_PASSWORD')).toBe('met'));
153
+ expect(status('LENGTH')).toBe('met');
154
+ expect(status('CONTAINS_EMP_NO')).toBe('unmet');
155
+ });
156
+
157
+ it('the Top-10k blocklist (lazy chunk) marks a common password', async () => {
158
+ const { user } = await open();
159
+ await fill(user, { Password: 'password' });
160
+ await waitFor(() => expect(status('COMMON_PASSWORD')).toBe('unmet'));
161
+ });
162
+
163
+ it('does not block submit: the server stays the authority (422 PASSWORD_POLICY)', async () => {
164
+ const { seen, user } = await open();
165
+ registerReturns(seen, () => problem(422, 'PASSWORD_POLICY', { errors: [{ field: 'password', code: 'PASSWORD_POLICY', rule: 'LENGTH' }] }));
166
+ await fill(user, { ...VALID, Password: 'short', 'Confirm password': 'short' });
167
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
168
+ expect(await screen.findByText('Password: The password does not meet the requirements.')).toBeInTheDocument();
169
+ expect(seen).toHaveLength(1);
170
+ });
171
+ });
172
+
141
173
  describe('Register page: submit', () => {
142
174
  it('sends the cleaned values with the CSRF token, then goes to /login with a notice and no login', async () => {
143
175
  const { user, seen, router } = await open();
@@ -0,0 +1,82 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { http, HttpResponse } from "msw";
4
+ import { axe } from "vitest-axe";
5
+ import { apiFetch, markActivity } from "@/shared/api";
6
+ import { server } from "@/shared/api/mocks/server";
7
+ import { initI18n } from "@/shared/i18n";
8
+ import { IdleWarning } from "./IdleWarning";
9
+
10
+ const API = "http://localhost:3000/api/v1";
11
+ const MIN = 60_000;
12
+ const DIALOG = { name: "Are you still there?" };
13
+
14
+ beforeEach(async () => {
15
+ await initI18n("en");
16
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
17
+ markActivity();
18
+ });
19
+ afterEach(() => vi.useRealTimers());
20
+
21
+ const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
22
+
23
+ describe("IdleWarning", () => {
24
+ it("opens at 28 minutes and ends the session at 30", async () => {
25
+ const onExpire = vi.fn();
26
+ render(<IdleWarning signedIn onExpire={onExpire} />);
27
+ await advance(28 * MIN - 1);
28
+ expect(screen.queryByRole("alertdialog")).toBeNull();
29
+ await advance(1);
30
+ expect(screen.getByRole("alertdialog", DIALOG)).toBeInTheDocument();
31
+ expect(onExpire).not.toHaveBeenCalled();
32
+ await advance(2 * MIN);
33
+ expect(onExpire).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it("Continue calls GET /auth/me, closes the dialog and restarts the clock", async () => {
37
+ let calls = 0;
38
+ server.use(
39
+ http.get(`${API}/auth/me`, () => {
40
+ calls += 1;
41
+ return HttpResponse.json({ empNo: "089290" });
42
+ }),
43
+ );
44
+ const onExpire = vi.fn();
45
+ render(<IdleWarning signedIn onExpire={onExpire} />);
46
+ await advance(28 * MIN);
47
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTimeAsync });
48
+ await user.click(screen.getByRole("button", { name: "Continue" }));
49
+ await vi.waitFor(() => expect(calls).toBe(1));
50
+ await vi.waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
51
+ await advance(29 * MIN);
52
+ expect(onExpire).not.toHaveBeenCalled();
53
+ await advance(MIN);
54
+ expect(onExpire).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it("any answered request restarts the clock before the warning", async () => {
58
+ server.use(http.get(`${API}/deadline`, () => HttpResponse.json({})));
59
+ render(<IdleWarning signedIn onExpire={vi.fn()} />);
60
+ await advance(20 * MIN);
61
+ await apiFetch("/deadline");
62
+ await advance(20 * MIN);
63
+ expect(screen.queryByRole("alertdialog")).toBeNull();
64
+ await advance(8 * MIN);
65
+ expect(screen.getByRole("alertdialog", DIALOG)).toBeInTheDocument();
66
+ });
67
+
68
+ it("does nothing for a visitor", async () => {
69
+ const onExpire = vi.fn();
70
+ render(<IdleWarning signedIn={false} onExpire={onExpire} />);
71
+ await advance(40 * MIN);
72
+ expect(screen.queryByRole("alertdialog")).toBeNull();
73
+ expect(onExpire).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("has no axe violations", async () => {
77
+ render(<IdleWarning signedIn onExpire={vi.fn()} />);
78
+ await advance(28 * MIN);
79
+ vi.useRealTimers();
80
+ expect((await axe(document.body)).violations).toEqual([]);
81
+ });
82
+ });
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState, useSyncExternalStore } from "react";
2
+ import { Dialog } from "radix-ui";
3
+ import { useTranslation } from "react-i18next";
4
+ import { apiFetch, lastActivity, subscribeActivity } from "@/shared/api";
5
+ import { Button } from "@/shared/ui/button";
6
+
7
+ /** Warn 2 min before the 30 min idle timeout: the 60 s refresh rule can put the server up to 1 min ahead (OI-11, OI-22). */
8
+ export const WARN_AFTER_MS = 28 * 60_000;
9
+ export const END_AFTER_MS = 30 * 60_000;
10
+
11
+ /**
12
+ * Idle-timeout warning (WCAG 2.2.1, Frontend.md §8.1). 28 minutes after the last server request a dialog
13
+ * offers "Continue", which calls `GET /auth/me` (that refreshes the session and restarts both timers).
14
+ * With no answer the session ends at 30 minutes like any other 401 (`onExpire`).
15
+ */
16
+ export function IdleWarning({
17
+ signedIn,
18
+ onExpire,
19
+ }: {
20
+ signedIn: boolean;
21
+ onExpire: () => void;
22
+ }) {
23
+ const { t } = useTranslation();
24
+ const last = useSyncExternalStore(subscribeActivity, lastActivity);
25
+ // The `last` the warning fired for: a newer request makes it stale, which closes the dialog without an effect.
26
+ const [warnedFor, setWarnedFor] = useState<number | null>(null);
27
+ const warning = signedIn && warnedFor === last;
28
+
29
+ useEffect(() => {
30
+ if (!signedIn) return;
31
+ const elapsed = Date.now() - last;
32
+ const warn = setTimeout(() => setWarnedFor(last), Math.max(0, WARN_AFTER_MS - elapsed));
33
+ const end = setTimeout(() => {
34
+ setWarnedFor(null);
35
+ onExpire();
36
+ }, Math.max(0, END_AFTER_MS - elapsed));
37
+ return () => {
38
+ clearTimeout(warn);
39
+ clearTimeout(end);
40
+ };
41
+ }, [signedIn, last, onExpire]);
42
+
43
+ // A failure needs no handling here: a 401 already ends the session through the global handler.
44
+ const keepAlive = () => void apiFetch("/auth/me").catch(() => undefined);
45
+
46
+ return (
47
+ <Dialog.Root open={warning} onOpenChange={(open) => !open && keepAlive()}>
48
+ <Dialog.Portal>
49
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
50
+ <Dialog.Content
51
+ role="alertdialog"
52
+ className="fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg"
53
+ onInteractOutside={(e) => e.preventDefault()}
54
+ >
55
+ <Dialog.Title className="text-lg font-semibold">{t("pages.idle.title")}</Dialog.Title>
56
+ <Dialog.Description className="mt-2 text-sm text-muted-foreground">
57
+ {t("pages.idle.body")}
58
+ </Dialog.Description>
59
+ <div className="mt-6 flex justify-end">
60
+ <Button onClick={keepAlive}>{t("pages.idle.continue")}</Button>
61
+ </div>
62
+ </Dialog.Content>
63
+ </Dialog.Portal>
64
+ </Dialog.Root>
65
+ );
66
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from "@/shared/ui/field";
13
13
  import { Input } from "@/shared/ui/input";
14
14
  import { login, type Me } from "./login";
15
+ import { useCountdown } from "./useCountdown";
15
16
 
16
17
  /**
17
18
  * The login form. Every credential failure is the same 401 `INVALID_CREDENTIALS` (PRD §6.5), so
@@ -33,6 +34,9 @@ export function LoginForm({
33
34
  const [errorKey, setErrorKey] = useState(0);
34
35
  const [pending, setPending] = useState(false);
35
36
  const alert = useRef<HTMLParagraphElement>(null);
37
+ const countdown = useCountdown();
38
+ const [wasThrottled, setWasThrottled] = useState(false);
39
+ const throttled = countdown.remaining > 0;
36
40
 
37
41
  // after the message is in the DOM (WCAG 3.3.1)
38
42
  useEffect(() => {
@@ -41,9 +45,10 @@ export function LoginForm({
41
45
 
42
46
  const onSubmit = async (e: FormEvent) => {
43
47
  e.preventDefault();
44
- if (pending) return;
48
+ if (pending || throttled) return;
45
49
  setPending(true);
46
50
  setError(null);
51
+ setWasThrottled(false);
47
52
  try {
48
53
  onLoggedIn(await login({ empNo: empNo.trim(), password }));
49
54
  } catch (err) {
@@ -51,6 +56,10 @@ export function LoginForm({
51
56
  err instanceof ApiError
52
57
  ? err
53
58
  : new ApiError({ status: 0, code: "UNKNOWN" });
59
+ if (apiErr.status === 429 && apiErr.retryAfterSeconds !== undefined) {
60
+ countdown.start(apiErr.retryAfterSeconds);
61
+ setWasThrottled(true);
62
+ }
54
63
  setError(
55
64
  t(`errors.${apiErr.code}`) +
56
65
  (apiErr.requestId
@@ -107,6 +116,11 @@ export function LoginForm({
107
116
  {error}
108
117
  </p>
109
118
  )}
119
+ {wasThrottled && !throttled && (
120
+ <p role="status" className="text-sm">
121
+ {t("pages.login.canRetry")}
122
+ </p>
123
+ )}
110
124
  <Field>
111
125
  <FieldLabel htmlFor="empNo">{t("fields.empNo")}</FieldLabel>
112
126
  <Input
@@ -134,10 +148,14 @@ export function LoginForm({
134
148
  <Field>
135
149
  <Button
136
150
  type="submit"
137
- disabled={pending}
151
+ disabled={pending || throttled}
138
152
  aria-busy={pending || undefined}
139
153
  >
140
- {pending ? t("pages.login.submitting") : t("nav.signIn")}
154
+ {pending
155
+ ? t("pages.login.submitting")
156
+ : throttled
157
+ ? t("pages.login.retryIn", { seconds: countdown.remaining })
158
+ : t("nav.signIn")}
141
159
  </Button>
142
160
  <FieldDescription className="text-center">
143
161
  {t("pages.login.noAccount")}{" "}
@@ -0,0 +1,73 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { axe } from "vitest-axe";
3
+ import { initI18n } from "@/shared/i18n";
4
+ import { PasswordChecklist } from "./PasswordChecklist";
5
+
6
+ const statuses = () =>
7
+ Object.fromEntries(
8
+ Array.from(document.querySelectorAll("li[data-rule]")).map((li) => [
9
+ li.getAttribute("data-rule"),
10
+ li.getAttribute("data-status"),
11
+ ]),
12
+ );
13
+
14
+ /** Renders and waits until the blocklist chunk is in (COMMON_PASSWORD no longer "server"). */
15
+ async function show(password: string, empNo = "") {
16
+ const view = render(<PasswordChecklist id="rules" password={password} empNo={empNo} />);
17
+ if (password !== "")
18
+ await waitFor(() => expect(statuses().COMMON_PASSWORD).not.toBe("server"));
19
+ return view;
20
+ }
21
+
22
+ beforeEach(async () => {
23
+ await initI18n("en");
24
+ });
25
+
26
+ describe("PasswordChecklist", () => {
27
+ it("lists the 8 rules in PRD §6.4 order, neutral while empty", async () => {
28
+ await show("");
29
+ expect(Object.keys(statuses())).toEqual([
30
+ "LENGTH",
31
+ "CHARSET",
32
+ "COMPLEXITY",
33
+ "COMMON_PASSWORD",
34
+ "KEYBOARD_RUN",
35
+ "SEQUENCE",
36
+ "REPEAT",
37
+ "CONTAINS_EMP_NO",
38
+ ]);
39
+ expect(new Set(Object.values(statuses()))).toEqual(new Set(["idle"]));
40
+ });
41
+
42
+ it("marks each rule met or not met as the password changes", async () => {
43
+ const { rerender } = await show("password");
44
+ expect(statuses()).toMatchObject({
45
+ LENGTH: "unmet",
46
+ CHARSET: "met",
47
+ COMPLEXITY: "unmet",
48
+ COMMON_PASSWORD: "unmet",
49
+ KEYBOARD_RUN: "met",
50
+ });
51
+ rerender(<PasswordChecklist id="rules" password="Correct-Horse-1" empNo="089290" />);
52
+ expect(new Set(Object.values(statuses()))).toEqual(new Set(["met"]));
53
+ });
54
+
55
+ it("checks the employee ID only when it is well-formed (as the server)", async () => {
56
+ const { rerender } = await show("Correct-089290-Ab", "089290");
57
+ expect(statuses().CONTAINS_EMP_NO).toBe("unmet");
58
+ rerender(<PasswordChecklist id="rules" password="Correct-089290-Ab" empNo="08929" />);
59
+ expect(statuses().CONTAINS_EMP_NO).toBe("met");
60
+ });
61
+
62
+ it("says the state in text, not colour alone (WCAG 1.4.1), and has no axe violations", async () => {
63
+ const { container } = await show("password");
64
+ expect(screen.getByText(/be 12–64 characters long/).textContent).toContain("(not met)");
65
+ expect(screen.getByText(/use only printable ASCII/).textContent).toContain("(met)");
66
+ expect((await axe(container)).violations).toEqual([]);
67
+ });
68
+
69
+ it("is not a live region (no announcement on every keystroke)", async () => {
70
+ const { container } = await show("x");
71
+ expect(container.querySelector("[aria-live],[role=status],[role=alert]")).toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,96 @@
1
+ import { Check, Circle, X } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { cn } from "@/shared/ui/utils";
5
+ import { loadCommonPasswords } from "./common-passwords";
6
+ import {
7
+ checkPassword,
8
+ empNoForPolicy,
9
+ PASSWORD_RULES,
10
+ type CommonCheck,
11
+ type PasswordRule,
12
+ } from "./password-policy";
13
+
14
+ /** `idle`: nothing typed yet; `server`: the blocklist is not (yet) loaded, the server checks this rule. */
15
+ type Status = "met" | "unmet" | "idle" | "server";
16
+
17
+ type ListState = { check: CommonCheck } | { failed: true } | null;
18
+
19
+ /** Loads the blocklist chunk on mount; until it arrives (or if it fails) COMMON_PASSWORD is left to the server. */
20
+ function useCommonList(): CommonCheck | undefined {
21
+ const [state, setState] = useState<ListState>(null);
22
+ useEffect(() => {
23
+ let live = true;
24
+ loadCommonPasswords().then(
25
+ (check) => live && setState({ check }),
26
+ () => live && setState({ failed: true }),
27
+ );
28
+ return () => {
29
+ live = false;
30
+ };
31
+ }, []);
32
+ return state && "check" in state ? state.check : undefined;
33
+ }
34
+
35
+ const ICON = { met: Check, unmet: X, idle: Circle, server: Circle } as const;
36
+
37
+ /**
38
+ * Live checklist of the 8 password rules (PRD §6.4, Frontend.md §5.6), keyed by
39
+ * the rule ids of PASSWORD_POLICY. It is not a live region: the password field
40
+ * points at it with `aria-describedby`, so the current state is read on focus
41
+ * instead of after every keystroke. State is carried by text, not colour alone.
42
+ */
43
+ export function PasswordChecklist({
44
+ id,
45
+ password,
46
+ empNo,
47
+ }: {
48
+ id: string;
49
+ password: string;
50
+ empNo: string;
51
+ }) {
52
+ const { t } = useTranslation();
53
+ const isCommon = useCommonList();
54
+ const violated = new Set<PasswordRule>(
55
+ checkPassword(password, empNoForPolicy(empNo), isCommon),
56
+ );
57
+ const status = (r: PasswordRule): Status => {
58
+ if (password === "") return "idle";
59
+ if (r === "COMMON_PASSWORD" && !isCommon) return "server";
60
+ return violated.has(r) ? "unmet" : "met";
61
+ };
62
+
63
+ return (
64
+ <div id={id} className="text-sm">
65
+ <p className="text-muted-foreground">{t("passwordRules.title")}</p>
66
+ <ul className="mt-1 space-y-0.5">
67
+ {PASSWORD_RULES.map((r) => {
68
+ const s = status(r);
69
+ const Icon = ICON[s];
70
+ return (
71
+ <li
72
+ key={r}
73
+ data-rule={r}
74
+ data-status={s}
75
+ className={cn(
76
+ "flex items-start gap-1.5",
77
+ s === "met" && "text-emerald-700 dark:text-emerald-400",
78
+ s === "unmet" && "text-destructive",
79
+ (s === "idle" || s === "server") && "text-muted-foreground",
80
+ )}
81
+ >
82
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
83
+ <span>
84
+ {t(`passwordRules.${r}`)}
85
+ <span className="sr-only">
86
+ {" "}
87
+ ({t(`passwordRules.status.${s}`)})
88
+ </span>
89
+ </span>
90
+ </li>
91
+ );
92
+ })}
93
+ </ul>
94
+ </div>
95
+ );
96
+ }
@@ -10,6 +10,7 @@ import {
10
10
  FieldLabel,
11
11
  } from "@/shared/ui/field";
12
12
  import { Input } from "@/shared/ui/input";
13
+ import { PasswordChecklist } from "./PasswordChecklist";
13
14
  import {
14
15
  mapServerError,
15
16
  precheck,
@@ -48,7 +49,6 @@ const FIELD_ATTRS: Record<
48
49
  const HINTS: Partial<Record<RegisterField, string>> = {
49
50
  empNo: "pages.register.empNoHint",
50
51
  email: "pages.register.emailHint",
51
- password: "pages.register.passwordHint",
52
52
  };
53
53
 
54
54
  /** The register form: format pre-check, error summary with focus, server errors mapped to fields (Frontend.md §5.6, §8). */
@@ -155,8 +155,10 @@ export function RegisterForm({
155
155
  const a = FIELD_ATTRS[f];
156
156
  const problem = problems[f];
157
157
  const hint = HINTS[f];
158
+ // the password's hint is the live rule checklist (F1b-01)
159
+ const hintId = f === "password" ? "password-rules" : hint && `${f}-hint`;
158
160
  const describedBy =
159
- [hint && `${f}-hint`, problem && `${f}-error`]
161
+ [hintId, problem && `${f}-error`]
160
162
  .filter(Boolean)
161
163
  .join(" ") || undefined;
162
164
  return (
@@ -180,6 +182,13 @@ export function RegisterForm({
180
182
  {hint && (
181
183
  <FieldDescription id={`${f}-hint`}>{t(hint)}</FieldDescription>
182
184
  )}
185
+ {f === "password" && (
186
+ <PasswordChecklist
187
+ id="password-rules"
188
+ password={values.password}
189
+ empNo={values.empNo}
190
+ />
191
+ )}
183
192
  {problem && (
184
193
  <FieldError id={`${f}-error`} role={undefined}>
185
194
  {message(problem)}
@@ -0,0 +1,31 @@
1
+ import { foldCase, type CommonCheck } from "./password-policy";
2
+
3
+ /** Builds the lookup from the list file: one entry per line, folded, `\r` and empty lines dropped. */
4
+ export function parseCommonList(text: string): CommonCheck {
5
+ const set = new Set<string>();
6
+ for (const line of text.split("\n")) {
7
+ const l = line.endsWith("\r") ? line.slice(0, -1) : line;
8
+ if (l !== "") set.add(foldCase(l));
9
+ }
10
+ // the whole password as typed: nothing trimmed, an entry inside a longer password does not match
11
+ return (pw) => set.has(foldCase(pw));
12
+ }
13
+
14
+ let loading: Promise<CommonCheck> | undefined;
15
+
16
+ /**
17
+ * Loads the bundled Top-10,000 list (the backend's own file, NOTICE next to it)
18
+ * as a separate chunk, once. Only the register page calls this, so no other
19
+ * page downloads it (Frontend.md §5.6). A failed load is not cached, so a
20
+ * later mount may retry.
21
+ */
22
+ export function loadCommonPasswords(): Promise<CommonCheck> {
23
+ loading ??= import("passwords/common-passwords.txt?raw").then(
24
+ (m) => parseCommonList(m.default),
25
+ (err: unknown) => {
26
+ loading = undefined;
27
+ throw err;
28
+ },
29
+ );
30
+ return loading;
31
+ }
@@ -0,0 +1,64 @@
1
+ import cases from "testdata/password.json";
2
+ import { loadCommonPasswords, parseCommonList } from "./common-passwords";
3
+ import { checkPassword, empNoForPolicy, foldCase, PASSWORD_RULES } from "./password-policy";
4
+
5
+ describe("checkPassword: testdata/password.json (same cases as the backend)", () => {
6
+ it.each(cases.cases.map((c) => [c.id, c] as const))("%s", async (_id, c) => {
7
+ const isCommon = await loadCommonPasswords();
8
+ expect(checkPassword(c.password, c.empNo, isCommon)).toEqual(c.wantRules);
9
+ });
10
+
11
+ it("covers every rule id at least once", () => {
12
+ const seen = new Set(cases.cases.flatMap((c) => c.wantRules));
13
+ expect(PASSWORD_RULES.filter((r) => !seen.has(r))).toEqual([]);
14
+ });
15
+ });
16
+
17
+ describe("checkPassword: client specifics", () => {
18
+ it("without the blocklist never reports COMMON_PASSWORD, the rest unchanged", () => {
19
+ for (const c of cases.cases) {
20
+ expect(checkPassword(c.password, c.empNo)).toEqual(
21
+ c.wantRules.filter((r) => r !== "COMMON_PASSWORD"),
22
+ );
23
+ }
24
+ });
25
+
26
+ it("counts a lone surrogate as one code point outside printable ASCII", () => {
27
+ expect(checkPassword("Correct-Hors\uD800", "")).toEqual(["CHARSET"]);
28
+ });
29
+
30
+ it("folds case like Go strings.ToLower, not toLowerCase (U+0130, U+212A)", () => {
31
+ expect(foldCase("PASSİK")).toBe("passik");
32
+ // U+212A (Kelvin) folds to k, so "jkl;" is a keyboard run on the server too
33
+ expect(checkPassword("Zq-JKL;-Mx81", "")).toEqual(["CHARSET", "KEYBOARD_RUN"]);
34
+ });
35
+ });
36
+
37
+ describe("empNoForPolicy", () => {
38
+ it.each([
39
+ ["089290", "089290"],
40
+ ["08929", ""],
41
+ ["0892901", ""],
42
+ ["08929a", ""],
43
+ ["089290", ""],
44
+ ])("%j → %j", (input, want) => expect(empNoForPolicy(input)).toBe(want));
45
+ });
46
+
47
+ describe("common-password list", () => {
48
+ it("loads once as a lazy chunk and matches whole passwords ignoring case", async () => {
49
+ const a = loadCommonPasswords();
50
+ expect(loadCommonPasswords()).toBe(a);
51
+ const isCommon = await a;
52
+ expect(isCommon("password")).toBe(true);
53
+ expect(isCommon("PassWord")).toBe(true);
54
+ expect(isCommon(" password")).toBe(false);
55
+ expect(isCommon("password1x")).toBe(false);
56
+ });
57
+
58
+ it("parses CRLF lines and skips empty ones", () => {
59
+ const isCommon = parseCommonList("Alpha\r\n\r\nbeta\n");
60
+ expect(isCommon("alpha")).toBe(true);
61
+ expect(isCommon("BETA")).toBe(true);
62
+ expect(isCommon("")).toBe(false);
63
+ });
64
+ });