@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1

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 (111) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/file_test.go +52 -0
  78. package/backend/test/matrix/load_test.go +62 -0
  79. package/backend/test/matrix/matrix_integration_test.go +291 -0
  80. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  81. package/docs/API/api-endpoints.md +1 -1
  82. package/docs/API/error-codes.md +1 -1
  83. package/frontend/public/auth-hero-login.jpg +0 -0
  84. package/frontend/public/auth-hero-register.jpg +0 -0
  85. package/frontend/src/app/App.test.tsx +2 -42
  86. package/frontend/src/app/App.tsx +11 -2
  87. package/frontend/src/app/index.css +4 -4
  88. package/frontend/src/app/layouts.tsx +7 -135
  89. package/frontend/src/app/pages/JoinRequests.tsx +2 -2
  90. package/frontend/src/app/pages/NotFound.tsx +1 -1
  91. package/frontend/src/app/pages/admin.tsx +1 -1
  92. package/frontend/src/app/pages/auth.tsx +53 -25
  93. package/frontend/src/app/pages/ideas.tsx +7 -7
  94. package/frontend/src/app/pages/login.test.tsx +3 -54
  95. package/frontend/src/app/pages/register.test.tsx +1 -1
  96. package/frontend/src/app/router.tsx +2 -6
  97. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  98. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  99. package/frontend/src/features/auth/LoginForm.tsx +8 -1
  100. package/frontend/src/shared/api/activity.ts +20 -0
  101. package/frontend/src/shared/api/http.ts +2 -0
  102. package/frontend/src/shared/api/index.ts +1 -0
  103. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  104. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  105. package/package.json +1 -1
  106. package/testdata/expected-filename.json +93 -0
  107. package/testdata/password.json +425 -0
  108. package/testdata/permission-matrix.json +157 -0
  109. package/testdata/schema/expected-filename.schema.json +32 -0
  110. package/testdata/schema/password.schema.json +59 -0
  111. package/testdata/schema/permission-matrix.schema.json +73 -0
@@ -1,32 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode } from "react";
2
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";
10
-
11
- function LanguageSwitch() {
12
- const { t, i18n } = useTranslation();
13
- return (
14
- <label className="flex items-center gap-2 text-sm">
15
- <span>{t("common.language")}</span>
16
- <select
17
- className="rounded-md border bg-background px-2 py-1"
18
- value={i18n.language}
19
- onChange={(e) => void setLocale(e.target.value as Locale)}
20
- >
21
- {LOCALES.map((l) => (
22
- <option key={l} value={l}>
23
- {l === "zh-TW" ? "繁體中文" : "English"}
24
- </option>
25
- ))}
26
- </select>
27
- </label>
28
- );
29
- }
30
3
 
31
4
  /** One h1 per page; after a route change focus moves to it and the title updates (Frontend.md §8). */
32
5
  export function PageHeading({ title }: { title: string }) {
@@ -40,125 +13,24 @@ export function PageHeading({ title }: { title: string }) {
40
13
  <h1
41
14
  ref={ref}
42
15
  tabIndex={-1}
43
- className="mb-4 text-2xl font-semibold outline-none"
16
+ className="mb-6 text-2xl font-semibold outline-none"
44
17
  >
45
18
  {title}
46
19
  </h1>
47
20
  );
48
21
  }
49
22
 
50
- function Frame({
51
- header,
23
+ /** Every page fills the viewport; there is no header, navigation or footer around it. */
24
+ export function Screen({
52
25
  children,
26
+ padded = true,
53
27
  }: {
54
- header: ReactNode;
55
28
  children: ReactNode;
29
+ padded?: boolean;
56
30
  }) {
57
- const { t } = useTranslation();
58
- return (
59
- <div className="min-h-screen">
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")}
65
- </a>
66
- <header className="border-b">{header}</header>
67
- <DeadlineBanner />
68
- <main id="main" className="mx-auto max-w-5xl p-4">
69
- {children}
70
- </main>
71
- </div>
72
- );
73
- }
74
-
75
- export function PublicLayout({ children }: { children: ReactNode }) {
76
- const { t } = useTranslation();
77
31
  return (
78
- <Frame
79
- header={
80
- <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 p-4">
81
- <Link to="/login" className="font-semibold">
82
- {t("app.name")}
83
- </Link>
84
- <LanguageSwitch />
85
- </div>
86
- }
87
- >
88
- {children}
89
- </Frame>
90
- );
91
- }
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
-
127
- /** Logged-in layout; navigation comes from the route table and the role. */
128
- export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
129
- const { t } = useTranslation();
130
- return (
131
- <Frame
132
- header={
133
- <div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-4 p-4">
134
- <nav aria-label={t("nav.main")}>
135
- <ul className="flex flex-wrap items-center gap-4">
136
- {navFor(me.role).map((r) => (
137
- <li key={r.path}>
138
- <NavLink
139
- to={r.path}
140
- className="underline-offset-4 hover:underline aria-[current=page]:font-semibold"
141
- >
142
- {t(`nav.${r.navKey!}`)}
143
- </NavLink>
144
- </li>
145
- ))}
146
- </ul>
147
- </nav>
148
- <div className="flex items-center gap-4">
149
- {me.role === "ADMIN" && (
150
- <span className="rounded-md border px-2 py-0.5 text-sm">
151
- {t("nav.administrator")}
152
- </span>
153
- )}
154
- <span className="text-sm">{me.name}</span>
155
- <LanguageSwitch />
156
- <SignOutButton />
157
- </div>
158
- </div>
159
- }
160
- >
32
+ <main id="main" className={padded ? "min-h-screen p-6 md:p-10" : "min-h-screen"}>
161
33
  {children}
162
- </Frame>
34
+ </main>
163
35
  );
164
36
  }
@@ -9,7 +9,7 @@ export function JoinRequests() {
9
9
  return (
10
10
  <>
11
11
  <PageHeading title={t('nav.joinRequests')} />
12
- <section className="mb-8 max-w-md">
12
+ <section className="mb-6 max-w-md rounded-xl border bg-card p-6">
13
13
  <h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.apply')}</h2>
14
14
  <form onSubmit={(e: FormEvent) => e.preventDefault()}>
15
15
  <Field label={t('fields.leaderEmpNo')}>{(p) => <input {...p} name="leaderEmpNo" required />}</Field>
@@ -17,7 +17,7 @@ export function JoinRequests() {
17
17
  <Button type="submit">{t('pages.joinRequests.submit')}</Button>
18
18
  </form>
19
19
  </section>
20
- <section>
20
+ <section className="rounded-xl border bg-card p-6">
21
21
  <h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.mine')}</h2>
22
22
  <p className="text-sm">{t('pages.joinRequests.empty')}</p>
23
23
  </section>
@@ -9,7 +9,7 @@ export function NotFound() {
9
9
  <>
10
10
  <PageHeading title={t('notFound.title')} />
11
11
  <p className="mb-4">{t('notFound.body')}</p>
12
- <Link to="/" className="underline">
12
+ <Link to="/" className="font-medium text-primary underline underline-offset-4">
13
13
  {t('notFound.home')}
14
14
  </Link>
15
15
  </>
@@ -10,7 +10,7 @@ export function AdminDeadline() {
10
10
  return (
11
11
  <>
12
12
  <PageHeading title={t('nav.deadline')} />
13
- <form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md">
13
+ <form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md rounded-xl border bg-card p-6">
14
14
  <Field label={t('pages.adminDeadline.label')} hint={t('pages.adminDeadline.hint')}>
15
15
  {(p) => <input {...p} name="deadline" type="datetime-local" required />}
16
16
  </Field>
@@ -29,7 +29,34 @@ function useAuthHeading(title: string) {
29
29
  return ref;
30
30
  }
31
31
 
32
- /** shadcn `login-02`: two columns, form on the left, brand panel on the right (hidden below lg). */
32
+ /** Brand mark shared by both hero layouts and the form header. */
33
+ function LogoMark({ className = "size-16" }: { className?: string }) {
34
+ return (
35
+ <div
36
+ aria-hidden="true"
37
+ className={`flex items-center justify-center rounded-2xl bg-primary text-primary-foreground ${className}`}
38
+ >
39
+ <Leaf className="size-1/2" />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function HeroCopy({ title, body }: { title: string; body: string }) {
45
+ return (
46
+ <div
47
+ aria-hidden="true"
48
+ className="flex flex-col items-center gap-4 text-center"
49
+ >
50
+ <LogoMark />
51
+ <p className="max-w-sm text-3xl font-semibold text-balance whitespace-pre-line text-primary">
52
+ {title}
53
+ </p>
54
+ <p className="max-w-xs text-sm text-balance text-foreground/80">{body}</p>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ /** Design `Login - Employee`: 500px photo panel on the left (lg and up), form centred on the right. */
33
60
  export function Login() {
34
61
  const { t } = useTranslation();
35
62
  const heading = useAuthHeading(t("nav.signIn"));
@@ -44,8 +71,17 @@ export function Login() {
44
71
  ? t("pages.login.sessionEnded")
45
72
  : undefined;
46
73
  return (
47
- <div className="grid overflow-hidden rounded-xl border lg:grid-cols-2">
48
- <div className="flex items-center justify-center p-6 md:p-10">
74
+ <div className="grid min-h-screen bg-card lg:grid-cols-[500px_1fr]">
75
+ <div
76
+ className="relative hidden items-center justify-center bg-cover bg-center p-10 lg:flex"
77
+ style={{ backgroundImage: "url(/auth-hero-login.jpg)" }}
78
+ >
79
+ <HeroCopy
80
+ title={t("pages.login.panelTitle")}
81
+ body={t("pages.login.panelBody")}
82
+ />
83
+ </div>
84
+ <div className="flex items-center justify-center p-6 md:p-12">
49
85
  {/* A new session means a new identity: nothing cached for the previous one may survive. The gate then
50
86
  sees `me` and redirects to the validated `next` (Frontend.md §6.4). */}
51
87
  <LoginForm
@@ -54,32 +90,24 @@ export function Login() {
54
90
  onLoggedIn={(me) => replaceIdentity(client, me)}
55
91
  />
56
92
  </div>
57
- <div
58
- aria-hidden="true"
59
- className="hidden flex-col items-center justify-center gap-3 bg-muted p-10 text-center lg:flex"
60
- >
61
- <div className="flex size-12 items-center justify-center rounded-md bg-primary text-primary-foreground">
62
- <Leaf className="size-6" />
63
- </div>
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>
68
- </div>
69
93
  </div>
70
94
  );
71
95
  }
72
96
 
73
- /** shadcn `signup-01`: a single centred card. */
97
+ /** Design `Sign Up - Employee`: a full-bleed photo with the form card centred on it. */
74
98
  export function Register() {
75
99
  const { t } = useTranslation();
76
100
  const navigate = useNavigate();
77
101
  const heading = useAuthHeading(t("nav.register"));
78
102
  return (
79
- <div className="mx-auto w-full max-w-sm">
80
- <Card>
81
- <CardHeader>
82
- <CardTitle>
103
+ <div
104
+ className="flex min-h-screen items-center justify-center bg-cover bg-center px-4 py-10"
105
+ style={{ backgroundImage: "url(/auth-hero-register.jpg)" }}
106
+ >
107
+ <Card className="w-full max-w-md shadow-lg">
108
+ <CardHeader className="items-center text-center">
109
+ <LogoMark className="size-14" />
110
+ <CardTitle className="pt-2 text-2xl">
83
111
  <h1 ref={heading} tabIndex={-1} className="outline-none">
84
112
  {t("pages.register.title")}
85
113
  </h1>
@@ -88,11 +116,11 @@ export function Register() {
88
116
  </CardHeader>
89
117
  <CardContent>
90
118
  <RegisterForm
91
- onRegistered={() => {
92
- const state: RegisteredState = { notice: "registered" };
93
- void navigate("/login", { replace: true, state });
94
- }}
95
- />
119
+ onRegistered={() => {
120
+ const state: RegisteredState = { notice: "registered" };
121
+ void navigate("/login", { replace: true, state });
122
+ }}
123
+ />
96
124
  </CardContent>
97
125
  </Card>
98
126
  </div>
@@ -62,12 +62,12 @@ export function IdeaList() {
62
62
  </Button>
63
63
  )}
64
64
  </form>
65
- <table className="w-full text-left text-sm">
65
+ <table className="w-full rounded-xl border bg-card text-left text-sm">
66
66
  <caption className="sr-only">{t('nav.ideas')}</caption>
67
67
  <thead>
68
68
  <tr>
69
69
  {(['ideaName', 'category', 'org', 'direction', 'leader', 'members'] as const).map((c) => (
70
- <th key={c} scope="col" className="border-b p-2">
70
+ <th key={c} scope="col" className="border-b p-3 font-medium text-muted-foreground">
71
71
  {t(`fields.${c}`)}
72
72
  </th>
73
73
  ))}
@@ -86,7 +86,7 @@ export function IdeaCreate() {
86
86
  return (
87
87
  <>
88
88
  <PageHeading title={t('pages.ideaCreate.title')} />
89
- <form onSubmit={noSubmit} className="max-w-2xl">
89
+ <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
90
90
  <IdeaFields withSlides />
91
91
  <Button type="submit" disabled={!open}>
92
92
  {t('pages.ideaCreate.submit')}
@@ -98,8 +98,8 @@ export function IdeaCreate() {
98
98
 
99
99
  function Section({ title, children }: { title: string; children?: ReactNode }) {
100
100
  return (
101
- <section className="mb-6">
102
- <h2 className="mb-2 text-lg font-semibold">{title}</h2>
101
+ <section className="mb-4 rounded-xl border bg-card p-6">
102
+ <h2 className="mb-3 text-lg font-semibold">{title}</h2>
103
103
  {children}
104
104
  </section>
105
105
  );
@@ -133,7 +133,7 @@ export function IdeaDetail() {
133
133
  <Button variant="destructive">{t('pages.ideaDetail.delete')}</Button>
134
134
  </div>
135
135
  </Section>
136
- <Link to="/ideas" className="underline">
136
+ <Link to="/ideas" className="font-medium text-primary underline underline-offset-4">
137
137
  {t('pages.ideaDetail.back')}
138
138
  </Link>
139
139
  </>
@@ -145,7 +145,7 @@ export function IdeaEdit() {
145
145
  return (
146
146
  <>
147
147
  <PageHeading title={t('pages.ideaEdit.title')} />
148
- <form onSubmit={noSubmit} className="max-w-2xl">
148
+ <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
149
149
  <IdeaFields withSlides={false} />
150
150
  <div className="flex gap-2">
151
151
  <Button type="submit">{t('common.save')}</Button>
@@ -13,12 +13,12 @@ import { routes } from "../router";
13
13
 
14
14
  const API = "http://localhost:3000/api/v1";
15
15
  const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
16
- const ADMIN = { empNo: "000001", name: "Root", email: null, role: "ADMIN" };
16
+ type Account = typeof USER | { empNo: string; name: string; email: null; role: "ADMIN" };
17
17
 
18
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) {
19
+ async function open(path: string, account: Account = USER) {
20
20
  clearCsrfToken();
21
- let session: typeof USER | typeof ADMIN | null = null;
21
+ let session: Account | null = null;
22
22
  const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
23
23
  login: [],
24
24
  logoutCsrf: [],
@@ -87,15 +87,6 @@ describe("Login page", () => {
87
87
  await signIn(user, " 089290 ", "secret");
88
88
  await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
89
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
90
  });
100
91
 
101
92
  it("follows a role-allowed next", async () => {
@@ -174,45 +165,3 @@ describe("Login page", () => {
174
165
  ).toBeInTheDocument();
175
166
  });
176
167
  });
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
- });
@@ -71,7 +71,7 @@ describe('Register page: markup', () => {
71
71
 
72
72
  it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), also with errors shown', async (locale) => {
73
73
  const { container, user } = await open(locale);
74
- await screen.findByRole('region', { name: locale === 'en' ? 'Submission status' : /.+/ });
74
+ await screen.findByRole('heading', { level: 1 });
75
75
  expect((await axe(container)).violations).toEqual([]);
76
76
  await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Create account' : '建立帳號' }));
77
77
  expect(await screen.findByRole('alert')).toBeInTheDocument();
@@ -7,7 +7,7 @@ import {
7
7
  type RouteObject,
8
8
  } from "react-router";
9
9
  import { decide, safeNext } from "./access";
10
- import { AppShell, PublicLayout } from "./layouts";
10
+ import { Screen } from "./layouts";
11
11
  import { NotFound } from "./pages/NotFound";
12
12
  import { Login, Register } from "./pages/auth";
13
13
  import { IdeaCreate, IdeaDetail, IdeaEdit, IdeaList } from "./pages/ideas";
@@ -94,11 +94,7 @@ function Gate({ route }: { route?: (typeof ROUTES)[number] }) {
94
94
  ) : (
95
95
  <NotFound />
96
96
  );
97
- return me ? (
98
- <AppShell me={me}>{page}</AppShell>
99
- ) : (
100
- <PublicLayout>{page}</PublicLayout>
101
- );
97
+ return <Screen padded={route?.access !== "public"}>{page}</Screen>;
102
98
  }
103
99
 
104
100
  function RootRedirect() {
@@ -0,0 +1,82 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { http, HttpResponse } from "msw";
4
+ import { axe } from "vitest-axe";
5
+ import { apiFetch, markActivity } from "@/shared/api";
6
+ import { server } from "@/shared/api/mocks/server";
7
+ import { initI18n } from "@/shared/i18n";
8
+ import { IdleWarning } from "./IdleWarning";
9
+
10
+ const API = "http://localhost:3000/api/v1";
11
+ const MIN = 60_000;
12
+ const DIALOG = { name: "Are you still there?" };
13
+
14
+ beforeEach(async () => {
15
+ await initI18n("en");
16
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
17
+ markActivity();
18
+ });
19
+ afterEach(() => vi.useRealTimers());
20
+
21
+ const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
22
+
23
+ describe("IdleWarning", () => {
24
+ it("opens at 28 minutes and ends the session at 30", async () => {
25
+ const onExpire = vi.fn();
26
+ render(<IdleWarning signedIn onExpire={onExpire} />);
27
+ await advance(28 * MIN - 1);
28
+ expect(screen.queryByRole("alertdialog")).toBeNull();
29
+ await advance(1);
30
+ expect(screen.getByRole("alertdialog", DIALOG)).toBeInTheDocument();
31
+ expect(onExpire).not.toHaveBeenCalled();
32
+ await advance(2 * MIN);
33
+ expect(onExpire).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it("Continue calls GET /auth/me, closes the dialog and restarts the clock", async () => {
37
+ let calls = 0;
38
+ server.use(
39
+ http.get(`${API}/auth/me`, () => {
40
+ calls += 1;
41
+ return HttpResponse.json({ empNo: "089290" });
42
+ }),
43
+ );
44
+ const onExpire = vi.fn();
45
+ render(<IdleWarning signedIn onExpire={onExpire} />);
46
+ await advance(28 * MIN);
47
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTimeAsync });
48
+ await user.click(screen.getByRole("button", { name: "Continue" }));
49
+ await vi.waitFor(() => expect(calls).toBe(1));
50
+ await vi.waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
51
+ await advance(29 * MIN);
52
+ expect(onExpire).not.toHaveBeenCalled();
53
+ await advance(MIN);
54
+ expect(onExpire).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it("any answered request restarts the clock before the warning", async () => {
58
+ server.use(http.get(`${API}/deadline`, () => HttpResponse.json({})));
59
+ render(<IdleWarning signedIn onExpire={vi.fn()} />);
60
+ await advance(20 * MIN);
61
+ await apiFetch("/deadline");
62
+ await advance(20 * MIN);
63
+ expect(screen.queryByRole("alertdialog")).toBeNull();
64
+ await advance(8 * MIN);
65
+ expect(screen.getByRole("alertdialog", DIALOG)).toBeInTheDocument();
66
+ });
67
+
68
+ it("does nothing for a visitor", async () => {
69
+ const onExpire = vi.fn();
70
+ render(<IdleWarning signedIn={false} onExpire={onExpire} />);
71
+ await advance(40 * MIN);
72
+ expect(screen.queryByRole("alertdialog")).toBeNull();
73
+ expect(onExpire).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("has no axe violations", async () => {
77
+ render(<IdleWarning signedIn onExpire={vi.fn()} />);
78
+ await advance(28 * MIN);
79
+ vi.useRealTimers();
80
+ expect((await axe(document.body)).violations).toEqual([]);
81
+ });
82
+ });
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState, useSyncExternalStore } from "react";
2
+ import { Dialog } from "radix-ui";
3
+ import { useTranslation } from "react-i18next";
4
+ import { apiFetch, lastActivity, subscribeActivity } from "@/shared/api";
5
+ import { Button } from "@/shared/ui/button";
6
+
7
+ /** Warn 2 min before the 30 min idle timeout: the 60 s refresh rule can put the server up to 1 min ahead (OI-11, OI-22). */
8
+ export const WARN_AFTER_MS = 28 * 60_000;
9
+ export const END_AFTER_MS = 30 * 60_000;
10
+
11
+ /**
12
+ * Idle-timeout warning (WCAG 2.2.1, Frontend.md §8.1). 28 minutes after the last server request a dialog
13
+ * offers "Continue", which calls `GET /auth/me` (that refreshes the session and restarts both timers).
14
+ * With no answer the session ends at 30 minutes like any other 401 (`onExpire`).
15
+ */
16
+ export function IdleWarning({
17
+ signedIn,
18
+ onExpire,
19
+ }: {
20
+ signedIn: boolean;
21
+ onExpire: () => void;
22
+ }) {
23
+ const { t } = useTranslation();
24
+ const last = useSyncExternalStore(subscribeActivity, lastActivity);
25
+ // The `last` the warning fired for: a newer request makes it stale, which closes the dialog without an effect.
26
+ const [warnedFor, setWarnedFor] = useState<number | null>(null);
27
+ const warning = signedIn && warnedFor === last;
28
+
29
+ useEffect(() => {
30
+ if (!signedIn) return;
31
+ const elapsed = Date.now() - last;
32
+ const warn = setTimeout(() => setWarnedFor(last), Math.max(0, WARN_AFTER_MS - elapsed));
33
+ const end = setTimeout(() => {
34
+ setWarnedFor(null);
35
+ onExpire();
36
+ }, Math.max(0, END_AFTER_MS - elapsed));
37
+ return () => {
38
+ clearTimeout(warn);
39
+ clearTimeout(end);
40
+ };
41
+ }, [signedIn, last, onExpire]);
42
+
43
+ // A failure needs no handling here: a 401 already ends the session through the global handler.
44
+ const keepAlive = () => void apiFetch("/auth/me").catch(() => undefined);
45
+
46
+ return (
47
+ <Dialog.Root open={warning} onOpenChange={(open) => !open && keepAlive()}>
48
+ <Dialog.Portal>
49
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
50
+ <Dialog.Content
51
+ role="alertdialog"
52
+ className="fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg"
53
+ onInteractOutside={(e) => e.preventDefault()}
54
+ >
55
+ <Dialog.Title className="text-lg font-semibold">{t("pages.idle.title")}</Dialog.Title>
56
+ <Dialog.Description className="mt-2 text-sm text-muted-foreground">
57
+ {t("pages.idle.body")}
58
+ </Dialog.Description>
59
+ <div className="mt-6 flex justify-end">
60
+ <Button onClick={keepAlive}>{t("pages.idle.continue")}</Button>
61
+ </div>
62
+ </Dialog.Content>
63
+ </Dialog.Portal>
64
+ </Dialog.Root>
65
+ );
66
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, type FormEvent, type Ref } from "react";
2
+ import { Leaf } from "lucide-react";
2
3
  import { useTranslation } from "react-i18next";
3
4
  import { Link } from "react-router";
4
5
  import { ApiError } from "@/shared/api";
@@ -65,11 +66,17 @@ export function LoginForm({
65
66
 
66
67
  return (
67
68
  <form
68
- className="flex w-full max-w-xs flex-col gap-6"
69
+ className="flex w-full max-w-sm flex-col gap-6"
69
70
  onSubmit={(e) => void onSubmit(e)}
70
71
  >
71
72
  <FieldGroup>
72
73
  <div className="flex flex-col items-center gap-1 text-center">
74
+ <div
75
+ aria-hidden="true"
76
+ className="mb-2 flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
77
+ >
78
+ <Leaf className="size-7" />
79
+ </div>
73
80
  <h1
74
81
  ref={heading}
75
82
  tabIndex={-1}