@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +70 -0
- package/backend/cmd/server/main.go +17 -1
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -0
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -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/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +20 -1
- package/backend/internal/server/api.go +19 -0
- package/backend/internal/server/public.go +37 -0
- package/backend/internal/server/public_test.go +73 -0
- package/backend/internal/server/server.go +7 -3
- package/backend/internal/shared/config/config.go +23 -4
- package/backend/internal/shared/config/config_test.go +14 -8
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -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 +71 -18
- 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/api/csrf.ts +51 -0
- package/frontend/src/shared/api/http.test.tsx +209 -0
- package/frontend/src/shared/api/http.ts +91 -0
- package/frontend/src/shared/api/index.ts +3 -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
|
@@ -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>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { http, HttpResponse } from "msw";
|
|
5
|
+
import { createMemoryRouter } from "react-router";
|
|
6
|
+
import { axe } from "vitest-axe";
|
|
7
|
+
import { clearCsrfToken } from "@/shared/api";
|
|
8
|
+
import { problem } from "@/shared/api/mocks/problem";
|
|
9
|
+
import { server } from "@/shared/api/mocks/server";
|
|
10
|
+
import { initI18n } from "@/shared/i18n";
|
|
11
|
+
import { App } from "../App";
|
|
12
|
+
import { routes } from "../router";
|
|
13
|
+
|
|
14
|
+
const API = "http://localhost:3000/api/v1";
|
|
15
|
+
const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
|
|
16
|
+
const ADMIN = { empNo: "000001", name: "Root", email: null, role: "ADMIN" };
|
|
17
|
+
|
|
18
|
+
/** A visitor on `path`; `account` is who a successful login returns. `GET /auth/me` follows the session. */
|
|
19
|
+
async function open(path: string, account: typeof USER | typeof ADMIN = USER) {
|
|
20
|
+
clearCsrfToken();
|
|
21
|
+
let session: typeof USER | typeof ADMIN | null = null;
|
|
22
|
+
const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
|
|
23
|
+
login: [],
|
|
24
|
+
logoutCsrf: [],
|
|
25
|
+
};
|
|
26
|
+
server.use(
|
|
27
|
+
http.get(`${API}/auth/me`, () =>
|
|
28
|
+
session ? HttpResponse.json(session) : problem(401, "UNAUTHENTICATED"),
|
|
29
|
+
),
|
|
30
|
+
http.get(`${API}/deadline`, () =>
|
|
31
|
+
HttpResponse.json({
|
|
32
|
+
deadline: null,
|
|
33
|
+
state: "OPEN",
|
|
34
|
+
serverTime: "2026-11-08T00:00:00.000Z",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
http.get(`${API}/auth/csrf`, () =>
|
|
38
|
+
HttpResponse.json({ token: session ? "tok-session" : "tok-anon" }),
|
|
39
|
+
),
|
|
40
|
+
http.post(`${API}/auth/login`, async ({ request }) => {
|
|
41
|
+
calls.login.push(await request.json());
|
|
42
|
+
session = account;
|
|
43
|
+
return HttpResponse.json(account);
|
|
44
|
+
}),
|
|
45
|
+
http.post(`${API}/auth/logout`, ({ request }) => {
|
|
46
|
+
calls.logoutCsrf.push(request.headers.get("X-CSRF-Token"));
|
|
47
|
+
session = null;
|
|
48
|
+
return new HttpResponse(null, { status: 204 });
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
await initI18n("en");
|
|
52
|
+
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
|
53
|
+
const view = render(<App router={router} client={new QueryClient()} />);
|
|
54
|
+
await screen.findByRole("heading", {
|
|
55
|
+
level: 1,
|
|
56
|
+
name: "Sign in to your account",
|
|
57
|
+
});
|
|
58
|
+
return { router, calls, user: userEvent.setup(), ...view };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function signIn(
|
|
62
|
+
user: ReturnType<typeof userEvent.setup>,
|
|
63
|
+
empNo = "089290",
|
|
64
|
+
password = "secret",
|
|
65
|
+
) {
|
|
66
|
+
await user.type(screen.getByLabelText("Employee ID"), empNo);
|
|
67
|
+
await user.type(screen.getByLabelText("Password"), password);
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("Login page", () => {
|
|
72
|
+
it("has labelled, autocompleted fields and no axe violations", async () => {
|
|
73
|
+
const { container } = await open("/login");
|
|
74
|
+
expect(screen.getByLabelText("Employee ID")).toHaveAttribute(
|
|
75
|
+
"autocomplete",
|
|
76
|
+
"username",
|
|
77
|
+
);
|
|
78
|
+
expect(screen.getByLabelText("Password")).toHaveAttribute(
|
|
79
|
+
"autocomplete",
|
|
80
|
+
"current-password",
|
|
81
|
+
);
|
|
82
|
+
expect((await axe(container)).violations).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("signs a User in and lands on /ideas", async () => {
|
|
86
|
+
const { router, calls, user } = await open("/login");
|
|
87
|
+
await signIn(user, " 089290 ", "secret");
|
|
88
|
+
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
89
|
+
expect(calls.login).toEqual([{ empNo: "089290", password: "secret" }]);
|
|
90
|
+
expect(await screen.findByText("Wang")).toBeInTheDocument();
|
|
91
|
+
expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("signs an Admin in, to the same page, with the Administrator badge", async () => {
|
|
95
|
+
const { router, user } = await open("/login", ADMIN);
|
|
96
|
+
await signIn(user, "000001");
|
|
97
|
+
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
98
|
+
expect(await screen.findByText("Administrator")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("follows a role-allowed next", async () => {
|
|
102
|
+
const { router, user } = await open("/login?next=%2Fjoin-requests");
|
|
103
|
+
await signIn(user);
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(router.state.location.pathname).toBe("/join-requests"),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it.each([
|
|
110
|
+
"//evil.example",
|
|
111
|
+
"https://evil.example/x",
|
|
112
|
+
"/admin/deadline",
|
|
113
|
+
"/nope",
|
|
114
|
+
"\\\\evil.example",
|
|
115
|
+
])("ignores next=%s", async (next) => {
|
|
116
|
+
const { router, user } = await open(
|
|
117
|
+
`/login?next=${encodeURIComponent(next)}`,
|
|
118
|
+
);
|
|
119
|
+
await signIn(user);
|
|
120
|
+
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
121
|
+
expect(router.state.location.search).toBe("");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("shows one message for a wrong password, clears the password and focuses the message", async () => {
|
|
125
|
+
const { user, container } = await open("/login");
|
|
126
|
+
server.use(
|
|
127
|
+
http.post(`${API}/auth/login`, () => problem(401, "INVALID_CREDENTIALS")),
|
|
128
|
+
);
|
|
129
|
+
await signIn(user, "089290", "wrong");
|
|
130
|
+
const alert = await screen.findByRole("alert");
|
|
131
|
+
expect(alert).toHaveTextContent(
|
|
132
|
+
"The employee ID or password is incorrect.",
|
|
133
|
+
);
|
|
134
|
+
expect(alert).toHaveFocus();
|
|
135
|
+
expect(screen.getByLabelText("Password")).toHaveValue("");
|
|
136
|
+
expect(screen.getByLabelText("Employee ID")).toHaveValue("089290");
|
|
137
|
+
expect((await axe(container)).violations).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("shows the throttle message", async () => {
|
|
141
|
+
const { user } = await open("/login");
|
|
142
|
+
server.use(
|
|
143
|
+
http.post(`${API}/auth/login`, () =>
|
|
144
|
+
problem(429, "LOGIN_THROTTLED", {}, { "Retry-After": "60" }),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
await signIn(user);
|
|
148
|
+
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
149
|
+
"Too many sign-in attempts",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shows the registered and session-ended notices", async () => {
|
|
154
|
+
const router = createMemoryRouter(routes, {
|
|
155
|
+
initialEntries: [
|
|
156
|
+
{ pathname: "/login", state: { notice: "sessionEnded" } },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
server.use(
|
|
160
|
+
http.get(`${API}/auth/me`, () => problem(401, "UNAUTHENTICATED")),
|
|
161
|
+
http.get(`${API}/deadline`, () =>
|
|
162
|
+
HttpResponse.json({
|
|
163
|
+
deadline: null,
|
|
164
|
+
state: "OPEN",
|
|
165
|
+
serverTime: "2026-11-08T00:00:00.000Z",
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
await initI18n("en");
|
|
170
|
+
render(<App router={router} client={new QueryClient()} />);
|
|
171
|
+
expect(await screen.findByRole("status", { name: "" })).toBeInTheDocument();
|
|
172
|
+
expect(
|
|
173
|
+
await screen.findByText("Your session has ended. Please sign in again."),
|
|
174
|
+
).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Sign out", () => {
|
|
179
|
+
it("ends the session with the session-bound CSRF token and returns to a clean /login", async () => {
|
|
180
|
+
const { router, calls, user } = await open("/login");
|
|
181
|
+
await signIn(user);
|
|
182
|
+
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
183
|
+
await screen.findByRole("heading", {
|
|
184
|
+
level: 1,
|
|
185
|
+
name: "Sign in to your account",
|
|
186
|
+
});
|
|
187
|
+
expect(calls.logoutCsrf).toEqual(["tok-session"]);
|
|
188
|
+
expect(router.state.location.pathname).toBe("/login");
|
|
189
|
+
expect(router.state.location.search).toBe("");
|
|
190
|
+
expect(
|
|
191
|
+
screen.queryByText("Your session has ended. Please sign in again."),
|
|
192
|
+
).not.toBeInTheDocument();
|
|
193
|
+
expect(screen.queryByText("Wang")).not.toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("a second login after logout uses a fresh CSRF token", async () => {
|
|
197
|
+
const { router, user } = await open("/login");
|
|
198
|
+
await signIn(user);
|
|
199
|
+
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
200
|
+
await screen.findByRole("heading", {
|
|
201
|
+
level: 1,
|
|
202
|
+
name: "Sign in to your account",
|
|
203
|
+
});
|
|
204
|
+
await signIn(user);
|
|
205
|
+
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("tells the user when sign-out fails and keeps them signed in", async () => {
|
|
209
|
+
const { router, user } = await open("/login");
|
|
210
|
+
await signIn(user);
|
|
211
|
+
server.use(
|
|
212
|
+
http.post(`${API}/auth/logout`, () => problem(500, "INTERNAL_ERROR")),
|
|
213
|
+
);
|
|
214
|
+
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
215
|
+
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
|
216
|
+
expect(router.state.location.pathname).toBe("/ideas");
|
|
217
|
+
});
|
|
218
|
+
});
|