@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.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- 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-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
<div
|
|
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
|
-
/**
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
19
|
+
async function open(path: string, account: Account = USER) {
|
|
20
20
|
clearCsrfToken();
|
|
21
|
-
let session:
|
|
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('
|
|
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 {
|
|
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
|
|
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-
|
|
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}
|