@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +10 -2
  3. package/backend/internal/account/logging_integration_test.go +70 -0
  4. package/backend/internal/api/api.gen.go +125 -0
  5. package/backend/internal/auth/internal/store/session.go +63 -5
  6. package/backend/internal/auth/logging_integration_test.go +88 -0
  7. package/backend/internal/auth/login.go +252 -0
  8. package/backend/internal/auth/login_integration_test.go +421 -0
  9. package/backend/internal/auth/login_test.go +68 -0
  10. package/backend/internal/auth/logout.go +68 -0
  11. package/backend/internal/auth/logout_integration_test.go +124 -0
  12. package/backend/internal/auth/me.go +40 -0
  13. package/backend/internal/auth/me_integration_test.go +70 -0
  14. package/backend/internal/auth/session.go +20 -1
  15. package/backend/internal/server/api.go +14 -0
  16. package/backend/internal/server/public.go +3 -1
  17. package/backend/internal/shared/web/problem.go +9 -0
  18. package/backend/internal/shared/web/retryafter_test.go +31 -0
  19. package/frontend/src/app/index.css +102 -102
  20. package/frontend/src/app/layouts.tsx +75 -18
  21. package/frontend/src/app/pages/auth.tsx +52 -78
  22. package/frontend/src/app/pages/login.test.tsx +218 -0
  23. package/frontend/src/app/pages/register.test.tsx +231 -0
  24. package/frontend/src/app/router.tsx +83 -34
  25. package/frontend/src/app/session.ts +53 -22
  26. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  27. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  28. package/frontend/src/features/auth/login.ts +23 -0
  29. package/frontend/src/features/auth/register.ts +105 -0
  30. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  31. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  32. package/frontend/src/shared/text/index.ts +20 -0
  33. package/frontend/src/shared/text/text.test.ts +28 -1
  34. package/frontend/vite.config.ts +5 -1
  35. package/package.json +1 -1
@@ -1,23 +1,20 @@
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 { Role } from './routes';
6
-
7
- /** GET /auth/me body (docs/API/response-bodies.md); `email` is null for Admins. */
8
- export interface Me {
9
- empNo: string;
10
- name: string;
11
- email: string | null;
12
- role: Role;
13
- }
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 };
14
8
 
15
- export const meKey = ['me'] as const;
9
+ export const meKey = ["me"] as const;
16
10
 
17
11
  /** A 401 means visitor → null (Frontend.md §6.3 rule 1). */
18
12
  export async function fetchMe(signal?: AbortSignal): Promise<Me | null> {
19
13
  try {
20
- const res = await apiFetch('/auth/me', { signal, allowUnauthenticated: true });
14
+ const res = await apiFetch("/auth/me", {
15
+ signal,
16
+ allowUnauthenticated: true,
17
+ });
21
18
  return (await res.json()) as Me;
22
19
  } catch (e) {
23
20
  if (e instanceof ApiError && e.status === 401) return null;
@@ -25,24 +22,58 @@ export async function fetchMe(signal?: AbortSignal): Promise<Me | null> {
25
22
  }
26
23
  }
27
24
 
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
+
28
43
  /** Router state a 401 redirect leaves for the login page to show "your session ended" (Frontend.md §5.2). */
29
44
  export interface SessionEndedState {
30
- notice: 'sessionEnded';
45
+ notice: "sessionEnded";
46
+ }
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";
31
51
  }
32
52
 
33
53
  /**
34
54
  * Global 401 (Frontend.md §5.2): forget the user and go to `/login?next=<current>`.
35
55
  * Already on a public route (login, register) there is nothing to leave, so only `me` is cleared.
36
56
  */
37
- export function handleSessionEnded(client: QueryClient, router: DataRouter): void {
57
+ export function handleSessionEnded(
58
+ client: QueryClient,
59
+ router: DataRouter,
60
+ ): void {
61
+ leaving = "sessionEnded";
38
62
  client.setQueryData(meKey, null);
39
63
  const { pathname, search } = router.state.location;
40
- if (findRoute(pathname)?.access === 'public') return;
41
- const state: SessionEndedState = { notice: 'sessionEnded' };
42
- void router.navigate(`/login?next=${encodeURIComponent(pathname + search)}`, { replace: true, state });
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
+ });
43
70
  }
44
71
 
45
72
  export function useMe() {
46
- return useQuery({ queryKey: meKey, queryFn: ({ signal }) => fetchMe(signal), retry: false, staleTime: 60_000 });
73
+ return useQuery({
74
+ queryKey: meKey,
75
+ queryFn: ({ signal }) => fetchMe(signal),
76
+ retry: false,
77
+ staleTime: 60_000,
78
+ });
47
79
  }
48
-
@@ -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
+ }
@@ -0,0 +1,211 @@
1
+ import { useRef, useState, type FormEvent } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { Link } from "react-router";
4
+ import { Button } from "@/shared/ui/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 {
14
+ mapServerError,
15
+ precheck,
16
+ register,
17
+ REGISTER_FIELDS,
18
+ type FieldProblems,
19
+ type RegisterField,
20
+ type RegisterResult,
21
+ } from "./register";
22
+
23
+ type Values = Record<RegisterField, string>;
24
+ const EMPTY: Values = {
25
+ empNo: "",
26
+ name: "",
27
+ email: "",
28
+ password: "",
29
+ confirmPassword: "",
30
+ };
31
+
32
+ const FIELD_ATTRS: Record<
33
+ RegisterField,
34
+ {
35
+ type?: string;
36
+ autoComplete: string;
37
+ maxLength?: number;
38
+ inputMode?: "numeric";
39
+ }
40
+ > = {
41
+ empNo: { autoComplete: "username", inputMode: "numeric" },
42
+ name: { autoComplete: "name" },
43
+ email: { type: "email", autoComplete: "email" },
44
+ password: { type: "password", autoComplete: "new-password" },
45
+ confirmPassword: { type: "password", autoComplete: "new-password" },
46
+ };
47
+
48
+ const HINTS: Partial<Record<RegisterField, string>> = {
49
+ empNo: "pages.register.empNoHint",
50
+ email: "pages.register.emailHint",
51
+ password: "pages.register.passwordHint",
52
+ };
53
+
54
+ /** The register form: format pre-check, error summary with focus, server errors mapped to fields (Frontend.md §5.6, §8). */
55
+ export function RegisterForm({
56
+ onRegistered,
57
+ }: {
58
+ onRegistered: (r: RegisterResult) => void;
59
+ }) {
60
+ const { t, i18n } = useTranslation();
61
+ const [values, setValues] = useState<Values>(EMPTY);
62
+ const [problems, setProblems] = useState<FieldProblems>({});
63
+ const [formError, setFormError] = useState<string | null>(null);
64
+ const [pending, setPending] = useState(false);
65
+ const summary = useRef<HTMLDivElement>(null);
66
+ const form = useRef<HTMLFormElement>(null);
67
+ const [summaryKey, setSummaryKey] = useState(0);
68
+
69
+ const message = (code: string): string => {
70
+ if (code === "PASSWORD_MISMATCH") return t("pages.register.mismatch");
71
+ return i18n.exists(`errors.${code}`)
72
+ ? t(`errors.${code}`)
73
+ : t("errors.VALIDATION_FAILED");
74
+ };
75
+
76
+ const fail = (p: FieldProblems, rest: string | null) => {
77
+ setProblems(p);
78
+ setFormError(rest);
79
+ setSummaryKey((k) => k + 1);
80
+ // wait for the summary to render, then move focus to it (WCAG 3.3.1)
81
+ queueMicrotask(() => summary.current?.focus());
82
+ };
83
+
84
+ const onSubmit = async (e: FormEvent) => {
85
+ e.preventDefault();
86
+ if (pending) return;
87
+ const { values: body, problems: local } = precheck(values);
88
+ if (Object.keys(local).length > 0) {
89
+ fail(local, null);
90
+ return;
91
+ }
92
+ setPending(true);
93
+ setProblems({});
94
+ setFormError(null);
95
+ try {
96
+ onRegistered(await register(body));
97
+ } catch (err) {
98
+ const { problems: p, rest } = mapServerError(err);
99
+ fail(
100
+ p,
101
+ rest
102
+ ? t(`errors.${rest.code}`) +
103
+ (rest.requestId
104
+ ? ` ${t("common.requestId", { id: rest.requestId })}`
105
+ : "")
106
+ : null,
107
+ );
108
+ } finally {
109
+ setPending(false);
110
+ }
111
+ };
112
+
113
+ const focusField =
114
+ (f: RegisterField) => (e: { preventDefault: () => void }) => {
115
+ e.preventDefault();
116
+ form.current?.querySelector<HTMLInputElement>(`#${f}`)?.focus();
117
+ };
118
+
119
+ const listed = REGISTER_FIELDS.filter((f) => problems[f] !== undefined);
120
+ const hasSummary = listed.length > 0 || formError !== null;
121
+
122
+ return (
123
+ <form ref={form} onSubmit={(e) => void onSubmit(e)} noValidate>
124
+ <FieldGroup>
125
+ {hasSummary && (
126
+ <div
127
+ key={summaryKey}
128
+ ref={summary}
129
+ role="alert"
130
+ tabIndex={-1}
131
+ className="rounded-md border border-destructive p-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-destructive/40"
132
+ >
133
+ <p className="font-medium text-destructive">
134
+ {t("pages.register.summaryTitle")}
135
+ </p>
136
+ {formError !== null && <p>{formError}</p>}
137
+ {listed.length > 0 && (
138
+ <ul className="mt-1 list-disc pl-5">
139
+ {listed.map((f) => (
140
+ <li key={f}>
141
+ <a
142
+ href={`#${f}`}
143
+ onClick={focusField(f)}
144
+ className="underline underline-offset-4"
145
+ >
146
+ {t(`fields.${f}`)}: {message(problems[f]!)}
147
+ </a>
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ )}
152
+ </div>
153
+ )}
154
+ {REGISTER_FIELDS.map((f) => {
155
+ const a = FIELD_ATTRS[f];
156
+ const problem = problems[f];
157
+ const hint = HINTS[f];
158
+ const describedBy =
159
+ [hint && `${f}-hint`, problem && `${f}-error`]
160
+ .filter(Boolean)
161
+ .join(" ") || undefined;
162
+ return (
163
+ <Field key={f} data-invalid={problem ? true : undefined}>
164
+ <FieldLabel htmlFor={f}>{t(`fields.${f}`)}</FieldLabel>
165
+ <Input
166
+ id={f}
167
+ name={f}
168
+ type={a.type}
169
+ autoComplete={a.autoComplete}
170
+ inputMode={a.inputMode}
171
+ required
172
+ aria-required="true"
173
+ aria-invalid={problem ? true : undefined}
174
+ aria-describedby={describedBy}
175
+ value={values[f]}
176
+ onChange={(e) =>
177
+ setValues((v) => ({ ...v, [f]: e.target.value }))
178
+ }
179
+ />
180
+ {hint && (
181
+ <FieldDescription id={`${f}-hint`}>{t(hint)}</FieldDescription>
182
+ )}
183
+ {problem && (
184
+ <FieldError id={`${f}-error`} role={undefined}>
185
+ {message(problem)}
186
+ </FieldError>
187
+ )}
188
+ </Field>
189
+ );
190
+ })}
191
+ <Field>
192
+ <Button
193
+ type="submit"
194
+ disabled={pending}
195
+ aria-busy={pending || undefined}
196
+ >
197
+ {pending
198
+ ? t("pages.register.submitting")
199
+ : t("pages.register.submit")}
200
+ </Button>
201
+ <FieldDescription className="text-center">
202
+ {t("pages.register.haveAccount")}{" "}
203
+ <Link to="/login" className="underline underline-offset-4">
204
+ {t("nav.signIn")}
205
+ </Link>
206
+ </FieldDescription>
207
+ </Field>
208
+ </FieldGroup>
209
+ </form>
210
+ );
211
+ }
@@ -0,0 +1,23 @@
1
+ import { apiFetch } from "@/shared/api";
2
+
3
+ /** GET /auth/me and POST /auth/login body (docs/API/response-bodies.md); `email` is null for Admins. */
4
+ export interface Me {
5
+ empNo: string;
6
+ name: string;
7
+ email: string | null;
8
+ role: "USER" | "ADMIN";
9
+ }
10
+
11
+ /** POST /auth/login. Sets the session cookie; the body is the same shape as `GET /auth/me`. */
12
+ export async function login(body: {
13
+ empNo: string;
14
+ password: string;
15
+ }): Promise<Me> {
16
+ const res = await apiFetch("/auth/login", { method: "POST", body });
17
+ return (await res.json()) as Me;
18
+ }
19
+
20
+ /** POST /auth/logout: idempotent 204; `apiFetch` drops the session-bound CSRF token afterwards. */
21
+ export async function logout(): Promise<void> {
22
+ await apiFetch("/auth/logout", { method: "POST" });
23
+ }
@@ -0,0 +1,105 @@
1
+ import { ApiError, apiFetch } from "@/shared/api";
2
+ import { checkEmail, checkName } from "@/shared/text";
3
+
4
+ export const REGISTER_FIELDS = [
5
+ "empNo",
6
+ "name",
7
+ "email",
8
+ "password",
9
+ "confirmPassword",
10
+ ] as const;
11
+ export type RegisterField = (typeof REGISTER_FIELDS)[number];
12
+
13
+ export interface RegisterValues {
14
+ empNo: string;
15
+ name: string;
16
+ email: string;
17
+ password: string;
18
+ confirmPassword: string;
19
+ }
20
+
21
+ /** A field problem: `code` is an `errors.<code>` key, or `PASSWORD_MISMATCH` (client only). */
22
+ export type FieldProblems = Partial<Record<RegisterField, string>>;
23
+
24
+ const NAME_MAX = 50;
25
+
26
+ /**
27
+ * Same checks, same order as the server for the format step (PRD §6.3, §6.2),
28
+ * one problem per field. The password policy is the server's (and F1b-01's) job;
29
+ * only "required" and "matches the confirmation" are checked here.
30
+ */
31
+ export function precheck(v: RegisterValues): {
32
+ values: Omit<RegisterValues, "confirmPassword">;
33
+ problems: FieldProblems;
34
+ } {
35
+ const problems: FieldProblems = {};
36
+ if (v.empNo.trim() === "") problems.empNo = "REQUIRED";
37
+ else if (!/^[0-9]{6}$/.test(v.empNo)) problems.empNo = "INVALID_FORMAT";
38
+
39
+ const name = checkName(v.name, NAME_MAX);
40
+ if (name.problem) problems.name = name.problem;
41
+
42
+ const email = checkEmail(v.email);
43
+ if (email.problem) problems.email = email.problem;
44
+
45
+ if (v.password === "") problems.password = "REQUIRED";
46
+ if (v.confirmPassword !== v.password)
47
+ problems.confirmPassword = "PASSWORD_MISMATCH";
48
+
49
+ return {
50
+ values: {
51
+ empNo: v.empNo,
52
+ name: name.cleaned,
53
+ email: email.cleaned,
54
+ password: v.password,
55
+ },
56
+ problems,
57
+ };
58
+ }
59
+
60
+ export interface RegisterResult {
61
+ empNo: string;
62
+ name: string;
63
+ email: string;
64
+ }
65
+
66
+ /** POST /auth/register. Creates no session; the caller sends the user to /login. */
67
+ export async function register(body: {
68
+ empNo: string;
69
+ name: string;
70
+ email: string;
71
+ password: string;
72
+ }): Promise<RegisterResult> {
73
+ const res = await apiFetch("/auth/register", { method: "POST", body });
74
+ return (await res.json()) as RegisterResult;
75
+ }
76
+
77
+ const isField = (f: string): f is RegisterField =>
78
+ (REGISTER_FIELDS as readonly string[]).includes(f);
79
+
80
+ /**
81
+ * Maps a failed register call to field problems. A 409 names its field, a 422 lists
82
+ * `errors[]`. `rest` is what has no field of its own (CSRF, 503, …) and is shown
83
+ * as a form-level message.
84
+ */
85
+ export function mapServerError(e: unknown): {
86
+ problems: FieldProblems;
87
+ rest: ApiError | null;
88
+ } {
89
+ if (!(e instanceof ApiError))
90
+ return { problems: {}, rest: new ApiError({ status: 0, code: "UNKNOWN" }) };
91
+ if (e.code === "EMP_NO_TAKEN")
92
+ return { problems: { empNo: "EMP_NO_TAKEN" }, rest: null };
93
+ if (e.code === "EMAIL_TAKEN")
94
+ return { problems: { email: "EMAIL_TAKEN" }, rest: null };
95
+ const problems: FieldProblems = {};
96
+ for (const fe of e.errors) {
97
+ // the first error of a field wins; a password lists every violated rule, the top code covers them
98
+ if (isField(fe.field) && problems[fe.field] === undefined)
99
+ problems[fe.field] =
100
+ fe.field === "password" && e.code === "PASSWORD_POLICY"
101
+ ? "PASSWORD_POLICY"
102
+ : fe.code;
103
+ }
104
+ return { problems, rest: Object.keys(problems).length > 0 ? null : e };
105
+ }
@@ -21,6 +21,7 @@ export const en = {
21
21
  main: 'Main navigation',
22
22
  skip: 'Skip to content',
23
23
  administrator: 'Administrator',
24
+ signOut: 'Sign out',
24
25
  signIn: 'Sign in',
25
26
  register: 'Register',
26
27
  },
@@ -56,8 +57,20 @@ export const en = {
56
57
  leaderEmpNo: 'Leader employee ID',
57
58
  },
58
59
  pages: {
59
- login: { title: 'Sign in to your account', description: 'Enter your employee ID and password.', noAccount: 'No account yet?', panelTitle: 'ESG Idea Contest', panelBody: 'Register, form a team and submit your Idea.' },
60
- register: { title: 'Create an account', description: 'Enter your details below to register.', passwordHint: 'At least 8 characters; see the rules shown while you type.', haveAccount: 'Already registered?' },
60
+ login: { title: 'Sign in to your account', description: 'Enter your employee ID and password.', noAccount: 'No account yet?', panelTitle: 'ESG Idea Contest', panelBody: 'Register, form a team and submit your Idea.', submitting: 'Signing in…', sessionEnded: 'Your session has ended. Please sign in again.' },
61
+ register: {
62
+ title: 'Create an account',
63
+ description: 'Enter your details below to register.',
64
+ empNoHint: '6 digits.',
65
+ emailHint: 'Letters, digits and symbols only, no spaces.',
66
+ passwordHint: '12–64 printable ASCII characters, with at least 3 of: upper case, lower case, digit, symbol.',
67
+ mismatch: 'The passwords do not match.',
68
+ haveAccount: 'Already registered?',
69
+ submit: 'Create account',
70
+ submitting: 'Creating account…',
71
+ summaryTitle: 'Please fix the following',
72
+ success: 'Your account was created. Sign in to continue.',
73
+ },
61
74
  ideaList: { search: 'Search', apply: 'Apply filters', create: 'Create an Idea', empty: 'No Ideas yet.' },
62
75
  ideaForm: { slidesHint: 'PDF or PPTX, up to 5 MiB.' },
63
76
  ideaCreate: { title: 'Create an Idea', submit: 'Submit Idea' },
@@ -18,6 +18,7 @@ export const zhTW: Dictionary = {
18
18
  main: '主要導覽',
19
19
  skip: '跳到主要內容',
20
20
  administrator: '管理員',
21
+ signOut: '登出',
21
22
  signIn: '登入',
22
23
  register: '註冊',
23
24
  },
@@ -53,8 +54,20 @@ export const zhTW: Dictionary = {
53
54
  leaderEmpNo: '組長員工編號',
54
55
  },
55
56
  pages: {
56
- login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。' },
57
- register: { title: '建立帳號', description: '請在下方輸入資料以完成註冊。', passwordHint: '至少 8 個字元,輸入時會顯示規則檢查。', haveAccount: '已經註冊?' },
57
+ login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', sessionEnded: '您的登入已結束,請重新登入。' },
58
+ register: {
59
+ title: '建立帳號',
60
+ description: '請在下方輸入資料以完成註冊。',
61
+ empNoHint: '6 位數字。',
62
+ emailHint: '僅限英數字與符號,不可包含空白。',
63
+ passwordHint: '12–64 個可列印 ASCII 字元,且至少包含大寫、小寫、數字、符號其中 3 類。',
64
+ mismatch: '兩次輸入的密碼不一致。',
65
+ haveAccount: '已經註冊?',
66
+ submit: '建立帳號',
67
+ submitting: '建立中…',
68
+ summaryTitle: '請修正以下項目',
69
+ success: '帳號已建立,請登入。',
70
+ },
58
71
  ideaList: { search: '搜尋', apply: '套用篩選', create: '建立提案', empty: '目前沒有提案。' },
59
72
  ideaForm: { slidesHint: 'PDF 或 PPTX,最大 5 MiB。' },
60
73
  ideaCreate: { title: '建立提案', submit: '送出提案' },
@@ -91,3 +91,23 @@ export function checkSummary(s: string, max: number): CheckResult {
91
91
  const cleaned = cleanSummary(s);
92
92
  return { cleaned, problem: check(cleaned, max, false) };
93
93
  }
94
+
95
+ export type EmailProblem = '' | 'REQUIRED' | 'INVALID_FORMAT' | 'OUT_OF_RANGE';
96
+
97
+ const MAX_EMAIL = 254;
98
+
99
+ /**
100
+ * Pre-check of PRD §6.3 (the server is the judge): trim, printable ASCII only
101
+ * (U+0021–U+007E, so no whitespace anywhere), exactly one `@` with non-empty
102
+ * sides, at most 254 characters. `cleaned` is lower-cased as the server stores it.
103
+ * First problem in report order: REQUIRED → INVALID_FORMAT → OUT_OF_RANGE.
104
+ */
105
+ export function checkEmail(s: string): { cleaned: string; problem: EmailProblem } {
106
+ const e = trimWhitespace(s);
107
+ if (e === '') return { cleaned: '', problem: 'REQUIRED' };
108
+ if (!/^[\u0021-\u007e]+$/.test(e)) return { cleaned: e, problem: 'INVALID_FORMAT' };
109
+ const at = e.indexOf('@');
110
+ if (at <= 0 || at === e.length - 1 || e.indexOf('@', at + 1) !== -1) return { cleaned: e, problem: 'INVALID_FORMAT' };
111
+ if (e.length > MAX_EMAIL) return { cleaned: e, problem: 'OUT_OF_RANGE' };
112
+ return { cleaned: e.toLowerCase(), problem: '' };
113
+ }