@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.
- package/backend/api/openapi.yaml +70 -0
- package/backend/cmd/server/main.go +10 -2
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -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/session.go +20 -1
- package/backend/internal/server/api.go +14 -0
- package/backend/internal/server/public.go +3 -1
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -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 +53 -22
- 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/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
|
@@ -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.
|
|
10
|
-
--foreground: oklch(0.
|
|
11
|
-
--card: oklch(
|
|
12
|
-
--card-foreground: oklch(0.
|
|
13
|
-
--popover: oklch(
|
|
14
|
-
--popover-foreground: oklch(0.
|
|
15
|
-
--primary: oklch(0.
|
|
16
|
-
--primary-foreground: oklch(
|
|
17
|
-
--secondary: oklch(0.
|
|
18
|
-
--secondary-foreground: oklch(0.
|
|
19
|
-
--muted: oklch(0.
|
|
20
|
-
--muted-foreground: oklch(0.
|
|
21
|
-
--accent: oklch(0.
|
|
22
|
-
--accent-foreground: oklch(0.
|
|
23
|
-
--destructive: oklch(0.
|
|
24
|
-
--border: oklch(0.
|
|
25
|
-
--input: oklch(0.
|
|
26
|
-
--ring: oklch(0.
|
|
27
|
-
--destructive-foreground: oklch(
|
|
28
|
-
--chart-1: oklch(0.
|
|
29
|
-
--chart-2: oklch(0.
|
|
30
|
-
--chart-3: oklch(0.
|
|
31
|
-
--chart-4: oklch(0.
|
|
32
|
-
--chart-5: oklch(0.
|
|
33
|
-
--sidebar: oklch(0.
|
|
34
|
-
--sidebar-foreground: oklch(0.
|
|
35
|
-
--sidebar-primary: oklch(0.
|
|
36
|
-
--sidebar-primary-foreground: oklch(
|
|
37
|
-
--sidebar-accent: oklch(0.
|
|
38
|
-
--sidebar-accent-foreground: oklch(0.
|
|
39
|
-
--sidebar-border: oklch(0.
|
|
40
|
-
--sidebar-ring: oklch(0.
|
|
41
|
-
--font-sans:
|
|
42
|
-
--font-serif:
|
|
43
|
-
--font-mono:
|
|
44
|
-
--shadow-color:
|
|
45
|
-
--shadow-opacity: 0.
|
|
46
|
-
--shadow-blur:
|
|
47
|
-
--shadow-spread:
|
|
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:
|
|
50
|
-
--letter-spacing:
|
|
49
|
+
--shadow-offset-y: 1px;
|
|
50
|
+
--letter-spacing: 0.025em;
|
|
51
51
|
--spacing: 0.25rem;
|
|
52
|
-
--shadow-2xs: 0px
|
|
53
|
-
--shadow-xs: 0px
|
|
54
|
-
--shadow-sm: 0px
|
|
55
|
-
--shadow: 0px
|
|
56
|
-
--shadow-md: 0px
|
|
57
|
-
--shadow-lg: 0px
|
|
58
|
-
--shadow-xl: 0px
|
|
59
|
-
--shadow-2xl: 0px
|
|
60
|
-
--tracking-normal:
|
|
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:
|
|
87
|
-
--font-mono:
|
|
88
|
-
--font-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.
|
|
143
|
-
--foreground: oklch(0.
|
|
144
|
-
--card: oklch(0.
|
|
145
|
-
--card-foreground: oklch(0.
|
|
146
|
-
--popover: oklch(0.
|
|
147
|
-
--popover-foreground: oklch(0.
|
|
148
|
-
--primary: oklch(0.
|
|
149
|
-
--primary-foreground: oklch(0.
|
|
150
|
-
--secondary: oklch(0.
|
|
151
|
-
--secondary-foreground: oklch(0.
|
|
152
|
-
--muted: oklch(0.
|
|
153
|
-
--muted-foreground: oklch(0.
|
|
154
|
-
--accent: oklch(0.
|
|
155
|
-
--accent-foreground: oklch(0.
|
|
156
|
-
--destructive: oklch(0.
|
|
157
|
-
--destructive-foreground: oklch(0.
|
|
158
|
-
--border: oklch(0.
|
|
159
|
-
--input: oklch(0.
|
|
160
|
-
--ring: oklch(0.
|
|
161
|
-
--chart-1: oklch(0.
|
|
162
|
-
--chart-2: oklch(0.
|
|
163
|
-
--chart-3: oklch(0.
|
|
164
|
-
--chart-4: oklch(0.
|
|
165
|
-
--chart-5: oklch(0.
|
|
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.
|
|
168
|
-
--sidebar-foreground: oklch(0.
|
|
169
|
-
--sidebar-primary: oklch(0.
|
|
170
|
-
--sidebar-primary-foreground: oklch(0.
|
|
171
|
-
--sidebar-accent: oklch(0.
|
|
172
|
-
--sidebar-accent-foreground: oklch(0.
|
|
173
|
-
--sidebar-border: oklch(0.
|
|
174
|
-
--sidebar-ring: oklch(0.
|
|
175
|
-
--font-sans:
|
|
176
|
-
--font-serif:
|
|
177
|
-
--font-mono:
|
|
178
|
-
--shadow-color:
|
|
179
|
-
--shadow-opacity: 0.
|
|
180
|
-
--shadow-blur:
|
|
181
|
-
--shadow-spread:
|
|
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:
|
|
184
|
-
--letter-spacing:
|
|
183
|
+
--shadow-offset-y: 1px;
|
|
184
|
+
--letter-spacing: 0.025em;
|
|
185
185
|
--spacing: 0.25rem;
|
|
186
|
-
--shadow-2xs: 0px
|
|
187
|
-
--shadow-xs: 0px
|
|
188
|
-
--shadow-sm: 0px
|
|
189
|
-
--shadow: 0px
|
|
190
|
-
--shadow-md: 0px
|
|
191
|
-
--shadow-lg: 0px
|
|
192
|
-
--shadow-xl: 0px
|
|
193
|
-
--shadow-2xl: 0px
|
|
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
|
|
2
|
-
import { useTranslation } from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import type
|
|
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(
|
|
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 ===
|
|
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(
|
|
36
|
+
document.title = `${title} · ${t("app.name")}`;
|
|
35
37
|
ref.current?.focus();
|
|
36
38
|
}, [title, t]);
|
|
37
39
|
return (
|
|
38
|
-
<h1
|
|
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({
|
|
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
|
|
49
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 ===
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
66
|
-
<p className="max-w-xs text-sm text-balance text-muted-foreground">
|
|
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
|
|
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(
|
|
84
|
+
{t("pages.register.title")}
|
|
83
85
|
</h1>
|
|
84
86
|
</CardTitle>
|
|
85
|
-
<CardDescription>{t(
|
|
87
|
+
<CardDescription>{t("pages.register.description")}</CardDescription>
|
|
86
88
|
</CardHeader>
|
|
87
89
|
<CardContent>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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>
|