@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +70 -0
- package/backend/cmd/server/main.go +17 -1
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -0
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -0
- package/backend/internal/auth/internal/store/session.go +63 -5
- package/backend/internal/auth/logging_integration_test.go +88 -0
- package/backend/internal/auth/login.go +252 -0
- package/backend/internal/auth/login_integration_test.go +421 -0
- package/backend/internal/auth/login_test.go +68 -0
- package/backend/internal/auth/logout.go +68 -0
- package/backend/internal/auth/logout_integration_test.go +124 -0
- package/backend/internal/auth/me.go +40 -0
- package/backend/internal/auth/me_integration_test.go +70 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +20 -1
- package/backend/internal/server/api.go +19 -0
- package/backend/internal/server/public.go +37 -0
- package/backend/internal/server/public_test.go +73 -0
- package/backend/internal/server/server.go +7 -3
- package/backend/internal/shared/config/config.go +23 -4
- package/backend/internal/shared/config/config_test.go +14 -8
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/index.css +102 -102
- package/frontend/src/app/layouts.tsx +75 -18
- package/frontend/src/app/pages/auth.tsx +52 -78
- package/frontend/src/app/pages/login.test.tsx +218 -0
- package/frontend/src/app/pages/register.test.tsx +231 -0
- package/frontend/src/app/router.tsx +83 -34
- package/frontend/src/app/session.ts +71 -18
- package/frontend/src/features/auth/LoginForm.tsx +145 -0
- package/frontend/src/features/auth/RegisterForm.tsx +211 -0
- package/frontend/src/features/auth/login.ts +23 -0
- package/frontend/src/features/auth/register.ts +105 -0
- package/frontend/src/shared/api/csrf.ts +51 -0
- package/frontend/src/shared/api/http.test.tsx +209 -0
- package/frontend/src/shared/api/http.ts +91 -0
- package/frontend/src/shared/api/index.ts +3 -0
- package/frontend/src/shared/i18n/locales/en.ts +15 -2
- package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
- package/frontend/src/shared/text/index.ts +20 -0
- package/frontend/src/shared/text/text.test.ts +28 -1
- package/frontend/vite.config.ts +5 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseApiError } from './problem';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session-bound CSRF token, kept in memory only (PRD §6.5, Frontend.md §5.2, F13).
|
|
5
|
+
* The token is bound to the current session (anonymous before login), so it is
|
|
6
|
+
* dropped on login, logout and any 401 and fetched again lazily.
|
|
7
|
+
*/
|
|
8
|
+
let token: string | null = null;
|
|
9
|
+
let inflight: Promise<string> | null = null;
|
|
10
|
+
/** Bumped by clearCsrfToken so a fetch started before the clear cannot repopulate the cache. */
|
|
11
|
+
let generation = 0;
|
|
12
|
+
|
|
13
|
+
export const API_BASE = () => `${location.origin}/api/v1`;
|
|
14
|
+
|
|
15
|
+
async function fetchToken(): Promise<string> {
|
|
16
|
+
const res = await fetch(`${API_BASE()}/auth/csrf`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
|
17
|
+
if (!res.ok) throw await parseApiError(res);
|
|
18
|
+
const body = (await res.json()) as { token?: unknown };
|
|
19
|
+
if (typeof body.token !== 'string' || body.token === '') throw await parseApiError(new Response(null, { status: 500 }));
|
|
20
|
+
return body.token;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the cached token, fetching it once (shared by concurrent callers) when absent. */
|
|
24
|
+
export function getCsrfToken(): Promise<string> {
|
|
25
|
+
if (token !== null) return Promise.resolve(token);
|
|
26
|
+
if (inflight) return inflight;
|
|
27
|
+
const started = generation;
|
|
28
|
+
const p = fetchToken()
|
|
29
|
+
.then((t) => {
|
|
30
|
+
if (started === generation) token = t;
|
|
31
|
+
return t;
|
|
32
|
+
})
|
|
33
|
+
.finally(() => {
|
|
34
|
+
if (inflight === p) inflight = null;
|
|
35
|
+
});
|
|
36
|
+
inflight = p;
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Drops the token (login, logout, 401). The next write fetches a new one. */
|
|
41
|
+
export function clearCsrfToken(): void {
|
|
42
|
+
generation += 1;
|
|
43
|
+
token = null;
|
|
44
|
+
inflight = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drops the token and fetches a new one (the `CSRF_INVALID` retry). */
|
|
48
|
+
export function refreshCsrfToken(): Promise<string> {
|
|
49
|
+
clearCsrfToken();
|
|
50
|
+
return getCsrfToken();
|
|
51
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { problem } from './mocks/problem';
|
|
3
|
+
import { server } from './mocks/server';
|
|
4
|
+
import { ApiError } from './problem';
|
|
5
|
+
import { clearCsrfToken } from './csrf';
|
|
6
|
+
import { apiFetch, setUnauthenticatedHandler } from './http';
|
|
7
|
+
|
|
8
|
+
const API = 'http://localhost:3000/api/v1';
|
|
9
|
+
|
|
10
|
+
/** Counts GET /auth/csrf and hands out t1, t2, … */
|
|
11
|
+
function csrfEndpoint() {
|
|
12
|
+
const state = { calls: 0 };
|
|
13
|
+
server.use(
|
|
14
|
+
http.get(`${API}/auth/csrf`, () => {
|
|
15
|
+
state.calls += 1;
|
|
16
|
+
return HttpResponse.json({ token: `t${state.calls}` });
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
clearCsrfToken();
|
|
24
|
+
setUnauthenticatedHandler(null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('CSRF token', () => {
|
|
28
|
+
it('is fetched lazily, cached, and sent on writes only', async () => {
|
|
29
|
+
const csrf = csrfEndpoint();
|
|
30
|
+
const seen: (string | null)[] = [];
|
|
31
|
+
server.use(
|
|
32
|
+
http.get(`${API}/deadline`, ({ request }) => {
|
|
33
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
34
|
+
return HttpResponse.json({});
|
|
35
|
+
}),
|
|
36
|
+
http.post(`${API}/join-requests`, ({ request }) => {
|
|
37
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
38
|
+
return HttpResponse.json({}, { status: 201 });
|
|
39
|
+
}),
|
|
40
|
+
http.delete(`${API}/ideas/x`, ({ request }) => {
|
|
41
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
42
|
+
return new HttpResponse(null, { status: 204 });
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
await apiFetch('/deadline');
|
|
46
|
+
expect(csrf.calls).toBe(0);
|
|
47
|
+
await apiFetch('/join-requests', { method: 'POST', body: { a: 1 } });
|
|
48
|
+
await apiFetch('/ideas/x', { method: 'DELETE' });
|
|
49
|
+
expect(csrf.calls).toBe(1);
|
|
50
|
+
expect(seen).toEqual([null, 't1', 't1']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is shared by concurrent first writes', async () => {
|
|
54
|
+
const csrf = csrfEndpoint();
|
|
55
|
+
server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
|
|
56
|
+
await Promise.all([1, 2, 3].map(() => apiFetch('/join-requests', { method: 'POST', body: {} })));
|
|
57
|
+
expect(csrf.calls).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sends JSON bodies with a JSON content type', async () => {
|
|
61
|
+
csrfEndpoint();
|
|
62
|
+
let ct: string | null = null;
|
|
63
|
+
let body: unknown;
|
|
64
|
+
server.use(
|
|
65
|
+
http.post(`${API}/auth/register`, async ({ request }) => {
|
|
66
|
+
ct = request.headers.get('Content-Type');
|
|
67
|
+
body = await request.json();
|
|
68
|
+
return HttpResponse.json({}, { status: 201 });
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
await apiFetch('/auth/register', { method: 'POST', body: { empNo: '1' } });
|
|
72
|
+
expect(ct).toBe('application/json');
|
|
73
|
+
expect(body).toEqual({ empNo: '1' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each(['/auth/login', '/auth/logout'])('is refetched after a successful %s', async (path) => {
|
|
77
|
+
const csrf = csrfEndpoint();
|
|
78
|
+
const seen: string[] = [];
|
|
79
|
+
server.use(
|
|
80
|
+
http.post(`${API}${path}`, ({ request }) => {
|
|
81
|
+
seen.push(request.headers.get('X-CSRF-Token') ?? '');
|
|
82
|
+
return path === '/auth/login' ? HttpResponse.json({}) : new HttpResponse(null, { status: 204 });
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
await apiFetch(path, { method: 'POST', body: {} });
|
|
86
|
+
await apiFetch(path, { method: 'POST', body: {} });
|
|
87
|
+
expect(csrf.calls).toBe(2);
|
|
88
|
+
expect(seen).toEqual(['t1', 't2']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is kept after a failed login', async () => {
|
|
92
|
+
const csrf = csrfEndpoint();
|
|
93
|
+
server.use(http.post(`${API}/auth/login`, () => problem(401, 'INVALID_CREDENTIALS')));
|
|
94
|
+
const unauth = vi.fn();
|
|
95
|
+
setUnauthenticatedHandler(unauth);
|
|
96
|
+
await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' });
|
|
97
|
+
await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toBeInstanceOf(ApiError);
|
|
98
|
+
expect(csrf.calls).toBe(1);
|
|
99
|
+
expect(unauth).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('CSRF_INVALID', () => {
|
|
104
|
+
it('refetches and retries once, resending the same body', async () => {
|
|
105
|
+
const csrf = csrfEndpoint();
|
|
106
|
+
const attempts: { token: string | null; body: unknown }[] = [];
|
|
107
|
+
server.use(
|
|
108
|
+
http.post(`${API}/join-requests`, async ({ request }) => {
|
|
109
|
+
attempts.push({ token: request.headers.get('X-CSRF-Token'), body: await request.json() });
|
|
110
|
+
return attempts.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
const res = await apiFetch('/join-requests', { method: 'POST', body: { ideaId: 'i' } });
|
|
114
|
+
expect(res.status).toBe(201);
|
|
115
|
+
expect(csrf.calls).toBe(2);
|
|
116
|
+
expect(attempts).toEqual([
|
|
117
|
+
{ token: 't1', body: { ideaId: 'i' } },
|
|
118
|
+
{ token: 't2', body: { ideaId: 'i' } },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not retry a second time', async () => {
|
|
123
|
+
const csrf = csrfEndpoint();
|
|
124
|
+
let posts = 0;
|
|
125
|
+
server.use(
|
|
126
|
+
http.post(`${API}/join-requests`, () => {
|
|
127
|
+
posts += 1;
|
|
128
|
+
return problem(403, 'CSRF_INVALID');
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 403, code: 'CSRF_INVALID' });
|
|
132
|
+
expect(posts).toBe(2);
|
|
133
|
+
expect(csrf.calls).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does not retry other 403s', async () => {
|
|
137
|
+
const csrf = csrfEndpoint();
|
|
138
|
+
let posts = 0;
|
|
139
|
+
server.use(
|
|
140
|
+
http.post(`${API}/join-requests`, () => {
|
|
141
|
+
posts += 1;
|
|
142
|
+
return problem(403, 'SUBMISSION_CLOSED');
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'SUBMISSION_CLOSED' });
|
|
146
|
+
expect(posts).toBe(1);
|
|
147
|
+
expect(csrf.calls).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('resends a multipart body on retry', async () => {
|
|
151
|
+
csrfEndpoint();
|
|
152
|
+
const names: (string | null)[] = [];
|
|
153
|
+
server.use(
|
|
154
|
+
http.post(`${API}/ideas`, async ({ request }) => {
|
|
155
|
+
const f = (await request.formData()).get('title');
|
|
156
|
+
names.push(f as string);
|
|
157
|
+
return names.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const fd = new FormData();
|
|
161
|
+
fd.set('title', 'T');
|
|
162
|
+
await apiFetch('/ideas', { method: 'POST', body: fd });
|
|
163
|
+
expect(names).toEqual(['T', 'T']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('a failed token fetch surfaces its error and caches nothing', async () => {
|
|
167
|
+
server.use(http.get(`${API}/auth/csrf`, () => problem(429, 'RATE_LIMITED')));
|
|
168
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 429 });
|
|
169
|
+
const csrf = csrfEndpoint();
|
|
170
|
+
server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
|
|
171
|
+
await apiFetch('/join-requests', { method: 'POST', body: {} });
|
|
172
|
+
expect(csrf.calls).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('401', () => {
|
|
177
|
+
it('clears the token and calls the handler', async () => {
|
|
178
|
+
const csrf = csrfEndpoint();
|
|
179
|
+
const unauth = vi.fn();
|
|
180
|
+
setUnauthenticatedHandler(unauth);
|
|
181
|
+
let n = 0;
|
|
182
|
+
server.use(
|
|
183
|
+
http.post(`${API}/join-requests`, () => {
|
|
184
|
+
n += 1;
|
|
185
|
+
return n === 1 ? problem(401, 'UNAUTHENTICATED') : HttpResponse.json({}, { status: 201 });
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 401, code: 'UNAUTHENTICATED' });
|
|
189
|
+
expect(unauth).toHaveBeenCalledTimes(1);
|
|
190
|
+
await apiFetch('/join-requests', { method: 'POST', body: {} });
|
|
191
|
+
expect(csrf.calls).toBe(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is an answer, not a redirect, when allowUnauthenticated is set', async () => {
|
|
195
|
+
const unauth = vi.fn();
|
|
196
|
+
setUnauthenticatedHandler(unauth);
|
|
197
|
+
server.use(http.get(`${API}/auth/me`, () => problem(401, 'UNAUTHENTICATED')));
|
|
198
|
+
await expect(apiFetch('/auth/me', { allowUnauthenticated: true })).rejects.toMatchObject({ status: 401 });
|
|
199
|
+
expect(unauth).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('fires for reads too', async () => {
|
|
203
|
+
const unauth = vi.fn();
|
|
204
|
+
setUnauthenticatedHandler(unauth);
|
|
205
|
+
server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
|
|
206
|
+
await expect(apiFetch('/ideas')).rejects.toBeInstanceOf(ApiError);
|
|
207
|
+
expect(unauth).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { API_BASE, clearCsrfToken, getCsrfToken, refreshCsrfToken } from './csrf';
|
|
2
|
+
import { parseApiError, type ApiError } from './problem';
|
|
3
|
+
|
|
4
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
5
|
+
|
|
6
|
+
/** Endpoints after which the session binding of the CSRF token changes (PRD §6.5). */
|
|
7
|
+
const SESSION_CHANGING = new Set(['/auth/login', '/auth/logout']);
|
|
8
|
+
|
|
9
|
+
type Unauthenticated = () => void;
|
|
10
|
+
let onUnauthenticated: Unauthenticated | null = null;
|
|
11
|
+
|
|
12
|
+
/** The app registers what a 401 does (clear `me`, go to /login?next=). Returns an unregister function. */
|
|
13
|
+
export function setUnauthenticatedHandler(fn: Unauthenticated | null): () => void {
|
|
14
|
+
onUnauthenticated = fn;
|
|
15
|
+
return () => {
|
|
16
|
+
if (onUnauthenticated === fn) onUnauthenticated = null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
|
21
|
+
/** Plain objects are sent as JSON; strings, FormData and Blobs are sent as given. */
|
|
22
|
+
body?: BodyInit | Record<string, unknown> | null;
|
|
23
|
+
/** A 401 is an answer here (e.g. `GET /auth/me` means "visitor"), not a session end. */
|
|
24
|
+
allowUnauthenticated?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function prepare(init: ApiRequestInit): { headers: Headers; body: BodyInit | null | undefined } {
|
|
28
|
+
const headers = new Headers(init.headers);
|
|
29
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
30
|
+
let body = init.body as BodyInit | null | undefined;
|
|
31
|
+
if (body !== null && body !== undefined && typeof body === 'object' && !isNativeBody(body)) {
|
|
32
|
+
body = JSON.stringify(body);
|
|
33
|
+
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
|
34
|
+
}
|
|
35
|
+
return { headers, body };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isNativeBody(b: object): boolean {
|
|
39
|
+
return (
|
|
40
|
+
b instanceof FormData ||
|
|
41
|
+
b instanceof Blob ||
|
|
42
|
+
b instanceof URLSearchParams ||
|
|
43
|
+
b instanceof ArrayBuffer ||
|
|
44
|
+
ArrayBuffer.isView(b) ||
|
|
45
|
+
b instanceof ReadableStream
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* fetch for `/api/v1`: `path` is relative to it (`/auth/me`). Resolves with the
|
|
51
|
+
* Response on 2xx and throws ApiError otherwise.
|
|
52
|
+
*
|
|
53
|
+
* - Writes send `X-CSRF-Token` (lazily fetched, cached in memory).
|
|
54
|
+
* - `CSRF_INVALID` refetches the token and retries the write once. The request
|
|
55
|
+
* was rejected before processing, so the retry is safe (Frontend.md §5.2).
|
|
56
|
+
* A request whose body is a stream cannot be replayed and is not retried.
|
|
57
|
+
* - A successful login or logout clears the token (the binding changed).
|
|
58
|
+
* - A 401 clears the token and calls the registered handler, except for
|
|
59
|
+
* `INVALID_CREDENTIALS` (a wrong password is not a session end).
|
|
60
|
+
*/
|
|
61
|
+
export async function apiFetch(path: string, init: ApiRequestInit = {}): Promise<Response> {
|
|
62
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
63
|
+
const write = !SAFE.has(method);
|
|
64
|
+
const { headers, body } = prepare(init);
|
|
65
|
+
const send = (csrf: string | null) => {
|
|
66
|
+
const h = new Headers(headers);
|
|
67
|
+
if (csrf !== null) h.set('X-CSRF-Token', csrf);
|
|
68
|
+
return fetch(`${API_BASE()}${path}`, { ...init, method, headers: h, body, credentials: 'same-origin' });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let res = await send(write ? await getCsrfToken() : null);
|
|
72
|
+
if (write && !res.ok && !(body instanceof ReadableStream) && (await errorCode(res)) === 'CSRF_INVALID') {
|
|
73
|
+
res = await send(await refreshCsrfToken());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
if (write && SESSION_CHANGING.has(path)) clearCsrfToken();
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
const err = await parseApiError(res);
|
|
81
|
+
if (err.status === 401 && err.code !== 'INVALID_CREDENTIALS' && !init.allowUnauthenticated) {
|
|
82
|
+
clearCsrfToken();
|
|
83
|
+
onUnauthenticated?.();
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reads the error code without consuming the response. */
|
|
89
|
+
async function errorCode(res: Response): Promise<ApiError['code']> {
|
|
90
|
+
return (await parseApiError(res.clone())).code;
|
|
91
|
+
}
|