@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.4

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
@@ -6,58 +6,58 @@
6
6
  Frontend.md §8.1 (4.5:1 text, 3:1 components). */
7
7
  :root {
8
8
  --radius: 0.5rem;
9
- --background: oklch(0.9751 0.0127 244.2507);
10
- --foreground: oklch(0.3729 0.0306 259.7328);
11
- --card: oklch(1.0000 0 0);
12
- --card-foreground: oklch(0.3729 0.0306 259.7328);
13
- --popover: oklch(1.0000 0 0);
14
- --popover-foreground: oklch(0.3729 0.0306 259.7328);
15
- --primary: oklch(0.7227 0.1920 149.5793);
16
- --primary-foreground: oklch(1.0000 0 0);
17
- --secondary: oklch(0.9514 0.0250 236.8242);
18
- --secondary-foreground: oklch(0.4461 0.0263 256.8018);
19
- --muted: oklch(0.9670 0.0029 264.5419);
20
- --muted-foreground: oklch(0.5510 0.0234 264.3637);
21
- --accent: oklch(0.9505 0.0507 163.0508);
22
- --accent-foreground: oklch(0.3729 0.0306 259.7328);
23
- --destructive: oklch(0.6368 0.2078 25.3313);
24
- --border: oklch(0.9276 0.0058 264.5313);
25
- --input: oklch(0.9276 0.0058 264.5313);
26
- --ring: oklch(0.7227 0.1920 149.5793);
27
- --destructive-foreground: oklch(1.0000 0 0);
28
- --chart-1: oklch(0.7227 0.1920 149.5793);
29
- --chart-2: oklch(0.6959 0.1491 162.4796);
30
- --chart-3: oklch(0.5960 0.1274 163.2254);
31
- --chart-4: oklch(0.5081 0.1049 165.6121);
32
- --chart-5: oklch(0.4318 0.0865 166.9128);
33
- --sidebar: oklch(0.9514 0.0250 236.8242);
34
- --sidebar-foreground: oklch(0.3729 0.0306 259.7328);
35
- --sidebar-primary: oklch(0.7227 0.1920 149.5793);
36
- --sidebar-primary-foreground: oklch(1.0000 0 0);
37
- --sidebar-accent: oklch(0.9505 0.0507 163.0508);
38
- --sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
39
- --sidebar-border: oklch(0.9276 0.0058 264.5313);
40
- --sidebar-ring: oklch(0.7227 0.1920 149.5793);
41
- --font-sans: DM Sans, sans-serif;
42
- --font-serif: Lora, serif;
43
- --font-mono: IBM Plex Mono, monospace;
44
- --shadow-color: hsl(0 0% 0%);
45
- --shadow-opacity: 0.1;
46
- --shadow-blur: 8px;
47
- --shadow-spread: -1px;
9
+ --background: oklch(0.9911 0 0);
10
+ --foreground: oklch(0.2046 0 0);
11
+ --card: oklch(0.9911 0 0);
12
+ --card-foreground: oklch(0.2046 0 0);
13
+ --popover: oklch(0.9911 0 0);
14
+ --popover-foreground: oklch(0.4386 0 0);
15
+ --primary: oklch(0.8348 0.1302 160.9080);
16
+ --primary-foreground: oklch(0.2626 0.0147 166.4589);
17
+ --secondary: oklch(0.9940 0 0);
18
+ --secondary-foreground: oklch(0.2046 0 0);
19
+ --muted: oklch(0.9461 0 0);
20
+ --muted-foreground: oklch(0.2435 0 0);
21
+ --accent: oklch(0.9461 0 0);
22
+ --accent-foreground: oklch(0.2435 0 0);
23
+ --destructive: oklch(0.5523 0.1927 32.7272);
24
+ --border: oklch(0.9037 0 0);
25
+ --input: oklch(0.9731 0 0);
26
+ --ring: oklch(0.8348 0.1302 160.9080);
27
+ --destructive-foreground: oklch(0.9934 0.0032 17.2118);
28
+ --chart-1: oklch(0.8348 0.1302 160.9080);
29
+ --chart-2: oklch(0.6231 0.1880 259.8145);
30
+ --chart-3: oklch(0.6056 0.2189 292.7172);
31
+ --chart-4: oklch(0.7686 0.1647 70.0804);
32
+ --chart-5: oklch(0.6959 0.1491 162.4796);
33
+ --sidebar: oklch(0.9911 0 0);
34
+ --sidebar-foreground: oklch(0.5452 0 0);
35
+ --sidebar-primary: oklch(0.8348 0.1302 160.9080);
36
+ --sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589);
37
+ --sidebar-accent: oklch(0.9461 0 0);
38
+ --sidebar-accent-foreground: oklch(0.2435 0 0);
39
+ --sidebar-border: oklch(0.9037 0 0);
40
+ --sidebar-ring: oklch(0.8348 0.1302 160.9080);
41
+ --font-sans: Outfit, sans-serif;
42
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
43
+ --font-mono: monospace;
44
+ --shadow-color: #000000;
45
+ --shadow-opacity: 0.17;
46
+ --shadow-blur: 3px;
47
+ --shadow-spread: 0px;
48
48
  --shadow-offset-x: 0px;
49
- --shadow-offset-y: 4px;
50
- --letter-spacing: 0em;
49
+ --shadow-offset-y: 1px;
50
+ --letter-spacing: 0.025em;
51
51
  --spacing: 0.25rem;
52
- --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
53
- --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
54
- --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
55
- --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
56
- --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
57
- --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
58
- --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
59
- --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
60
- --tracking-normal: 0em;
52
+ --shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
53
+ --shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
54
+ --shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
55
+ --shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
56
+ --shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
57
+ --shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
58
+ --shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
59
+ --shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
60
+ --tracking-normal: 0.025em;
61
61
  }
62
62
 
63
63
  @theme inline {
@@ -83,9 +83,9 @@
83
83
  --color-border: var(--border);
84
84
  --color-input: var(--input);
85
85
  --color-ring: var(--ring);
86
- --font-sans: DM Sans, sans-serif;
87
- --font-mono: IBM Plex Mono, monospace;
88
- --font-serif: Lora, serif;
86
+ --font-sans: Outfit, sans-serif;
87
+ --font-mono: monospace;
88
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
89
89
  --radius: 0.5rem;
90
90
  --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
91
91
  --tracking-tight: calc(var(--tracking-normal) - 0.025em);
@@ -139,56 +139,56 @@
139
139
  }
140
140
 
141
141
  .dark {
142
- --background: oklch(0.2077 0.0398 265.7549);
143
- --foreground: oklch(0.8717 0.0093 258.3382);
144
- --card: oklch(0.2795 0.0368 260.0310);
145
- --card-foreground: oklch(0.8717 0.0093 258.3382);
146
- --popover: oklch(0.2795 0.0368 260.0310);
147
- --popover-foreground: oklch(0.8717 0.0093 258.3382);
148
- --primary: oklch(0.7729 0.1535 163.2231);
149
- --primary-foreground: oklch(0.2077 0.0398 265.7549);
150
- --secondary: oklch(0.3351 0.0331 260.9120);
151
- --secondary-foreground: oklch(0.7118 0.0129 286.0665);
152
- --muted: oklch(0.2463 0.0275 259.9628);
153
- --muted-foreground: oklch(0.5510 0.0234 264.3637);
154
- --accent: oklch(0.3729 0.0306 259.7328);
155
- --accent-foreground: oklch(0.7118 0.0129 286.0665);
156
- --destructive: oklch(0.6368 0.2078 25.3313);
157
- --destructive-foreground: oklch(0.2077 0.0398 265.7549);
158
- --border: oklch(0.4461 0.0263 256.8018);
159
- --input: oklch(0.4461 0.0263 256.8018);
160
- --ring: oklch(0.7729 0.1535 163.2231);
161
- --chart-1: oklch(0.7729 0.1535 163.2231);
162
- --chart-2: oklch(0.7845 0.1325 181.9120);
163
- --chart-3: oklch(0.7227 0.1920 149.5793);
164
- --chart-4: oklch(0.6959 0.1491 162.4796);
165
- --chart-5: oklch(0.5960 0.1274 163.2254);
142
+ --background: oklch(0.1822 0 0);
143
+ --foreground: oklch(0.9288 0.0126 255.5078);
144
+ --card: oklch(0.2046 0 0);
145
+ --card-foreground: oklch(0.9288 0.0126 255.5078);
146
+ --popover: oklch(0.2603 0 0);
147
+ --popover-foreground: oklch(0.7348 0 0);
148
+ --primary: oklch(0.4365 0.1044 156.7556);
149
+ --primary-foreground: oklch(0.9213 0.0135 167.1556);
150
+ --secondary: oklch(0.2603 0 0);
151
+ --secondary-foreground: oklch(0.9851 0 0);
152
+ --muted: oklch(0.2393 0 0);
153
+ --muted-foreground: oklch(0.7122 0 0);
154
+ --accent: oklch(0.3132 0 0);
155
+ --accent-foreground: oklch(0.9851 0 0);
156
+ --destructive: oklch(0.3123 0.0852 29.7877);
157
+ --destructive-foreground: oklch(0.9368 0.0045 34.3092);
158
+ --border: oklch(0.2809 0 0);
159
+ --input: oklch(0.2603 0 0);
160
+ --ring: oklch(0.8003 0.1821 151.7110);
161
+ --chart-1: oklch(0.8003 0.1821 151.7110);
162
+ --chart-2: oklch(0.7137 0.1434 254.6240);
163
+ --chart-3: oklch(0.7090 0.1592 293.5412);
164
+ --chart-4: oklch(0.8369 0.1644 84.4286);
165
+ --chart-5: oklch(0.7845 0.1325 181.9120);
166
166
  --radius: 0.5rem;
167
- --sidebar: oklch(0.2795 0.0368 260.0310);
168
- --sidebar-foreground: oklch(0.8717 0.0093 258.3382);
169
- --sidebar-primary: oklch(0.7729 0.1535 163.2231);
170
- --sidebar-primary-foreground: oklch(0.2077 0.0398 265.7549);
171
- --sidebar-accent: oklch(0.3729 0.0306 259.7328);
172
- --sidebar-accent-foreground: oklch(0.7118 0.0129 286.0665);
173
- --sidebar-border: oklch(0.4461 0.0263 256.8018);
174
- --sidebar-ring: oklch(0.7729 0.1535 163.2231);
175
- --font-sans: DM Sans, sans-serif;
176
- --font-serif: Lora, serif;
177
- --font-mono: IBM Plex Mono, monospace;
178
- --shadow-color: hsl(0 0% 0%);
179
- --shadow-opacity: 0.1;
180
- --shadow-blur: 8px;
181
- --shadow-spread: -1px;
167
+ --sidebar: oklch(0.1822 0 0);
168
+ --sidebar-foreground: oklch(0.6301 0 0);
169
+ --sidebar-primary: oklch(0.4365 0.1044 156.7556);
170
+ --sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556);
171
+ --sidebar-accent: oklch(0.3132 0 0);
172
+ --sidebar-accent-foreground: oklch(0.9851 0 0);
173
+ --sidebar-border: oklch(0.2809 0 0);
174
+ --sidebar-ring: oklch(0.8003 0.1821 151.7110);
175
+ --font-sans: Outfit, sans-serif;
176
+ --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
177
+ --font-mono: monospace;
178
+ --shadow-color: #000000;
179
+ --shadow-opacity: 0.17;
180
+ --shadow-blur: 3px;
181
+ --shadow-spread: 0px;
182
182
  --shadow-offset-x: 0px;
183
- --shadow-offset-y: 4px;
184
- --letter-spacing: 0em;
183
+ --shadow-offset-y: 1px;
184
+ --letter-spacing: 0.025em;
185
185
  --spacing: 0.25rem;
186
- --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
187
- --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
188
- --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
189
- --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
190
- --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
191
- --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
192
- --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
193
- --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
186
+ --shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
187
+ --shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
188
+ --shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
189
+ --shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
190
+ --shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
191
+ --shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
192
+ --shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
193
+ --shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
194
194
  }
@@ -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>