@johnnyboy11234/esg-idea-contest 0.3.1 → 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 (58) hide show
  1. package/backend/api/openapi.yaml +36 -0
  2. package/backend/cmd/server/main.go +1 -0
  3. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  4. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  5. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  6. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  7. package/backend/internal/account/password.go +8 -2
  8. package/backend/internal/account/sequence.go +54 -0
  9. package/backend/internal/account/sequence_test.go +38 -0
  10. package/backend/internal/api/api.gen.go +38 -0
  11. package/backend/internal/idea/create.go +5 -1
  12. package/backend/internal/idea/create_integration_test.go +33 -3
  13. package/backend/internal/idea/download.go +193 -0
  14. package/backend/internal/idea/download_integration_test.go +311 -0
  15. package/backend/internal/idea/download_test.go +25 -0
  16. package/backend/internal/idea/handler.go +3 -0
  17. package/backend/internal/idea/service.go +2 -1
  18. package/backend/internal/server/api.go +5 -0
  19. package/backend/internal/server/binding.go +4 -0
  20. package/backend/internal/server/server.go +20 -1
  21. package/backend/internal/server/slides_test.go +82 -0
  22. package/backend/internal/shared/web/transport.go +4 -3
  23. package/backend/test/arch/arch_test.go +5 -1
  24. package/backend/test/matrix/matrix_integration_test.go +6 -0
  25. package/docs/Architecture/Architecture.md +1 -1
  26. package/docs/Architecture/Backend.md +2 -1
  27. package/frontend/Dockerfile +2 -0
  28. package/frontend/package-lock.json +1 -0
  29. package/frontend/package.json +1 -0
  30. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  31. package/frontend/src/app/pages/ideas.tsx +23 -9
  32. package/frontend/src/app/pages/login.test.tsx +38 -1
  33. package/frontend/src/app/pages/register.test.tsx +32 -0
  34. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  35. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  36. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  38. package/frontend/src/features/auth/common-passwords.ts +31 -0
  39. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  40. package/frontend/src/features/auth/password-policy.ts +168 -0
  41. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  42. package/frontend/src/features/auth/useCountdown.ts +28 -0
  43. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  44. package/frontend/src/features/idea/create.ts +123 -0
  45. package/frontend/src/shared/api/types.generated.ts +319 -1
  46. package/frontend/src/shared/i18n/locales/en.ts +45 -4
  47. package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
  48. package/frontend/src/shared/slides/index.ts +74 -0
  49. package/frontend/src/shared/slides/slides.test.ts +51 -0
  50. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  51. package/frontend/src/shared/ui/native-select.tsx +27 -0
  52. package/frontend/src/shared/ui/textarea.tsx +19 -0
  53. package/frontend/src/test-setup.ts +37 -2
  54. package/frontend/vite.config.ts +3 -1
  55. package/package.json +1 -1
  56. package/testdata/password.json +164 -7
  57. package/testdata/permission-matrix.json +23 -0
  58. package/testdata/schema/permission-matrix.schema.json +1 -1
@@ -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
+ }