@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.
Files changed (49) hide show
  1. package/backend/api/openapi.yaml +70 -0
  2. package/backend/cmd/server/main.go +17 -1
  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/csrf.go +212 -0
  6. package/backend/internal/auth/csrf_test.go +281 -0
  7. package/backend/internal/auth/internal/store/session.go +63 -5
  8. package/backend/internal/auth/logging_integration_test.go +88 -0
  9. package/backend/internal/auth/login.go +252 -0
  10. package/backend/internal/auth/login_integration_test.go +421 -0
  11. package/backend/internal/auth/login_test.go +68 -0
  12. package/backend/internal/auth/logout.go +68 -0
  13. package/backend/internal/auth/logout_integration_test.go +124 -0
  14. package/backend/internal/auth/me.go +40 -0
  15. package/backend/internal/auth/me_integration_test.go +70 -0
  16. package/backend/internal/auth/middleware.go +66 -0
  17. package/backend/internal/auth/middleware_test.go +153 -0
  18. package/backend/internal/auth/session.go +20 -1
  19. package/backend/internal/server/api.go +19 -0
  20. package/backend/internal/server/public.go +37 -0
  21. package/backend/internal/server/public_test.go +73 -0
  22. package/backend/internal/server/server.go +7 -3
  23. package/backend/internal/shared/config/config.go +23 -4
  24. package/backend/internal/shared/config/config_test.go +14 -8
  25. package/backend/internal/shared/web/problem.go +9 -0
  26. package/backend/internal/shared/web/retryafter_test.go +31 -0
  27. package/frontend/src/app/App.test.tsx +13 -0
  28. package/frontend/src/app/App.tsx +4 -0
  29. package/frontend/src/app/index.css +102 -102
  30. package/frontend/src/app/layouts.tsx +75 -18
  31. package/frontend/src/app/pages/auth.tsx +52 -78
  32. package/frontend/src/app/pages/login.test.tsx +218 -0
  33. package/frontend/src/app/pages/register.test.tsx +231 -0
  34. package/frontend/src/app/router.tsx +83 -34
  35. package/frontend/src/app/session.ts +71 -18
  36. package/frontend/src/features/auth/LoginForm.tsx +145 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +211 -0
  38. package/frontend/src/features/auth/login.ts +23 -0
  39. package/frontend/src/features/auth/register.ts +105 -0
  40. package/frontend/src/shared/api/csrf.ts +51 -0
  41. package/frontend/src/shared/api/http.test.tsx +209 -0
  42. package/frontend/src/shared/api/http.ts +91 -0
  43. package/frontend/src/shared/api/index.ts +3 -0
  44. package/frontend/src/shared/i18n/locales/en.ts +15 -2
  45. package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
  46. package/frontend/src/shared/text/index.ts +20 -0
  47. package/frontend/src/shared/text/text.test.ts +28 -1
  48. package/frontend/vite.config.ts +5 -1
  49. package/package.json +1 -1
@@ -1,16 +1,18 @@
1
- import { useEffect, useRef, type ReactNode } from 'react';
2
- import { useTranslation } from 'react-i18next';
3
- import { NavLink, Link } from 'react-router';
4
- import { DeadlineBanner } from '@/features/deadline/DeadlineBanner';
5
- import { LOCALES, setLocale, type Locale } from '@/shared/i18n';
6
- import { navFor } from './routes';
7
- import type { Me } from './session';
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import { NavLink, Link, useNavigate } from "react-router";
5
+ import { logout } from "@/features/auth/login";
6
+ import { DeadlineBanner } from "@/features/deadline/DeadlineBanner";
7
+ import { LOCALES, setLocale, type Locale } from "@/shared/i18n";
8
+ import { navFor } from "./routes";
9
+ import { replaceIdentity, type Me } from "./session";
8
10
 
9
11
  function LanguageSwitch() {
10
12
  const { t, i18n } = useTranslation();
11
13
  return (
12
14
  <label className="flex items-center gap-2 text-sm">
13
- <span>{t('common.language')}</span>
15
+ <span>{t("common.language")}</span>
14
16
  <select
15
17
  className="rounded-md border bg-background px-2 py-1"
16
18
  value={i18n.language}
@@ -18,7 +20,7 @@ function LanguageSwitch() {
18
20
  >
19
21
  {LOCALES.map((l) => (
20
22
  <option key={l} value={l}>
21
- {l === 'zh-TW' ? '繁體中文' : 'English'}
23
+ {l === "zh-TW" ? "繁體中文" : "English"}
22
24
  </option>
23
25
  ))}
24
26
  </select>
@@ -31,22 +33,35 @@ export function PageHeading({ title }: { title: string }) {
31
33
  const { t } = useTranslation();
32
34
  const ref = useRef<HTMLHeadingElement>(null);
33
35
  useEffect(() => {
34
- document.title = `${title} · ${t('app.name')}`;
36
+ document.title = `${title} · ${t("app.name")}`;
35
37
  ref.current?.focus();
36
38
  }, [title, t]);
37
39
  return (
38
- <h1 ref={ref} tabIndex={-1} className="mb-4 text-2xl font-semibold outline-none">
40
+ <h1
41
+ ref={ref}
42
+ tabIndex={-1}
43
+ className="mb-4 text-2xl font-semibold outline-none"
44
+ >
39
45
  {title}
40
46
  </h1>
41
47
  );
42
48
  }
43
49
 
44
- function Frame({ header, children }: { header: ReactNode; children: ReactNode }) {
50
+ function Frame({
51
+ header,
52
+ children,
53
+ }: {
54
+ header: ReactNode;
55
+ children: ReactNode;
56
+ }) {
45
57
  const { t } = useTranslation();
46
58
  return (
47
59
  <div className="min-h-screen">
48
- <a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:z-10 focus:bg-background focus:p-2">
49
- {t('nav.skip')}
60
+ <a
61
+ href="#main"
62
+ className="sr-only focus:not-sr-only focus:absolute focus:z-10 focus:bg-background focus:p-2"
63
+ >
64
+ {t("nav.skip")}
50
65
  </a>
51
66
  <header className="border-b">{header}</header>
52
67
  <DeadlineBanner />
@@ -64,7 +79,7 @@ export function PublicLayout({ children }: { children: ReactNode }) {
64
79
  header={
65
80
  <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 p-4">
66
81
  <Link to="/login" className="font-semibold">
67
- {t('app.name')}
82
+ {t("app.name")}
68
83
  </Link>
69
84
  <LanguageSwitch />
70
85
  </div>
@@ -75,6 +90,40 @@ export function PublicLayout({ children }: { children: ReactNode }) {
75
90
  );
76
91
  }
77
92
 
93
+ /**
94
+ * Ends the session. Whatever the answer, the browser must stop acting as this user: logout is
95
+ * idempotent on the server, and on a network failure the user is told instead of being left unsure.
96
+ */
97
+ function SignOutButton() {
98
+ const { t } = useTranslation();
99
+ const client = useQueryClient();
100
+ const navigate = useNavigate();
101
+ const out = useMutation({
102
+ mutationFn: logout,
103
+ onSuccess: () => {
104
+ replaceIdentity(client, null);
105
+ void navigate("/login", { replace: true });
106
+ },
107
+ });
108
+ return (
109
+ <>
110
+ <button
111
+ type="button"
112
+ className="rounded-md border px-2 py-1 text-sm disabled:opacity-50"
113
+ disabled={out.isPending}
114
+ onClick={() => out.mutate()}
115
+ >
116
+ {t("nav.signOut")}
117
+ </button>
118
+ {out.isError && (
119
+ <span role="alert" className="text-sm text-destructive">
120
+ {t("errors.UNKNOWN")}
121
+ </span>
122
+ )}
123
+ </>
124
+ );
125
+ }
126
+
78
127
  /** Logged-in layout; navigation comes from the route table and the role. */
79
128
  export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
80
129
  const { t } = useTranslation();
@@ -82,11 +131,14 @@ export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
82
131
  <Frame
83
132
  header={
84
133
  <div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-4 p-4">
85
- <nav aria-label={t('nav.main')}>
134
+ <nav aria-label={t("nav.main")}>
86
135
  <ul className="flex flex-wrap items-center gap-4">
87
136
  {navFor(me.role).map((r) => (
88
137
  <li key={r.path}>
89
- <NavLink to={r.path} className="underline-offset-4 hover:underline aria-[current=page]:font-semibold">
138
+ <NavLink
139
+ to={r.path}
140
+ className="underline-offset-4 hover:underline aria-[current=page]:font-semibold"
141
+ >
90
142
  {t(`nav.${r.navKey!}`)}
91
143
  </NavLink>
92
144
  </li>
@@ -94,9 +146,14 @@ export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
94
146
  </ul>
95
147
  </nav>
96
148
  <div className="flex items-center gap-4">
97
- {me.role === 'ADMIN' && <span className="rounded-md border px-2 py-0.5 text-sm">{t('nav.administrator')}</span>}
149
+ {me.role === "ADMIN" && (
150
+ <span className="rounded-md border px-2 py-0.5 text-sm">
151
+ {t("nav.administrator")}
152
+ </span>
153
+ )}
98
154
  <span className="text-sm">{me.name}</span>
99
155
  <LanguageSwitch />
156
+ <SignOutButton />
100
157
  </div>
101
158
  </div>
102
159
  }
@@ -1,20 +1,29 @@
1
- import { useEffect, useRef, type FormEvent } from 'react';
2
- import { Leaf } from 'lucide-react';
3
- import { useTranslation } from 'react-i18next';
4
- import { Link } from 'react-router';
5
- import { Button } from '@/shared/ui/button';
6
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card';
7
- import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/shared/ui/field';
8
- import { Input } from '@/shared/ui/input';
9
-
10
- const noSubmit = (e: FormEvent) => e.preventDefault();
1
+ import { useEffect, useRef } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { Leaf } from "lucide-react";
4
+ import { useTranslation } from "react-i18next";
5
+ import { useLocation, useNavigate } from "react-router";
6
+ import { LoginForm } from "@/features/auth/LoginForm";
7
+ import { RegisterForm } from "@/features/auth/RegisterForm";
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardDescription,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from "@/shared/ui/card";
15
+ import {
16
+ replaceIdentity,
17
+ type RegisteredState,
18
+ type SessionEndedState,
19
+ } from "../session";
11
20
 
12
21
  /** One h1 per page; focus moves to it after a route change and the title updates (Frontend.md §8). */
13
22
  function useAuthHeading(title: string) {
14
23
  const { t } = useTranslation();
15
24
  const ref = useRef<HTMLHeadingElement>(null);
16
25
  useEffect(() => {
17
- document.title = `${title} · ${t('app.name')}`;
26
+ document.title = `${title} · ${t("app.name")}`;
18
27
  ref.current?.focus();
19
28
  }, [title, t]);
20
29
  return ref;
@@ -23,37 +32,27 @@ function useAuthHeading(title: string) {
23
32
  /** shadcn `login-02`: two columns, form on the left, brand panel on the right (hidden below lg). */
24
33
  export function Login() {
25
34
  const { t } = useTranslation();
26
- const heading = useAuthHeading(t('nav.signIn'));
35
+ const heading = useAuthHeading(t("nav.signIn"));
36
+ const client = useQueryClient();
37
+ const state = useLocation().state as Partial<
38
+ RegisteredState | SessionEndedState
39
+ > | null;
40
+ const notice =
41
+ state?.notice === "registered"
42
+ ? t("pages.register.success")
43
+ : state?.notice === "sessionEnded"
44
+ ? t("pages.login.sessionEnded")
45
+ : undefined;
27
46
  return (
28
47
  <div className="grid overflow-hidden rounded-xl border lg:grid-cols-2">
29
48
  <div className="flex items-center justify-center p-6 md:p-10">
30
- <form className="flex w-full max-w-xs flex-col gap-6" onSubmit={noSubmit}>
31
- <FieldGroup>
32
- <div className="flex flex-col items-center gap-1 text-center">
33
- <h1 ref={heading} tabIndex={-1} className="text-2xl font-bold outline-none">
34
- {t('pages.login.title')}
35
- </h1>
36
- <p className="text-sm text-balance text-muted-foreground">{t('pages.login.description')}</p>
37
- </div>
38
- <Field>
39
- <FieldLabel htmlFor="empNo">{t('fields.empNo')}</FieldLabel>
40
- <Input id="empNo" name="empNo" autoComplete="username" required />
41
- </Field>
42
- <Field>
43
- <FieldLabel htmlFor="password">{t('fields.password')}</FieldLabel>
44
- <Input id="password" name="password" type="password" autoComplete="current-password" required />
45
- </Field>
46
- <Field>
47
- <Button type="submit">{t('nav.signIn')}</Button>
48
- <FieldDescription className="text-center">
49
- {t('pages.login.noAccount')}{' '}
50
- <Link to="/register" className="underline underline-offset-4">
51
- {t('nav.register')}
52
- </Link>
53
- </FieldDescription>
54
- </Field>
55
- </FieldGroup>
56
- </form>
49
+ {/* A new session means a new identity: nothing cached for the previous one may survive. The gate then
50
+ sees `me` and redirects to the validated `next` (Frontend.md §6.4). */}
51
+ <LoginForm
52
+ heading={heading}
53
+ notice={notice}
54
+ onLoggedIn={(me) => replaceIdentity(client, me)}
55
+ />
57
56
  </div>
58
57
  <div
59
58
  aria-hidden="true"
@@ -62,8 +61,10 @@ export function Login() {
62
61
  <div className="flex size-12 items-center justify-center rounded-md bg-primary text-primary-foreground">
63
62
  <Leaf className="size-6" />
64
63
  </div>
65
- <p className="text-xl font-semibold">{t('pages.login.panelTitle')}</p>
66
- <p className="max-w-xs text-sm text-balance text-muted-foreground">{t('pages.login.panelBody')}</p>
64
+ <p className="text-xl font-semibold">{t("pages.login.panelTitle")}</p>
65
+ <p className="max-w-xs text-sm text-balance text-muted-foreground">
66
+ {t("pages.login.panelBody")}
67
+ </p>
67
68
  </div>
68
69
  </div>
69
70
  );
@@ -72,53 +73,26 @@ export function Login() {
72
73
  /** shadcn `signup-01`: a single centred card. */
73
74
  export function Register() {
74
75
  const { t } = useTranslation();
75
- const heading = useAuthHeading(t('nav.register'));
76
+ const navigate = useNavigate();
77
+ const heading = useAuthHeading(t("nav.register"));
76
78
  return (
77
79
  <div className="mx-auto w-full max-w-sm">
78
80
  <Card>
79
81
  <CardHeader>
80
82
  <CardTitle>
81
83
  <h1 ref={heading} tabIndex={-1} className="outline-none">
82
- {t('pages.register.title')}
84
+ {t("pages.register.title")}
83
85
  </h1>
84
86
  </CardTitle>
85
- <CardDescription>{t('pages.register.description')}</CardDescription>
87
+ <CardDescription>{t("pages.register.description")}</CardDescription>
86
88
  </CardHeader>
87
89
  <CardContent>
88
- <form onSubmit={noSubmit}>
89
- <FieldGroup>
90
- <Field>
91
- <FieldLabel htmlFor="empNo">{t('fields.empNo')}</FieldLabel>
92
- <Input id="empNo" name="empNo" autoComplete="username" required />
93
- </Field>
94
- <Field>
95
- <FieldLabel htmlFor="name">{t('fields.name')}</FieldLabel>
96
- <Input id="name" name="name" autoComplete="name" required />
97
- </Field>
98
- <Field>
99
- <FieldLabel htmlFor="email">{t('fields.email')}</FieldLabel>
100
- <Input id="email" name="email" type="email" autoComplete="email" required />
101
- </Field>
102
- <Field>
103
- <FieldLabel htmlFor="password">{t('fields.password')}</FieldLabel>
104
- <Input id="password" name="password" type="password" autoComplete="new-password" required />
105
- <FieldDescription>{t('pages.register.passwordHint')}</FieldDescription>
106
- </Field>
107
- <Field>
108
- <FieldLabel htmlFor="confirmPassword">{t('fields.confirmPassword')}</FieldLabel>
109
- <Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" required />
110
- </Field>
111
- <Field>
112
- <Button type="submit">{t('nav.register')}</Button>
113
- <FieldDescription className="text-center">
114
- {t('pages.register.haveAccount')}{' '}
115
- <Link to="/login" className="underline underline-offset-4">
116
- {t('nav.signIn')}
117
- </Link>
118
- </FieldDescription>
119
- </Field>
120
- </FieldGroup>
121
- </form>
90
+ <RegisterForm
91
+ onRegistered={() => {
92
+ const state: RegisteredState = { notice: "registered" };
93
+ void navigate("/login", { replace: true, state });
94
+ }}
95
+ />
122
96
  </CardContent>
123
97
  </Card>
124
98
  </div>
@@ -0,0 +1,218 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { http, HttpResponse } from "msw";
5
+ import { createMemoryRouter } from "react-router";
6
+ import { axe } from "vitest-axe";
7
+ import { clearCsrfToken } from "@/shared/api";
8
+ import { problem } from "@/shared/api/mocks/problem";
9
+ import { server } from "@/shared/api/mocks/server";
10
+ import { initI18n } from "@/shared/i18n";
11
+ import { App } from "../App";
12
+ import { routes } from "../router";
13
+
14
+ const API = "http://localhost:3000/api/v1";
15
+ const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
16
+ const ADMIN = { empNo: "000001", name: "Root", email: null, role: "ADMIN" };
17
+
18
+ /** A visitor on `path`; `account` is who a successful login returns. `GET /auth/me` follows the session. */
19
+ async function open(path: string, account: typeof USER | typeof ADMIN = USER) {
20
+ clearCsrfToken();
21
+ let session: typeof USER | typeof ADMIN | null = null;
22
+ const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
23
+ login: [],
24
+ logoutCsrf: [],
25
+ };
26
+ server.use(
27
+ http.get(`${API}/auth/me`, () =>
28
+ session ? HttpResponse.json(session) : problem(401, "UNAUTHENTICATED"),
29
+ ),
30
+ http.get(`${API}/deadline`, () =>
31
+ HttpResponse.json({
32
+ deadline: null,
33
+ state: "OPEN",
34
+ serverTime: "2026-11-08T00:00:00.000Z",
35
+ }),
36
+ ),
37
+ http.get(`${API}/auth/csrf`, () =>
38
+ HttpResponse.json({ token: session ? "tok-session" : "tok-anon" }),
39
+ ),
40
+ http.post(`${API}/auth/login`, async ({ request }) => {
41
+ calls.login.push(await request.json());
42
+ session = account;
43
+ return HttpResponse.json(account);
44
+ }),
45
+ http.post(`${API}/auth/logout`, ({ request }) => {
46
+ calls.logoutCsrf.push(request.headers.get("X-CSRF-Token"));
47
+ session = null;
48
+ return new HttpResponse(null, { status: 204 });
49
+ }),
50
+ );
51
+ await initI18n("en");
52
+ const router = createMemoryRouter(routes, { initialEntries: [path] });
53
+ const view = render(<App router={router} client={new QueryClient()} />);
54
+ await screen.findByRole("heading", {
55
+ level: 1,
56
+ name: "Sign in to your account",
57
+ });
58
+ return { router, calls, user: userEvent.setup(), ...view };
59
+ }
60
+
61
+ async function signIn(
62
+ user: ReturnType<typeof userEvent.setup>,
63
+ empNo = "089290",
64
+ password = "secret",
65
+ ) {
66
+ await user.type(screen.getByLabelText("Employee ID"), empNo);
67
+ await user.type(screen.getByLabelText("Password"), password);
68
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
69
+ }
70
+
71
+ describe("Login page", () => {
72
+ it("has labelled, autocompleted fields and no axe violations", async () => {
73
+ const { container } = await open("/login");
74
+ expect(screen.getByLabelText("Employee ID")).toHaveAttribute(
75
+ "autocomplete",
76
+ "username",
77
+ );
78
+ expect(screen.getByLabelText("Password")).toHaveAttribute(
79
+ "autocomplete",
80
+ "current-password",
81
+ );
82
+ expect((await axe(container)).violations).toEqual([]);
83
+ });
84
+
85
+ it("signs a User in and lands on /ideas", async () => {
86
+ const { router, calls, user } = await open("/login");
87
+ await signIn(user, " 089290 ", "secret");
88
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
89
+ expect(calls.login).toEqual([{ empNo: "089290", password: "secret" }]);
90
+ expect(await screen.findByText("Wang")).toBeInTheDocument();
91
+ expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("signs an Admin in, to the same page, with the Administrator badge", async () => {
95
+ const { router, user } = await open("/login", ADMIN);
96
+ await signIn(user, "000001");
97
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
98
+ expect(await screen.findByText("Administrator")).toBeInTheDocument();
99
+ });
100
+
101
+ it("follows a role-allowed next", async () => {
102
+ const { router, user } = await open("/login?next=%2Fjoin-requests");
103
+ await signIn(user);
104
+ await waitFor(() =>
105
+ expect(router.state.location.pathname).toBe("/join-requests"),
106
+ );
107
+ });
108
+
109
+ it.each([
110
+ "//evil.example",
111
+ "https://evil.example/x",
112
+ "/admin/deadline",
113
+ "/nope",
114
+ "\\\\evil.example",
115
+ ])("ignores next=%s", async (next) => {
116
+ const { router, user } = await open(
117
+ `/login?next=${encodeURIComponent(next)}`,
118
+ );
119
+ await signIn(user);
120
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
121
+ expect(router.state.location.search).toBe("");
122
+ });
123
+
124
+ it("shows one message for a wrong password, clears the password and focuses the message", async () => {
125
+ const { user, container } = await open("/login");
126
+ server.use(
127
+ http.post(`${API}/auth/login`, () => problem(401, "INVALID_CREDENTIALS")),
128
+ );
129
+ await signIn(user, "089290", "wrong");
130
+ const alert = await screen.findByRole("alert");
131
+ expect(alert).toHaveTextContent(
132
+ "The employee ID or password is incorrect.",
133
+ );
134
+ expect(alert).toHaveFocus();
135
+ expect(screen.getByLabelText("Password")).toHaveValue("");
136
+ expect(screen.getByLabelText("Employee ID")).toHaveValue("089290");
137
+ expect((await axe(container)).violations).toEqual([]);
138
+ });
139
+
140
+ it("shows the throttle message", async () => {
141
+ const { user } = await open("/login");
142
+ server.use(
143
+ http.post(`${API}/auth/login`, () =>
144
+ problem(429, "LOGIN_THROTTLED", {}, { "Retry-After": "60" }),
145
+ ),
146
+ );
147
+ await signIn(user);
148
+ expect(await screen.findByRole("alert")).toHaveTextContent(
149
+ "Too many sign-in attempts",
150
+ );
151
+ });
152
+
153
+ it("shows the registered and session-ended notices", async () => {
154
+ const router = createMemoryRouter(routes, {
155
+ initialEntries: [
156
+ { pathname: "/login", state: { notice: "sessionEnded" } },
157
+ ],
158
+ });
159
+ server.use(
160
+ http.get(`${API}/auth/me`, () => problem(401, "UNAUTHENTICATED")),
161
+ http.get(`${API}/deadline`, () =>
162
+ HttpResponse.json({
163
+ deadline: null,
164
+ state: "OPEN",
165
+ serverTime: "2026-11-08T00:00:00.000Z",
166
+ }),
167
+ ),
168
+ );
169
+ await initI18n("en");
170
+ render(<App router={router} client={new QueryClient()} />);
171
+ expect(await screen.findByRole("status", { name: "" })).toBeInTheDocument();
172
+ expect(
173
+ await screen.findByText("Your session has ended. Please sign in again."),
174
+ ).toBeInTheDocument();
175
+ });
176
+ });
177
+
178
+ describe("Sign out", () => {
179
+ it("ends the session with the session-bound CSRF token and returns to a clean /login", async () => {
180
+ const { router, calls, user } = await open("/login");
181
+ await signIn(user);
182
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
183
+ await screen.findByRole("heading", {
184
+ level: 1,
185
+ name: "Sign in to your account",
186
+ });
187
+ expect(calls.logoutCsrf).toEqual(["tok-session"]);
188
+ expect(router.state.location.pathname).toBe("/login");
189
+ expect(router.state.location.search).toBe("");
190
+ expect(
191
+ screen.queryByText("Your session has ended. Please sign in again."),
192
+ ).not.toBeInTheDocument();
193
+ expect(screen.queryByText("Wang")).not.toBeInTheDocument();
194
+ });
195
+
196
+ it("a second login after logout uses a fresh CSRF token", async () => {
197
+ const { router, user } = await open("/login");
198
+ await signIn(user);
199
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
200
+ await screen.findByRole("heading", {
201
+ level: 1,
202
+ name: "Sign in to your account",
203
+ });
204
+ await signIn(user);
205
+ await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
206
+ });
207
+
208
+ it("tells the user when sign-out fails and keeps them signed in", async () => {
209
+ const { router, user } = await open("/login");
210
+ await signIn(user);
211
+ server.use(
212
+ http.post(`${API}/auth/logout`, () => problem(500, "INTERNAL_ERROR")),
213
+ );
214
+ await user.click(await screen.findByRole("button", { name: "Sign out" }));
215
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
216
+ expect(router.state.location.pathname).toBe("/ideas");
217
+ });
218
+ });