@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
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Password policy of PRD §6.4, ported rule for rule from the backend
3
+ * (backend/internal/account/password.go, keyboard.go, sequence.go) and checked
4
+ * against the same cases (testdata/password.json). The server stays the
5
+ * authority; this drives the live checklist on the register page.
6
+ */
7
+
8
+ /** Rule ids in PRD §6.4 table order; they are the wire contract (`errors[].rule`). */
9
+ export const PASSWORD_RULES = [
10
+ "LENGTH",
11
+ "CHARSET",
12
+ "COMPLEXITY",
13
+ "COMMON_PASSWORD",
14
+ "KEYBOARD_RUN",
15
+ "SEQUENCE",
16
+ "REPEAT",
17
+ "CONTAINS_EMP_NO",
18
+ ] as const;
19
+ export type PasswordRule = (typeof PASSWORD_RULES)[number];
20
+
21
+ /** Whole-password blocklist lookup (case-insensitive); see `common-passwords.ts`. */
22
+ export type CommonCheck = (password: string) => boolean;
23
+
24
+ const MIN_LEN = 12;
25
+ const MAX_LEN = 64; // code points
26
+ const MIN_CLASSES = 3;
27
+ const MAX_REPEAT = 2; // 3+ identical characters in a row is a violation
28
+ const MIN_KEYBOARD_RUN = 4;
29
+ const MIN_SEQUENCE = 4;
30
+
31
+ /** The password as code points (a JS string is UTF-16; the server counts code points). */
32
+ const codePoints = (s: string): number[] => Array.from(s, (c) => c.codePointAt(0)!);
33
+
34
+ /**
35
+ * Lower-cases one code point exactly as Go's `strings.ToLower` does for every
36
+ * result that matters here (ASCII): A–Z, plus the only two non-ASCII code
37
+ * points whose lower case is ASCII, U+0130 → `i` and U+212A (Kelvin) → `k`.
38
+ * `String.prototype.toLowerCase` differs (U+0130 becomes two code points).
39
+ */
40
+ const foldCp = (cp: number): number =>
41
+ cp >= 0x41 && cp <= 0x5a ? cp + 0x20 : cp === 0x130 ? 0x69 : cp === 0x212a ? 0x6b : cp;
42
+
43
+ /** Case-folds a string with {@link foldCp}; used for the blocklist lookup. */
44
+ export const foldCase = (s: string): string =>
45
+ String.fromCodePoint(...codePoints(s).map(foldCp));
46
+
47
+ const violatesLength = (cps: number[]) => cps.length < MIN_LEN || cps.length > MAX_LEN;
48
+
49
+ const violatesCharset = (cps: number[]) => cps.some((cp) => cp < 0x20 || cp > 0x7e);
50
+
51
+ /** Upper, lower, digit, symbol (printable non-alphanumeric ASCII, space included). Non-ASCII is no class. */
52
+ function violatesComplexity(cps: number[]): boolean {
53
+ let upper = false, lower = false, digit = false, symbol = false;
54
+ for (const cp of cps) {
55
+ if (cp >= 0x41 && cp <= 0x5a) upper = true;
56
+ else if (cp >= 0x61 && cp <= 0x7a) lower = true;
57
+ else if (cp >= 0x30 && cp <= 0x39) digit = true;
58
+ else if (cp >= 0x20 && cp <= 0x7e) symbol = true;
59
+ }
60
+ return [upper, lower, digit, symbol].filter(Boolean).length < MIN_CLASSES;
61
+ }
62
+
63
+ /** The four rows of US QWERTY; rows do not join and do not wrap. */
64
+ const KEYBOARD_ROWS = ["1234567890-=", "qwertyuiop[]", "asdfghjkl;'", "zxcvbnm,./"];
65
+ /** The shifted glyph of each key, position for position (letters are folded by case instead). */
66
+ const SHIFTED_ROWS = ["!@#$%^&*()_+", "QWERTYUIOP{}", "ASDFGHJKL:\"", "ZXCVBNM<>?"];
67
+
68
+ /** Glyph → [row, column]. Glyphs on no row (` ~ \ | and anything non-ASCII) break a run. */
69
+ const KEY_OF = new Map<number, [number, number]>();
70
+ [KEYBOARD_ROWS, SHIFTED_ROWS].forEach((rows) =>
71
+ rows.forEach((row, r) =>
72
+ Array.from(row).forEach((g, c) => KEY_OF.set(g.codePointAt(0)!, [r, c])),
73
+ ),
74
+ );
75
+
76
+ /**
77
+ * Longest run of positions that step by exactly ±1 in one direction within one
78
+ * group reaches `min`. `pos` maps a code point to [group, position] or undefined
79
+ * (which breaks the run). Same position twice is not a step (that is REPEAT).
80
+ * Shared by KEYBOARD_RUN (group = row) and SEQUENCE (group = alphabet).
81
+ */
82
+ function hasRun(
83
+ cps: number[],
84
+ pos: (cp: number) => readonly [number, number] | undefined,
85
+ min: number,
86
+ ): boolean {
87
+ let prev: readonly [number, number] | undefined;
88
+ let run = 0, dir = 0;
89
+ for (const cp of cps) {
90
+ const k = pos(cp);
91
+ if (!k) {
92
+ prev = undefined;
93
+ run = 0;
94
+ dir = 0;
95
+ continue;
96
+ }
97
+ const step = prev && k[0] === prev[0] ? k[1] - prev[1] : 0;
98
+ if (step === 1 || step === -1) {
99
+ if (run >= 2 && step === dir) run++;
100
+ else {
101
+ run = 2;
102
+ dir = step;
103
+ }
104
+ } else {
105
+ run = 1;
106
+ dir = 0;
107
+ }
108
+ prev = k;
109
+ if (run >= min) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** 4+ adjacent keys on one row, either direction, case-insensitive, shifted glyph = its key. */
115
+ const violatesKeyboardRun = (cps: number[]) =>
116
+ hasRun(cps.map(foldCp), (cp) => KEY_OF.get(cp), MIN_KEYBOARD_RUN);
117
+
118
+ /** Letters a–z (case folded) are group 0, digits 0–9 group 1; anything else breaks a sequence. */
119
+ function seqKey(cp: number): [number, number] | undefined {
120
+ if (cp >= 0x61 && cp <= 0x7a) return [0, cp - 0x61];
121
+ if (cp >= 0x41 && cp <= 0x5a) return [0, cp - 0x41];
122
+ if (cp >= 0x30 && cp <= 0x39) return [1, cp - 0x30];
123
+ return undefined;
124
+ }
125
+
126
+ /** 4+ consecutive letters or digits, ascending or descending; no wrap; letters and digits never join. */
127
+ const violatesSequence = (cps: number[]) => hasRun(cps, seqKey, MIN_SEQUENCE);
128
+
129
+ /** The same code point 3+ times in a row, compared exactly ("aAa" is fine). */
130
+ function violatesRepeat(cps: number[]): boolean {
131
+ let run = 0, prev = -1;
132
+ for (const cp of cps) {
133
+ if (cp === prev) run++;
134
+ else {
135
+ run = 1;
136
+ prev = cp;
137
+ }
138
+ if (run > MAX_REPEAT) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Every violated rule in table order. `empNo` is "" when the employee ID is
145
+ * malformed (CONTAINS_EMP_NO is then skipped, as on the server). Without
146
+ * `isCommon` (blocklist not loaded) COMMON_PASSWORD is never reported.
147
+ */
148
+ export function checkPassword(
149
+ password: string,
150
+ empNo: string,
151
+ isCommon?: CommonCheck,
152
+ ): PasswordRule[] {
153
+ const cps = codePoints(password);
154
+ const violated: Record<PasswordRule, boolean> = {
155
+ LENGTH: violatesLength(cps),
156
+ CHARSET: violatesCharset(cps),
157
+ COMPLEXITY: violatesComplexity(cps),
158
+ COMMON_PASSWORD: isCommon?.(password) ?? false,
159
+ KEYBOARD_RUN: violatesKeyboardRun(cps),
160
+ SEQUENCE: violatesSequence(cps),
161
+ REPEAT: violatesRepeat(cps),
162
+ CONTAINS_EMP_NO: empNo !== "" && password.includes(empNo),
163
+ };
164
+ return PASSWORD_RULES.filter((r) => violated[r]);
165
+ }
166
+
167
+ /** The employee ID as the server sees it for CONTAINS_EMP_NO: "" unless it is well-formed. */
168
+ export const empNoForPolicy = (empNo: string): string => (/^[0-9]{6}$/.test(empNo) ? empNo : "");
@@ -0,0 +1,33 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useCountdown } from "./useCountdown";
4
+
5
+ describe("useCountdown", () => {
6
+ beforeEach(() => vi.useFakeTimers());
7
+ afterEach(() => vi.useRealTimers());
8
+
9
+ it("counts down in whole seconds and stops at zero", () => {
10
+ const { result } = renderHook(() => useCountdown());
11
+ expect(result.current.remaining).toBe(0);
12
+ act(() => result.current.start(3));
13
+ expect(result.current.remaining).toBe(3);
14
+ act(() => void vi.advanceTimersByTime(1000));
15
+ expect(result.current.remaining).toBe(2);
16
+ act(() => void vi.advanceTimersByTime(2000));
17
+ expect(result.current.remaining).toBe(0);
18
+ });
19
+
20
+ it("treats 0 as at least one second", () => {
21
+ const { result } = renderHook(() => useCountdown());
22
+ act(() => result.current.start(0));
23
+ expect(result.current.remaining).toBe(1);
24
+ });
25
+
26
+ it("restarts from a new value", () => {
27
+ const { result } = renderHook(() => useCountdown());
28
+ act(() => result.current.start(10));
29
+ act(() => void vi.advanceTimersByTime(4000));
30
+ act(() => result.current.start(30));
31
+ expect(result.current.remaining).toBe(30);
32
+ });
33
+ });
@@ -0,0 +1,28 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Whole seconds left of a countdown started with `start(seconds)`. It advances with
5
+ * `performance.now()`, so changing the system clock does not affect it (Frontend.md §5.3).
6
+ */
7
+ export function useCountdown() {
8
+ const [endsAt, setEndsAt] = useState<number | null>(null);
9
+ const [remaining, setRemaining] = useState(0);
10
+
11
+ useEffect(() => {
12
+ if (endsAt === null) return;
13
+ const tick = () => {
14
+ const left = Math.max(0, Math.ceil((endsAt - performance.now()) / 1000));
15
+ setRemaining(left);
16
+ if (left === 0) setEndsAt(null);
17
+ };
18
+ tick();
19
+ const id = setInterval(tick, 250);
20
+ return () => clearInterval(id);
21
+ }, [endsAt]);
22
+
23
+ const start = useCallback((seconds: number) => {
24
+ setEndsAt(performance.now() + Math.max(1, seconds) * 1000);
25
+ }, []);
26
+
27
+ return { remaining, start };
28
+ }
@@ -0,0 +1,279 @@
1
+ import { useRef, useState, type FormEvent, type ReactNode } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { Button } from "@/shared/ui/button";
4
+ import { CopyButton } from "@/shared/ui/copy-button";
5
+ import {
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldGroup,
10
+ FieldLabel,
11
+ } from "@/shared/ui/field";
12
+ import { Input } from "@/shared/ui/input";
13
+ import { NativeSelect } from "@/shared/ui/native-select";
14
+ import { Textarea } from "@/shared/ui/textarea";
15
+ import { SLIDE_EXTS } from "@/shared/slides";
16
+ import { cleanName, cleanSummary, countCodePoints } from "@/shared/text";
17
+ import {
18
+ CATEGORIES,
19
+ createIdea,
20
+ DIRECTIONS,
21
+ expectedBaseFor,
22
+ IDEA_FIELDS,
23
+ mapServerError,
24
+ NAME_MAX,
25
+ ORGS,
26
+ precheck,
27
+ SUMMARY_MAX,
28
+ type FieldProblems,
29
+ type IdeaDetail,
30
+ type IdeaField,
31
+ type IdeaText,
32
+ type Leader,
33
+ } from "./create";
34
+
35
+ const EMPTY: IdeaText = { category: "", org: "", direction: "", name: "", summary: "" };
36
+
37
+ const OPTIONS = {
38
+ category: { list: CATEGORIES, key: "enums.category" },
39
+ org: { list: ORGS, key: "enums.org" },
40
+ direction: { list: DIRECTIONS, key: "enums.direction" },
41
+ } as const;
42
+
43
+ /**
44
+ * The create form (Frontend.md §5.5, §5.6, §8): code-point counters, the expected
45
+ * filename with copy buttons, a pre-check in the server's order, an error summary
46
+ * that takes focus, and server errors mapped to fields. The file is never renamed
47
+ * (OI-21): the user names it.
48
+ */
49
+ export function CreateIdeaForm({
50
+ leader,
51
+ canSubmit,
52
+ onCreated,
53
+ onSubmissionRejected,
54
+ }: {
55
+ leader: Leader;
56
+ /** False outside the submission window; the server still decides (Frontend.md §6.3 rule 6). */
57
+ canSubmit: boolean;
58
+ onCreated: (idea: IdeaDetail) => void;
59
+ /** A 403 SUBMISSION_* answer: the caller refetches the deadline (Frontend.md §5.3). */
60
+ onSubmissionRejected?: () => void;
61
+ }) {
62
+ const { t, i18n } = useTranslation();
63
+ const [values, setValues] = useState<IdeaText>(EMPTY);
64
+ const [files, setFiles] = useState<File[]>([]);
65
+ const [problems, setProblems] = useState<FieldProblems>({});
66
+ const [serverExpected, setServerExpected] = useState<string | undefined>();
67
+ const [formError, setFormError] = useState<string | null>(null);
68
+ const [pending, setPending] = useState(false);
69
+ const [summaryKey, setSummaryKey] = useState(0);
70
+ const summary = useRef<HTMLDivElement>(null);
71
+ const form = useRef<HTMLFormElement>(null);
72
+
73
+ const expectedBase = expectedBaseFor(values, leader);
74
+ const fileExt = files[0]?.name.split(".").pop()?.toLowerCase();
75
+
76
+ const message = (f: IdeaField, code: string): string => {
77
+ const text = i18n.exists(`errors.${code}`) ? t(`errors.${code}`) : t("errors.VALIDATION_FAILED");
78
+ if (f !== "slides" || code !== "FILENAME_MISMATCH") return text;
79
+ const expected =
80
+ serverExpected ??
81
+ (expectedBase && `${expectedBase}.${fileExt === "pptx" ? "pptx" : "pdf"}`);
82
+ return expected ? `${text} ${t("pages.ideaForm.expectedIs", { name: expected })}` : text;
83
+ };
84
+
85
+ const fail = (p: FieldProblems, rest: string | null) => {
86
+ setProblems(p);
87
+ setFormError(rest);
88
+ setSummaryKey((k) => k + 1);
89
+ queueMicrotask(() => summary.current?.focus()); // WCAG 3.3.1
90
+ };
91
+
92
+ const onSubmit = async (e: FormEvent) => {
93
+ e.preventDefault();
94
+ if (pending || !canSubmit) return;
95
+ setServerExpected(undefined);
96
+ const { values: body, problems: local } = await precheck(values, files, leader);
97
+ if (Object.keys(local).length > 0) {
98
+ fail(local, null);
99
+ return;
100
+ }
101
+ setPending(true);
102
+ setProblems({});
103
+ setFormError(null);
104
+ try {
105
+ onCreated(await createIdea(body, files[0]!));
106
+ } catch (err) {
107
+ const { problems: p, rest, expected } = mapServerError(err);
108
+ setServerExpected(expected);
109
+ if (rest?.code === "SUBMISSION_CLOSED" || rest?.code === "SUBMISSION_NOT_OPEN") onSubmissionRejected?.();
110
+ fail(
111
+ p,
112
+ rest
113
+ ? t(`errors.${rest.code}`) + (rest.requestId ? ` ${t("common.requestId", { id: rest.requestId })}` : "")
114
+ : null,
115
+ );
116
+ } finally {
117
+ setPending(false);
118
+ }
119
+ };
120
+
121
+ const focusField = (f: IdeaField) => (e: { preventDefault: () => void }) => {
122
+ e.preventDefault();
123
+ form.current?.querySelector<HTMLElement>(`#${f}`)?.focus();
124
+ };
125
+
126
+ const set = (f: keyof IdeaText) => (e: { target: { value: string } }) =>
127
+ setValues((v) => ({ ...v, [f]: e.target.value }));
128
+
129
+ /** Common ARIA wiring of a control; `extra` ids (hint, counter) come first. */
130
+ const aria = (f: IdeaField, ...extra: string[]) => {
131
+ const problem = problems[f];
132
+ return {
133
+ id: f,
134
+ name: f,
135
+ required: true,
136
+ "aria-required": true as const,
137
+ "aria-invalid": problem ? true : undefined,
138
+ "aria-describedby": [...extra, problem && `${f}-error`].filter(Boolean).join(" ") || undefined,
139
+ };
140
+ };
141
+
142
+ const wrap = (f: IdeaField, control: ReactNode, below?: ReactNode) => (
143
+ <Field key={f} data-invalid={problems[f] ? true : undefined}>
144
+ <FieldLabel htmlFor={f}>{t(`fields.${f === "name" ? "ideaName" : f}`)}</FieldLabel>
145
+ {control}
146
+ {below}
147
+ {problems[f] && (
148
+ <FieldError id={`${f}-error`} role={undefined}>
149
+ {message(f, problems[f])}
150
+ </FieldError>
151
+ )}
152
+ </Field>
153
+ );
154
+
155
+ const counter = (f: "name" | "summary", n: number, max: number) => (
156
+ <FieldDescription id={`${f}-count`} className={n > max ? "text-destructive" : undefined}>
157
+ {t("pages.ideaForm.count", { n, max })}
158
+ </FieldDescription>
159
+ );
160
+
161
+ const listed = IDEA_FIELDS.filter((f) => problems[f] !== undefined);
162
+ const hasSummary = listed.length > 0 || formError !== null;
163
+ const nameCount = countCodePoints(cleanName(values.name));
164
+ const summaryCount = countCodePoints(cleanSummary(values.summary));
165
+
166
+ return (
167
+ <form ref={form} onSubmit={(e) => void onSubmit(e)} noValidate>
168
+ <FieldGroup>
169
+ {hasSummary && (
170
+ <div
171
+ key={summaryKey}
172
+ ref={summary}
173
+ role="alert"
174
+ tabIndex={-1}
175
+ className="rounded-md border border-destructive p-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-destructive/40"
176
+ >
177
+ <p className="font-medium text-destructive">{t("pages.register.summaryTitle")}</p>
178
+ {formError !== null && <p>{formError}</p>}
179
+ {listed.length > 0 && (
180
+ <ul className="mt-1 list-disc pl-5">
181
+ {listed.map((f) => (
182
+ <li key={f}>
183
+ <a href={`#${f}`} onClick={focusField(f)} className="underline underline-offset-4">
184
+ {t(`fields.${f === "name" ? "ideaName" : f}`)}: {message(f, problems[f]!)}
185
+ </a>
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ )}
190
+ </div>
191
+ )}
192
+
193
+ <div className="flex flex-col gap-1 text-sm">
194
+ <p className="font-medium">{t("fields.leader")}</p>
195
+ <p>
196
+ {leader.name} ({leader.empNo})
197
+ </p>
198
+ <p className="text-muted-foreground">{t("pages.ideaForm.leaderHint")}</p>
199
+ </div>
200
+
201
+ {(["category", "org", "direction"] as const).map((f) =>
202
+ wrap(
203
+ f,
204
+ <NativeSelect {...aria(f)} value={values[f]} onChange={set(f)}>
205
+ <option value="">{t("pages.ideaForm.choose")}</option>
206
+ {OPTIONS[f].list.map((code) => (
207
+ <option key={code} value={code}>
208
+ {t(`${OPTIONS[f].key}.${code}`)}
209
+ </option>
210
+ ))}
211
+ </NativeSelect>,
212
+ ),
213
+ )}
214
+
215
+ {wrap(
216
+ "name",
217
+ <Input {...aria("name", "name-count", "name-hint")} autoComplete="off" value={values.name} onChange={set("name")} />,
218
+ <>
219
+ {counter("name", nameCount, NAME_MAX)}
220
+ <FieldDescription id="name-hint">{t("pages.ideaForm.nameHint")}</FieldDescription>
221
+ </>,
222
+ )}
223
+
224
+ {wrap(
225
+ "summary",
226
+ <Textarea {...aria("summary", "summary-count")} rows={6} value={values.summary} onChange={set("summary")} />,
227
+ counter("summary", summaryCount, SUMMARY_MAX),
228
+ )}
229
+
230
+ <section aria-labelledby="expected-title" className="rounded-md border bg-muted/40 p-3 text-sm">
231
+ <h2 id="expected-title" className="font-medium">
232
+ {t("pages.ideaForm.expectedTitle")}
233
+ </h2>
234
+ {expectedBase === null ? (
235
+ <p className="mt-1 text-muted-foreground">{t("pages.ideaForm.expectedPending")}</p>
236
+ ) : (
237
+ <>
238
+ <p className="mt-1 text-muted-foreground">{t("pages.ideaForm.expectedHint")}</p>
239
+ <ul className="mt-2 flex flex-col gap-2">
240
+ {SLIDE_EXTS.map((ext) => {
241
+ const name = `${expectedBase}.${ext}`;
242
+ return (
243
+ <li key={ext} className="flex items-center gap-2">
244
+ <code className="min-w-0 flex-1 break-all rounded bg-background px-2 py-1">{name}</code>
245
+ <CopyButton
246
+ value={name}
247
+ label={t("pages.ideaForm.copy", { ext: ext.toUpperCase() })}
248
+ copiedText={t("pages.ideaForm.copied")}
249
+ failedText={t("pages.ideaForm.copyFailed")}
250
+ />
251
+ </li>
252
+ );
253
+ })}
254
+ </ul>
255
+ </>
256
+ )}
257
+ </section>
258
+
259
+ {wrap(
260
+ "slides",
261
+ <Input
262
+ {...aria("slides", "slides-hint")}
263
+ type="file"
264
+ accept=".pdf,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation"
265
+ onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
266
+ />,
267
+ <FieldDescription id="slides-hint">{t("pages.ideaForm.slidesHint")}</FieldDescription>,
268
+ )}
269
+
270
+ <Field>
271
+ {!canSubmit && <FieldDescription>{t("pages.ideaCreate.closedHint")}</FieldDescription>}
272
+ <Button type="submit" disabled={pending || !canSubmit} aria-busy={pending || undefined}>
273
+ {pending ? t("pages.ideaCreate.submitting") : t("pages.ideaCreate.submit")}
274
+ </Button>
275
+ </Field>
276
+ </FieldGroup>
277
+ </form>
278
+ );
279
+ }
@@ -0,0 +1,123 @@
1
+ import { ApiError, apiFetch, type components } from '@/shared/api';
2
+ import { CATEGORIES, expectedFilenameBase, filenameTooLong, precheckSlides, type Category } from '@/shared/slides';
3
+ import { checkName, checkSummary } from '@/shared/text';
4
+
5
+ export type IdeaDetail = components['schemas']['IdeaDetail'];
6
+
7
+ /** Form-field order; also the order of the error summary (error-codes.md §2). */
8
+ export const IDEA_FIELDS = ['category', 'org', 'direction', 'name', 'summary', 'slides'] as const;
9
+ export type IdeaField = (typeof IDEA_FIELDS)[number];
10
+ export type TextField = Exclude<IdeaField, 'slides'>;
11
+
12
+ export const ORGS = ['AAID', 'BSID', 'ICSD', 'TSID', 'PLED', 'DEID'] as const;
13
+ export const DIRECTIONS = [
14
+ 'GREEN_MANUFACTURING',
15
+ 'RESPONSIBLE_SUPPLY_CHAIN',
16
+ 'INCLUSIVE_WORKPLACE',
17
+ 'TALENT_DEVELOPMENT',
18
+ 'CARING_FOR_DISADVANTAGED',
19
+ ] as const;
20
+ export { CATEGORIES };
21
+
22
+ export const NAME_MAX = 75;
23
+ export const SUMMARY_MAX = 300;
24
+
25
+ export type IdeaText = Record<TextField, string>;
26
+ /** A field problem is an `errors.<code>` key. */
27
+ export type FieldProblems = Partial<Record<IdeaField, string>>;
28
+
29
+ export interface Leader {
30
+ name: string;
31
+ empNo: string;
32
+ }
33
+
34
+ const isCategory = (v: string): v is Category => (CATEGORIES as readonly string[]).includes(v);
35
+ const isOneOf = (list: readonly string[], v: string) => list.includes(v);
36
+
37
+ /**
38
+ * The expected filename base for the current input, or null while category, org or
39
+ * name cannot produce one. Built from the cleaned name, as the server does.
40
+ */
41
+ export function expectedBaseFor(v: Pick<IdeaText, 'category' | 'org' | 'name'>, leader: Leader): string | null {
42
+ const name = checkName(v.name, NAME_MAX);
43
+ if (!isCategory(v.category) || !isOneOf(ORGS, v.org) || name.problem !== '') return null;
44
+ return expectedFilenameBase(v.category, v.org, leader.name, leader.empNo, name.cleaned);
45
+ }
46
+
47
+ /**
48
+ * Text checks in the server's order (PRD §6.2, §6.6), one problem per field;
49
+ * `FILENAME_TOO_LONG` is reported on `name` like the server does. Returns the
50
+ * cleaned values that are sent, and the expected base when the text is valid.
51
+ */
52
+ export function precheckText(v: IdeaText, leader: Leader): { values: IdeaText; problems: FieldProblems; expectedBase: string | null } {
53
+ const problems: FieldProblems = {};
54
+ const pick = (f: 'category' | 'org' | 'direction', list: readonly string[]) => {
55
+ if (v[f] === '') problems[f] = 'REQUIRED';
56
+ else if (!isOneOf(list, v[f])) problems[f] = 'INVALID_ENUM';
57
+ };
58
+ pick('category', CATEGORIES);
59
+ pick('org', ORGS);
60
+ pick('direction', DIRECTIONS);
61
+
62
+ const name = checkName(v.name, NAME_MAX);
63
+ let expectedBase: string | null = null;
64
+ if (name.problem) problems.name = name.problem;
65
+ else {
66
+ expectedBase = expectedBaseFor(v, leader);
67
+ if (expectedBase !== null && filenameTooLong(expectedBase)) {
68
+ problems.name = 'FILENAME_TOO_LONG';
69
+ expectedBase = null;
70
+ }
71
+ }
72
+
73
+ const summary = checkSummary(v.summary, SUMMARY_MAX);
74
+ if (summary.problem) problems.summary = summary.problem;
75
+
76
+ return { values: { ...v, name: name.cleaned, summary: summary.cleaned }, problems, expectedBase };
77
+ }
78
+
79
+ /**
80
+ * Whole-form pre-check. A missing file is always reported; the other file steps
81
+ * run only when the text is valid, because the server does not validate the file
82
+ * otherwise (PRD §6.8) and the filename depends on the text.
83
+ */
84
+ export async function precheck(v: IdeaText, files: readonly File[], leader: Leader): Promise<{ values: IdeaText; problems: FieldProblems }> {
85
+ const { values, problems, expectedBase } = precheckText(v, leader);
86
+ const textOk = Object.keys(problems).length === 0;
87
+ const slides = files.length === 0 ? 'REQUIRED' : textOk ? await precheckSlides(files, expectedBase) : '';
88
+ if (slides) problems.slides = slides;
89
+ return { values, problems };
90
+ }
91
+
92
+ /** POST /ideas: one multipart request with every field and the slides (PRD §6.6). */
93
+ export async function createIdea(values: IdeaText, file: File): Promise<IdeaDetail> {
94
+ const body = new FormData();
95
+ for (const f of ['category', 'org', 'direction', 'name', 'summary'] as const) body.append(f, values[f]);
96
+ body.append('slides', file, file.name);
97
+ const res = await apiFetch('/ideas', { method: 'POST', body });
98
+ return (await res.json()) as IdeaDetail;
99
+ }
100
+
101
+ /** File errors that carry no `errors[]` entry; they belong to the slides field. */
102
+ const SLIDES_CODES = new Set(['FILE_TOO_LARGE', 'EMPTY_FILE', 'UNSUPPORTED_FILE_TYPE', 'FILENAME_MISMATCH']);
103
+
104
+ const isField = (f: string): f is IdeaField => (IDEA_FIELDS as readonly string[]).includes(f);
105
+
106
+ /**
107
+ * Maps a failed create to field problems; `rest` has no field (403 state, 503, …)
108
+ * and is shown as a form-level message. `expected` is the server's expected
109
+ * filename from a `FILENAME_MISMATCH`.
110
+ */
111
+ export function mapServerError(e: unknown): { problems: FieldProblems; rest: ApiError | null; expected?: string } {
112
+ if (!(e instanceof ApiError)) return { problems: {}, rest: new ApiError({ status: 0, code: 'UNKNOWN' }) };
113
+ if (e.code === 'IDEA_DUPLICATE') return { problems: { name: 'IDEA_DUPLICATE' }, rest: null };
114
+ const problems: FieldProblems = {};
115
+ let expected: string | undefined;
116
+ for (const fe of e.errors) {
117
+ if (!isField(fe.field) || problems[fe.field] !== undefined) continue;
118
+ problems[fe.field] = fe.code;
119
+ if (fe.field === 'slides' && typeof fe.expected === 'string') expected = fe.expected;
120
+ }
121
+ if (Object.keys(problems).length === 0 && SLIDES_CODES.has(e.code)) problems.slides = e.code;
122
+ return { problems, rest: Object.keys(problems).length > 0 ? null : e, expected };
123
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Time of the last answered server request (Frontend.md §8.1, OI-22). Any answer except a 401 means
3
+ * the server saw the session, so the idle clock restarts; the idle-timeout warning reads it.
4
+ */
5
+ let last = Date.now();
6
+ const listeners = new Set<() => void>();
7
+
8
+ export const lastActivity = () => last;
9
+
10
+ export function markActivity(): void {
11
+ last = Date.now();
12
+ listeners.forEach((fn) => fn());
13
+ }
14
+
15
+ export function subscribeActivity(fn: () => void): () => void {
16
+ listeners.add(fn);
17
+ return () => {
18
+ listeners.delete(fn);
19
+ };
20
+ }
@@ -1,3 +1,4 @@
1
+ import { markActivity } from './activity';
1
2
  import { API_BASE, clearCsrfToken, getCsrfToken, refreshCsrfToken } from './csrf';
2
3
  import { parseApiError, type ApiError } from './problem';
3
4
 
@@ -73,6 +74,7 @@ export async function apiFetch(path: string, init: ApiRequestInit = {}): Promise
73
74
  res = await send(await refreshCsrfToken());
74
75
  }
75
76
 
77
+ if (res.status !== 401) markActivity();
76
78
  if (res.ok) {
77
79
  if (write && SESSION_CHANGING.has(path)) clearCsrfToken();
78
80
  return res;
@@ -4,3 +4,4 @@ export { apiFetch, setUnauthenticatedHandler } from './http';
4
4
  export type { ApiRequestInit } from './http';
5
5
  export { clearCsrfToken, getCsrfToken } from './csrf';
6
6
  export type { paths, components } from './types.generated';
7
+ export { lastActivity, markActivity, subscribeActivity } from './activity';