@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.0

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.
@@ -0,0 +1,52 @@
1
+ package matrix
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "slices"
8
+ "testing"
9
+ )
10
+
11
+ // The data file itself is checked without Docker: ids are unique, actors are
12
+ // known, and every expected code exists in the shared error catalogue with the
13
+ // status the catalogue gives it.
14
+ func TestMatrixFileIsConsistent(t *testing.T) {
15
+ raw, err := os.ReadFile(filepath.Join(testdata, "error-codes.json"))
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ var cat []struct {
20
+ Code string
21
+ Status int
22
+ }
23
+ if err := json.Unmarshal(raw, &cat); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ status := map[string]int{}
27
+ for _, c := range cat {
28
+ status[c.Code] = c.Status
29
+ }
30
+
31
+ seen := map[string]bool{}
32
+ for _, r := range load(t) {
33
+ if seen[r.ID] {
34
+ t.Errorf("duplicate row id %q", r.ID)
35
+ }
36
+ seen[r.ID] = true
37
+ for actor, c := range r.Expect {
38
+ if !slices.Contains(Actors, actor) {
39
+ t.Errorf("%s: unknown actor %q", r.ID, actor)
40
+ }
41
+ if c.Code == "" {
42
+ if c.Status >= 400 {
43
+ t.Errorf("%s/%s: %d needs a code", r.ID, actor, c.Status)
44
+ }
45
+ continue
46
+ }
47
+ if want, ok := status[c.Code]; !ok || want != c.Status {
48
+ t.Errorf("%s/%s: %s is %d in the catalogue, row says %d", r.ID, actor, c.Code, want, c.Status)
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,50 @@
1
+ // Package matrix runs the permission matrix (PRD §7) from
2
+ // testdata/permission-matrix.json: every cell is one test case.
3
+ package matrix
4
+
5
+ import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+ )
12
+
13
+ const testdata = "../../../testdata"
14
+
15
+ // Cell is the expected outcome of one (row, actor) pair.
16
+ type Cell struct {
17
+ Status int `json:"status"`
18
+ Code string `json:"code"`
19
+ }
20
+
21
+ // Row is one operation or check-order case.
22
+ type Row struct {
23
+ ID string `json:"id"`
24
+ Operation string `json:"operation"`
25
+ Method string `json:"method"`
26
+ Path string `json:"path"`
27
+ Body json.RawMessage `json:"body"`
28
+ CSRF string `json:"csrf"`
29
+ OutsideSpec bool `json:"outsideSpec"`
30
+ Expect map[string]Cell `json:"expect"`
31
+ }
32
+
33
+ // Actors in PRD §7 order. The harness has a fixture for each one it lists in
34
+ // fixtures; a row naming any other actor fails loudly instead of passing.
35
+ var Actors = []string{"visitor", "user", "member", "leader", "admin"}
36
+
37
+ func load(tb testing.TB) []Row {
38
+ tb.Helper()
39
+ raw, err := os.ReadFile(filepath.Join(testdata, "permission-matrix.json"))
40
+ if err != nil {
41
+ tb.Fatal(err)
42
+ }
43
+ var m struct{ Rows []Row }
44
+ dec := json.NewDecoder(bytes.NewReader(raw))
45
+ dec.DisallowUnknownFields()
46
+ if err := dec.Decode(&m); err != nil {
47
+ tb.Fatalf("permission-matrix.json: %v", err)
48
+ }
49
+ return m.Rows
50
+ }
@@ -0,0 +1,210 @@
1
+ //go:build integration
2
+
3
+ package matrix
4
+
5
+ import (
6
+ "database/sql"
7
+ "encoding/json"
8
+ "fmt"
9
+ "log/slog"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+
16
+ "github.com/google/uuid"
17
+ "golang.org/x/crypto/bcrypt"
18
+
19
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
20
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
21
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
22
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
23
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
24
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
25
+ "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
26
+ )
27
+
28
+ const password = "Correct-Horse-1"
29
+
30
+ type env struct {
31
+ db *sql.DB
32
+ plain http.Handler // production chain
33
+ checked http.Handler // same chain, every response validated against openapi.yaml
34
+ seq atomic.Int64
35
+ }
36
+
37
+ // newEnv wires the same chain as cmd/esg: this harness must fail if the
38
+ // middleware order in production changes, so it must not build its own.
39
+ func newEnv(t *testing.T) *env {
40
+ t.Helper()
41
+ db := dbtest.New(t)
42
+ log := slog.New(slog.DiscardHandler)
43
+ auditSvc := audit.New(db, log)
44
+ errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
45
+ csrf, err := auth.NewCSRF(strings.Repeat("k", 32))
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ sessions := auth.New(db)
50
+ logins, err := auth.NewLogins(sessions, auditSvc, auth.NoThrottle{})
51
+ if err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ si := server.API{
55
+ Auth: auth.Handler{CSRF: csrf, Log: log},
56
+ Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
57
+ Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
58
+ Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
59
+ Account: account.Handler{
60
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
61
+ Fail: errs.Write,
62
+ },
63
+ Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
64
+ }
65
+ chain := server.NewPublic(log, server.Mount(si),
66
+ auth.Middleware(sessions, server.IsPublic, log),
67
+ csrf.Middleware(errs.Write, server.ActionFor))
68
+ return &env{db: db, plain: chain, checked: apispec.Load(t).Validate(t, chain)}
69
+ }
70
+
71
+ func (e *env) do(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
72
+ rec := httptest.NewRecorder()
73
+ h.ServeHTTP(rec, req)
74
+ return rec
75
+ }
76
+
77
+ func (e *env) addUser(t *testing.T, empNo, role string) {
78
+ t.Helper()
79
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
80
+ if err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ var email any = empNo + "@example.com"
84
+ if role == "ADMIN" {
85
+ email = nil
86
+ }
87
+ if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
88
+ uuid.NewString(), empNo, "N "+empNo, email, string(hash), role, true); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ }
92
+
93
+ // actor is one caller of the matrix: its employee number and, when logged in,
94
+ // its session cookie.
95
+ type actor struct {
96
+ empNo string
97
+ session *http.Cookie
98
+ }
99
+
100
+ // fixtures lists the actors the harness can build. Idea-related actors (member,
101
+ // leader) arrive with the PRs that create Ideas.
102
+ var fixtures = map[string]string{"visitor": "", "user": "USER", "admin": "ADMIN"}
103
+
104
+ // newActor creates a fresh account (logout and login change session state, so
105
+ // no two cells share one) and logs in through the API.
106
+ func (e *env) newActor(t *testing.T, name string) actor {
107
+ t.Helper()
108
+ role, ok := fixtures[name]
109
+ if !ok {
110
+ t.Fatalf("matrix actor %q has no fixture; add one before adding cells for it", name)
111
+ }
112
+ if role == "" {
113
+ return actor{empNo: "000000"}
114
+ }
115
+ empNo := fmt.Sprintf("%06d", 100000+e.seq.Add(1))
116
+ e.addUser(t, empNo, role)
117
+ tok, ck := e.csrf(t, nil)
118
+ body := fmt.Sprintf(`{"empNo":%q,"password":%q}`, empNo, password)
119
+ req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
120
+ req.Header.Set("Content-Type", "application/json")
121
+ req.Header.Set(auth.CSRFHeader, tok)
122
+ req.AddCookie(ck)
123
+ rec := e.do(e.plain, req)
124
+ if rec.Code != 200 {
125
+ t.Fatalf("login %s: %d %s", name, rec.Code, rec.Body)
126
+ }
127
+ return actor{empNo: empNo, session: cookie(rec, auth.SessionCookie)}
128
+ }
129
+
130
+ func cookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
131
+ for _, c := range rec.Result().Cookies() {
132
+ if c.Name == name {
133
+ return c
134
+ }
135
+ }
136
+ return nil
137
+ }
138
+
139
+ func (e *env) csrf(t *testing.T, session *http.Cookie) (string, *http.Cookie) {
140
+ t.Helper()
141
+ req := httptest.NewRequest("GET", "/api/v1/auth/csrf", nil)
142
+ if session != nil {
143
+ req.AddCookie(session)
144
+ }
145
+ rec := e.do(e.plain, req)
146
+ var b struct{ Token string }
147
+ if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil || rec.Code != 200 {
148
+ t.Fatalf("csrf: %d %s", rec.Code, rec.Body)
149
+ }
150
+ return b.Token, cookie(rec, auth.CSRFCookie)
151
+ }
152
+
153
+ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
154
+ t.Helper()
155
+ var body *strings.Reader
156
+ if r.Body != nil {
157
+ s := string(r.Body)
158
+ s = strings.ReplaceAll(s, "${password}", password)
159
+ s = strings.ReplaceAll(s, "${actorEmpNo}", a.empNo)
160
+ s = strings.ReplaceAll(s, "${newEmpNo}", fmt.Sprintf("%06d", 500000+e.seq.Add(1)))
161
+ body = strings.NewReader(s)
162
+ }
163
+ var req *http.Request
164
+ if body != nil {
165
+ req = httptest.NewRequest(r.Method, r.Path, body)
166
+ req.Header.Set("Content-Type", "application/json")
167
+ } else {
168
+ req = httptest.NewRequest(r.Method, r.Path, nil)
169
+ }
170
+ req.RemoteAddr = "192.0.2.9:4000"
171
+ req.Header.Set("User-Agent", "matrix/1")
172
+ if a.session != nil {
173
+ req.AddCookie(a.session)
174
+ }
175
+ if r.Method != http.MethodGet && r.CSRF != "none" {
176
+ tok, ck := e.csrf(t, a.session)
177
+ req.Header.Set(auth.CSRFHeader, tok)
178
+ req.AddCookie(ck)
179
+ }
180
+ return req
181
+ }
182
+
183
+ func TestPermissionMatrix(t *testing.T) {
184
+ e := newEnv(t)
185
+ for _, r := range load(t) {
186
+ for _, name := range Actors {
187
+ want, ok := r.Expect[name]
188
+ if !ok {
189
+ continue
190
+ }
191
+ t.Run(r.ID+"/"+name, func(t *testing.T) {
192
+ a := e.newActor(t, name)
193
+ h := e.checked
194
+ if r.OutsideSpec {
195
+ h = e.plain
196
+ }
197
+ rec := e.do(h, e.request(t, r, a))
198
+ if rec.Code != want.Status {
199
+ t.Fatalf("%s %s as %s: status %d, want %d (%s)", r.Method, r.Path, name, rec.Code, want.Status, rec.Body)
200
+ }
201
+ if want.Code != "" {
202
+ var p struct{ Code string }
203
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || p.Code != want.Code {
204
+ t.Fatalf("%s %s as %s: code %q, want %q (%s)", r.Method, r.Path, name, p.Code, want.Code, rec.Body)
205
+ }
206
+ }
207
+ })
208
+ }
209
+ }
210
+ }
@@ -1,5 +1,5 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
- import { render, screen, waitFor, within } from '@testing-library/react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { http, HttpResponse } from 'msw';
4
4
  import { createMemoryRouter } from 'react-router';
5
5
  import { axe } from 'vitest-axe';
@@ -9,7 +9,7 @@ import { initI18n } from '@/shared/i18n';
9
9
  import { App } from './App';
10
10
  import { safeNext } from './access';
11
11
  import { PAGES, routes } from './router';
12
- import { ROUTES, navFor, type Role } from './routes';
12
+ import { ROUTES, type Role } from './routes';
13
13
 
14
14
  const API = 'http://localhost:3000/api/v1';
15
15
  const me = (role: Role | null) =>
@@ -32,31 +32,6 @@ async function open(path: string, role: Role | null, state: 'NOT_OPEN' | 'OPEN'
32
32
  /** A concrete URL for a route pattern. */
33
33
  const concrete = (p: string) => p.replace(':id', '11111111-1111-4111-8111-111111111111');
34
34
 
35
- describe('deadline banner (3 states)', () => {
36
- it.each([
37
- ['NOT_OPEN', 'Submissions are not open yet.'],
38
- ['OPEN', 'Submissions are open.'],
39
- ['CLOSED', 'Submissions are closed.'],
40
- ] as const)('%s', async (state, text) => {
41
- await open('/login', null, state);
42
- const region = await screen.findByRole('region', { name: 'Submission status' });
43
- expect(await within(region).findByText(new RegExp(text))).toBeInTheDocument();
44
- expect(region).toHaveAttribute('data-state', state);
45
- });
46
-
47
- it('shows an error state when the deadline cannot be loaded', async () => {
48
- server.use(me(null), http.get(`${API}/deadline`, () => problem(503, 'SERVICE_UNAVAILABLE')));
49
- await initI18n('en');
50
- render(<App router={createMemoryRouter(routes, { initialEntries: ['/login'] })} client={new QueryClient()} />);
51
- expect(await screen.findByText('The submission status could not be loaded.')).toBeInTheDocument();
52
- });
53
-
54
- it('is present in AppShell too', async () => {
55
- await open('/ideas', 'USER', 'CLOSED');
56
- expect(await screen.findByRole('region', { name: 'Submission status' })).toBeInTheDocument();
57
- });
58
- });
59
-
60
35
  describe('route access (Frontend.md §6.3)', () => {
61
36
  const nonPublic = ROUTES.filter((r) => r.access !== 'public');
62
37
 
@@ -98,19 +73,6 @@ describe('route access (Frontend.md §6.3)', () => {
98
73
  });
99
74
  });
100
75
 
101
- describe('navigation matches the route table', () => {
102
- it.each([
103
- ['USER', ['Ideas', 'Join requests']],
104
- ['ADMIN', ['Ideas', 'Deadline', 'Statistics']],
105
- ] as const)('%s', async (role, labels) => {
106
- await open('/ideas', role);
107
- const nav = await screen.findByRole('navigation', { name: 'Main navigation' });
108
- expect(within(nav).getAllByRole('link').map((a) => a.textContent)).toEqual(labels);
109
- expect(within(nav).getAllByRole('link').map((a) => a.getAttribute('href'))).toEqual(navFor(role).map((r) => r.path));
110
- expect(!!screen.queryByText('Administrator')).toBe(role === 'ADMIN');
111
- });
112
- });
113
-
114
76
  describe('safeNext (§6.4)', () => {
115
77
  it.each([
116
78
  ['/ideas?page=2', 'USER', '/ideas?page=2'],
@@ -137,7 +99,6 @@ describe('every route has a page', () => {
137
99
  async (path, role) => {
138
100
  const { container } = await open(concrete(path), role);
139
101
  expect(await screen.findAllByRole('heading', { level: 1 })).toHaveLength(1);
140
- await screen.findByRole('region', { name: 'Submission status' });
141
102
  expect((await axe(container)).violations).toEqual([]);
142
103
  },
143
104
  );
@@ -157,7 +118,6 @@ describe('accessibility', () => {
157
118
  ] as const)('%s as %s has no axe violations', async (path, role) => {
158
119
  const { container } = await open(path, role);
159
120
  await screen.findByRole('heading', { level: 1 });
160
- await screen.findByRole('region', { name: 'Submission status' });
161
121
  expect((await axe(container)).violations).toEqual([]);
162
122
  });
163
123
  });
@@ -12,18 +12,18 @@
12
12
  --card-foreground: oklch(0.2046 0 0);
13
13
  --popover: oklch(0.9911 0 0);
14
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);
15
+ --primary: oklch(0.5203 0.1364 144.2);
16
+ --primary-foreground: oklch(0.9911 0 0);
17
17
  --secondary: oklch(0.9940 0 0);
18
18
  --secondary-foreground: oklch(0.2046 0 0);
19
19
  --muted: oklch(0.9461 0 0);
20
- --muted-foreground: oklch(0.2435 0 0);
20
+ --muted-foreground: oklch(0.4386 0 0);
21
21
  --accent: oklch(0.9461 0 0);
22
22
  --accent-foreground: oklch(0.2435 0 0);
23
23
  --destructive: oklch(0.5523 0.1927 32.7272);
24
24
  --border: oklch(0.9037 0 0);
25
25
  --input: oklch(0.9731 0 0);
26
- --ring: oklch(0.8348 0.1302 160.9080);
26
+ --ring: oklch(0.6 0.15 144.2);
27
27
  --destructive-foreground: oklch(0.9934 0.0032 17.2118);
28
28
  --chart-1: oklch(0.8348 0.1302 160.9080);
29
29
  --chart-2: oklch(0.6231 0.1880 259.8145);
@@ -1,32 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode } from "react";
2
2
  import { useTranslation } from "react-i18next";
3
- import { useMutation, useQueryClient } from "@tanstack/react-query";
4
- import { NavLink, Link, useNavigate } from "react-router";
5
- import { logout } from "@/features/auth/login";
6
- import { DeadlineBanner } from "@/features/deadline/DeadlineBanner";
7
- import { LOCALES, setLocale, type Locale } from "@/shared/i18n";
8
- import { navFor } from "./routes";
9
- import { replaceIdentity, type Me } from "./session";
10
-
11
- function LanguageSwitch() {
12
- const { t, i18n } = useTranslation();
13
- return (
14
- <label className="flex items-center gap-2 text-sm">
15
- <span>{t("common.language")}</span>
16
- <select
17
- className="rounded-md border bg-background px-2 py-1"
18
- value={i18n.language}
19
- onChange={(e) => void setLocale(e.target.value as Locale)}
20
- >
21
- {LOCALES.map((l) => (
22
- <option key={l} value={l}>
23
- {l === "zh-TW" ? "繁體中文" : "English"}
24
- </option>
25
- ))}
26
- </select>
27
- </label>
28
- );
29
- }
30
3
 
31
4
  /** One h1 per page; after a route change focus moves to it and the title updates (Frontend.md §8). */
32
5
  export function PageHeading({ title }: { title: string }) {
@@ -40,125 +13,24 @@ export function PageHeading({ title }: { title: string }) {
40
13
  <h1
41
14
  ref={ref}
42
15
  tabIndex={-1}
43
- className="mb-4 text-2xl font-semibold outline-none"
16
+ className="mb-6 text-2xl font-semibold outline-none"
44
17
  >
45
18
  {title}
46
19
  </h1>
47
20
  );
48
21
  }
49
22
 
50
- function Frame({
51
- header,
23
+ /** Every page fills the viewport; there is no header, navigation or footer around it. */
24
+ export function Screen({
52
25
  children,
26
+ padded = true,
53
27
  }: {
54
- header: ReactNode;
55
28
  children: ReactNode;
29
+ padded?: boolean;
56
30
  }) {
57
- const { t } = useTranslation();
58
- return (
59
- <div className="min-h-screen">
60
- <a
61
- href="#main"
62
- className="sr-only focus:not-sr-only focus:absolute focus:z-10 focus:bg-background focus:p-2"
63
- >
64
- {t("nav.skip")}
65
- </a>
66
- <header className="border-b">{header}</header>
67
- <DeadlineBanner />
68
- <main id="main" className="mx-auto max-w-5xl p-4">
69
- {children}
70
- </main>
71
- </div>
72
- );
73
- }
74
-
75
- export function PublicLayout({ children }: { children: ReactNode }) {
76
- const { t } = useTranslation();
77
31
  return (
78
- <Frame
79
- header={
80
- <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 p-4">
81
- <Link to="/login" className="font-semibold">
82
- {t("app.name")}
83
- </Link>
84
- <LanguageSwitch />
85
- </div>
86
- }
87
- >
88
- {children}
89
- </Frame>
90
- );
91
- }
92
-
93
- /**
94
- * Ends the session. Whatever the answer, the browser must stop acting as this user: logout is
95
- * idempotent on the server, and on a network failure the user is told instead of being left unsure.
96
- */
97
- function SignOutButton() {
98
- const { t } = useTranslation();
99
- const client = useQueryClient();
100
- const navigate = useNavigate();
101
- const out = useMutation({
102
- mutationFn: logout,
103
- onSuccess: () => {
104
- replaceIdentity(client, null);
105
- void navigate("/login", { replace: true });
106
- },
107
- });
108
- return (
109
- <>
110
- <button
111
- type="button"
112
- className="rounded-md border px-2 py-1 text-sm disabled:opacity-50"
113
- disabled={out.isPending}
114
- onClick={() => out.mutate()}
115
- >
116
- {t("nav.signOut")}
117
- </button>
118
- {out.isError && (
119
- <span role="alert" className="text-sm text-destructive">
120
- {t("errors.UNKNOWN")}
121
- </span>
122
- )}
123
- </>
124
- );
125
- }
126
-
127
- /** Logged-in layout; navigation comes from the route table and the role. */
128
- export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
129
- const { t } = useTranslation();
130
- return (
131
- <Frame
132
- header={
133
- <div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-4 p-4">
134
- <nav aria-label={t("nav.main")}>
135
- <ul className="flex flex-wrap items-center gap-4">
136
- {navFor(me.role).map((r) => (
137
- <li key={r.path}>
138
- <NavLink
139
- to={r.path}
140
- className="underline-offset-4 hover:underline aria-[current=page]:font-semibold"
141
- >
142
- {t(`nav.${r.navKey!}`)}
143
- </NavLink>
144
- </li>
145
- ))}
146
- </ul>
147
- </nav>
148
- <div className="flex items-center gap-4">
149
- {me.role === "ADMIN" && (
150
- <span className="rounded-md border px-2 py-0.5 text-sm">
151
- {t("nav.administrator")}
152
- </span>
153
- )}
154
- <span className="text-sm">{me.name}</span>
155
- <LanguageSwitch />
156
- <SignOutButton />
157
- </div>
158
- </div>
159
- }
160
- >
32
+ <main id="main" className={padded ? "min-h-screen p-6 md:p-10" : "min-h-screen"}>
161
33
  {children}
162
- </Frame>
34
+ </main>
163
35
  );
164
36
  }
@@ -9,7 +9,7 @@ export function JoinRequests() {
9
9
  return (
10
10
  <>
11
11
  <PageHeading title={t('nav.joinRequests')} />
12
- <section className="mb-8 max-w-md">
12
+ <section className="mb-6 max-w-md rounded-xl border bg-card p-6">
13
13
  <h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.apply')}</h2>
14
14
  <form onSubmit={(e: FormEvent) => e.preventDefault()}>
15
15
  <Field label={t('fields.leaderEmpNo')}>{(p) => <input {...p} name="leaderEmpNo" required />}</Field>
@@ -17,7 +17,7 @@ export function JoinRequests() {
17
17
  <Button type="submit">{t('pages.joinRequests.submit')}</Button>
18
18
  </form>
19
19
  </section>
20
- <section>
20
+ <section className="rounded-xl border bg-card p-6">
21
21
  <h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.mine')}</h2>
22
22
  <p className="text-sm">{t('pages.joinRequests.empty')}</p>
23
23
  </section>
@@ -9,7 +9,7 @@ export function NotFound() {
9
9
  <>
10
10
  <PageHeading title={t('notFound.title')} />
11
11
  <p className="mb-4">{t('notFound.body')}</p>
12
- <Link to="/" className="underline">
12
+ <Link to="/" className="font-medium text-primary underline underline-offset-4">
13
13
  {t('notFound.home')}
14
14
  </Link>
15
15
  </>
@@ -10,7 +10,7 @@ export function AdminDeadline() {
10
10
  return (
11
11
  <>
12
12
  <PageHeading title={t('nav.deadline')} />
13
- <form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md">
13
+ <form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md rounded-xl border bg-card p-6">
14
14
  <Field label={t('pages.adminDeadline.label')} hint={t('pages.adminDeadline.hint')}>
15
15
  {(p) => <input {...p} name="deadline" type="datetime-local" required />}
16
16
  </Field>
@@ -29,7 +29,34 @@ function useAuthHeading(title: string) {
29
29
  return ref;
30
30
  }
31
31
 
32
- /** shadcn `login-02`: two columns, form on the left, brand panel on the right (hidden below lg). */
32
+ /** Brand mark shared by both hero layouts and the form header. */
33
+ function LogoMark({ className = "size-16" }: { className?: string }) {
34
+ return (
35
+ <div
36
+ aria-hidden="true"
37
+ className={`flex items-center justify-center rounded-2xl bg-primary text-primary-foreground ${className}`}
38
+ >
39
+ <Leaf className="size-1/2" />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function HeroCopy({ title, body }: { title: string; body: string }) {
45
+ return (
46
+ <div
47
+ aria-hidden="true"
48
+ className="flex flex-col items-center gap-4 text-center"
49
+ >
50
+ <LogoMark />
51
+ <p className="max-w-sm text-3xl font-semibold text-balance whitespace-pre-line text-primary">
52
+ {title}
53
+ </p>
54
+ <p className="max-w-xs text-sm text-balance text-foreground/80">{body}</p>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ /** Design `Login - Employee`: 500px photo panel on the left (lg and up), form centred on the right. */
33
60
  export function Login() {
34
61
  const { t } = useTranslation();
35
62
  const heading = useAuthHeading(t("nav.signIn"));
@@ -44,8 +71,17 @@ export function Login() {
44
71
  ? t("pages.login.sessionEnded")
45
72
  : undefined;
46
73
  return (
47
- <div className="grid overflow-hidden rounded-xl border lg:grid-cols-2">
48
- <div className="flex items-center justify-center p-6 md:p-10">
74
+ <div className="grid min-h-screen bg-card lg:grid-cols-[500px_1fr]">
75
+ <div
76
+ className="relative hidden items-center justify-center bg-cover bg-center p-10 lg:flex"
77
+ style={{ backgroundImage: "url(/auth-hero-login.jpg)" }}
78
+ >
79
+ <HeroCopy
80
+ title={t("pages.login.panelTitle")}
81
+ body={t("pages.login.panelBody")}
82
+ />
83
+ </div>
84
+ <div className="flex items-center justify-center p-6 md:p-12">
49
85
  {/* A new session means a new identity: nothing cached for the previous one may survive. The gate then
50
86
  sees `me` and redirects to the validated `next` (Frontend.md §6.4). */}
51
87
  <LoginForm
@@ -54,32 +90,24 @@ export function Login() {
54
90
  onLoggedIn={(me) => replaceIdentity(client, me)}
55
91
  />
56
92
  </div>
57
- <div
58
- aria-hidden="true"
59
- className="hidden flex-col items-center justify-center gap-3 bg-muted p-10 text-center lg:flex"
60
- >
61
- <div className="flex size-12 items-center justify-center rounded-md bg-primary text-primary-foreground">
62
- <Leaf className="size-6" />
63
- </div>
64
- <p className="text-xl font-semibold">{t("pages.login.panelTitle")}</p>
65
- <p className="max-w-xs text-sm text-balance text-muted-foreground">
66
- {t("pages.login.panelBody")}
67
- </p>
68
- </div>
69
93
  </div>
70
94
  );
71
95
  }
72
96
 
73
- /** shadcn `signup-01`: a single centred card. */
97
+ /** Design `Sign Up - Employee`: a full-bleed photo with the form card centred on it. */
74
98
  export function Register() {
75
99
  const { t } = useTranslation();
76
100
  const navigate = useNavigate();
77
101
  const heading = useAuthHeading(t("nav.register"));
78
102
  return (
79
- <div className="mx-auto w-full max-w-sm">
80
- <Card>
81
- <CardHeader>
82
- <CardTitle>
103
+ <div
104
+ className="flex min-h-screen items-center justify-center bg-cover bg-center px-4 py-10"
105
+ style={{ backgroundImage: "url(/auth-hero-register.jpg)" }}
106
+ >
107
+ <Card className="w-full max-w-md shadow-lg">
108
+ <CardHeader className="items-center text-center">
109
+ <LogoMark className="size-14" />
110
+ <CardTitle className="pt-2 text-2xl">
83
111
  <h1 ref={heading} tabIndex={-1} className="outline-none">
84
112
  {t("pages.register.title")}
85
113
  </h1>
@@ -88,11 +116,11 @@ export function Register() {
88
116
  </CardHeader>
89
117
  <CardContent>
90
118
  <RegisterForm
91
- onRegistered={() => {
92
- const state: RegisteredState = { notice: "registered" };
93
- void navigate("/login", { replace: true, state });
94
- }}
95
- />
119
+ onRegistered={() => {
120
+ const state: RegisteredState = { notice: "registered" };
121
+ void navigate("/login", { replace: true, state });
122
+ }}
123
+ />
96
124
  </CardContent>
97
125
  </Card>
98
126
  </div>
@@ -62,12 +62,12 @@ export function IdeaList() {
62
62
  </Button>
63
63
  )}
64
64
  </form>
65
- <table className="w-full text-left text-sm">
65
+ <table className="w-full rounded-xl border bg-card text-left text-sm">
66
66
  <caption className="sr-only">{t('nav.ideas')}</caption>
67
67
  <thead>
68
68
  <tr>
69
69
  {(['ideaName', 'category', 'org', 'direction', 'leader', 'members'] as const).map((c) => (
70
- <th key={c} scope="col" className="border-b p-2">
70
+ <th key={c} scope="col" className="border-b p-3 font-medium text-muted-foreground">
71
71
  {t(`fields.${c}`)}
72
72
  </th>
73
73
  ))}
@@ -86,7 +86,7 @@ export function IdeaCreate() {
86
86
  return (
87
87
  <>
88
88
  <PageHeading title={t('pages.ideaCreate.title')} />
89
- <form onSubmit={noSubmit} className="max-w-2xl">
89
+ <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
90
90
  <IdeaFields withSlides />
91
91
  <Button type="submit" disabled={!open}>
92
92
  {t('pages.ideaCreate.submit')}
@@ -98,8 +98,8 @@ export function IdeaCreate() {
98
98
 
99
99
  function Section({ title, children }: { title: string; children?: ReactNode }) {
100
100
  return (
101
- <section className="mb-6">
102
- <h2 className="mb-2 text-lg font-semibold">{title}</h2>
101
+ <section className="mb-4 rounded-xl border bg-card p-6">
102
+ <h2 className="mb-3 text-lg font-semibold">{title}</h2>
103
103
  {children}
104
104
  </section>
105
105
  );
@@ -133,7 +133,7 @@ export function IdeaDetail() {
133
133
  <Button variant="destructive">{t('pages.ideaDetail.delete')}</Button>
134
134
  </div>
135
135
  </Section>
136
- <Link to="/ideas" className="underline">
136
+ <Link to="/ideas" className="font-medium text-primary underline underline-offset-4">
137
137
  {t('pages.ideaDetail.back')}
138
138
  </Link>
139
139
  </>
@@ -145,7 +145,7 @@ export function IdeaEdit() {
145
145
  return (
146
146
  <>
147
147
  <PageHeading title={t('pages.ideaEdit.title')} />
148
- <form onSubmit={noSubmit} className="max-w-2xl">
148
+ <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
149
149
  <IdeaFields withSlides={false} />
150
150
  <div className="flex gap-2">
151
151
  <Button type="submit">{t('common.save')}</Button>
@@ -13,12 +13,12 @@ import { routes } from "../router";
13
13
 
14
14
  const API = "http://localhost:3000/api/v1";
15
15
  const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
16
- const ADMIN = { empNo: "000001", name: "Root", email: null, role: "ADMIN" };
16
+ type Account = typeof USER | { empNo: string; name: string; email: null; role: "ADMIN" };
17
17
 
18
18
  /** A visitor on `path`; `account` is who a successful login returns. `GET /auth/me` follows the session. */
19
- async function open(path: string, account: typeof USER | typeof ADMIN = USER) {
19
+ async function open(path: string, account: Account = USER) {
20
20
  clearCsrfToken();
21
- let session: typeof USER | typeof ADMIN | null = null;
21
+ let session: Account | null = null;
22
22
  const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
23
23
  login: [],
24
24
  logoutCsrf: [],
@@ -87,15 +87,6 @@ describe("Login page", () => {
87
87
  await signIn(user, " 089290 ", "secret");
88
88
  await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
89
89
  expect(calls.login).toEqual([{ empNo: "089290", password: "secret" }]);
90
- expect(await screen.findByText("Wang")).toBeInTheDocument();
91
- expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
92
- });
93
-
94
- it("signs an Admin in, to the same page, with the Administrator badge", async () => {
95
- const { router, user } = await open("/login", ADMIN);
96
- await signIn(user, "000001");
97
- await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
98
- expect(await screen.findByText("Administrator")).toBeInTheDocument();
99
90
  });
100
91
 
101
92
  it("follows a role-allowed next", async () => {
@@ -174,45 +165,3 @@ describe("Login page", () => {
174
165
  ).toBeInTheDocument();
175
166
  });
176
167
  });
177
-
178
- describe("Sign out", () => {
179
- it("ends the session with the session-bound CSRF token and returns to a clean /login", async () => {
180
- const { router, calls, user } = await open("/login");
181
- await signIn(user);
182
- await user.click(await screen.findByRole("button", { name: "Sign out" }));
183
- await screen.findByRole("heading", {
184
- level: 1,
185
- name: "Sign in to your account",
186
- });
187
- expect(calls.logoutCsrf).toEqual(["tok-session"]);
188
- expect(router.state.location.pathname).toBe("/login");
189
- expect(router.state.location.search).toBe("");
190
- expect(
191
- screen.queryByText("Your session has ended. Please sign in again."),
192
- ).not.toBeInTheDocument();
193
- expect(screen.queryByText("Wang")).not.toBeInTheDocument();
194
- });
195
-
196
- it("a second login after logout uses a fresh CSRF token", async () => {
197
- const { router, user } = await open("/login");
198
- await signIn(user);
199
- await user.click(await screen.findByRole("button", { name: "Sign out" }));
200
- await screen.findByRole("heading", {
201
- level: 1,
202
- name: "Sign in to your account",
203
- });
204
- await signIn(user);
205
- await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
206
- });
207
-
208
- it("tells the user when sign-out fails and keeps them signed in", async () => {
209
- const { router, user } = await open("/login");
210
- await signIn(user);
211
- server.use(
212
- http.post(`${API}/auth/logout`, () => problem(500, "INTERNAL_ERROR")),
213
- );
214
- await user.click(await screen.findByRole("button", { name: "Sign out" }));
215
- expect(await screen.findByRole("alert")).toBeInTheDocument();
216
- expect(router.state.location.pathname).toBe("/ideas");
217
- });
218
- });
@@ -71,7 +71,7 @@ describe('Register page: markup', () => {
71
71
 
72
72
  it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), also with errors shown', async (locale) => {
73
73
  const { container, user } = await open(locale);
74
- await screen.findByRole('region', { name: locale === 'en' ? 'Submission status' : /.+/ });
74
+ await screen.findByRole('heading', { level: 1 });
75
75
  expect((await axe(container)).violations).toEqual([]);
76
76
  await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Create account' : '建立帳號' }));
77
77
  expect(await screen.findByRole('alert')).toBeInTheDocument();
@@ -7,7 +7,7 @@ import {
7
7
  type RouteObject,
8
8
  } from "react-router";
9
9
  import { decide, safeNext } from "./access";
10
- import { AppShell, PublicLayout } from "./layouts";
10
+ import { Screen } from "./layouts";
11
11
  import { NotFound } from "./pages/NotFound";
12
12
  import { Login, Register } from "./pages/auth";
13
13
  import { IdeaCreate, IdeaDetail, IdeaEdit, IdeaList } from "./pages/ideas";
@@ -94,11 +94,7 @@ function Gate({ route }: { route?: (typeof ROUTES)[number] }) {
94
94
  ) : (
95
95
  <NotFound />
96
96
  );
97
- return me ? (
98
- <AppShell me={me}>{page}</AppShell>
99
- ) : (
100
- <PublicLayout>{page}</PublicLayout>
101
- );
97
+ return <Screen padded={route?.access !== "public"}>{page}</Screen>;
102
98
  }
103
99
 
104
100
  function RootRedirect() {
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, type FormEvent, type Ref } from "react";
2
+ import { Leaf } from "lucide-react";
2
3
  import { useTranslation } from "react-i18next";
3
4
  import { Link } from "react-router";
4
5
  import { ApiError } from "@/shared/api";
@@ -65,11 +66,17 @@ export function LoginForm({
65
66
 
66
67
  return (
67
68
  <form
68
- className="flex w-full max-w-xs flex-col gap-6"
69
+ className="flex w-full max-w-sm flex-col gap-6"
69
70
  onSubmit={(e) => void onSubmit(e)}
70
71
  >
71
72
  <FieldGroup>
72
73
  <div className="flex flex-col items-center gap-1 text-center">
74
+ <div
75
+ aria-hidden="true"
76
+ className="mb-2 flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
77
+ >
78
+ <Leaf className="size-7" />
79
+ </div>
73
80
  <h1
74
81
  ref={heading}
75
82
  tabIndex={-1}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnyboy11234/esg-idea-contest",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "ESG Idea Contest registration system: Go backend, React frontend and design documents (source distribution).",
5
5
  "license": "MIT",
6
6
  "author": "Brian Huang",
@@ -0,0 +1,109 @@
1
+ {
2
+ "rows": [
3
+ {
4
+ "id": "csrf",
5
+ "operation": "csrf",
6
+ "method": "GET",
7
+ "path": "/api/v1/auth/csrf",
8
+ "expect": {
9
+ "visitor": { "status": 200 },
10
+ "user": { "status": 200 },
11
+ "admin": { "status": 200 }
12
+ }
13
+ },
14
+ {
15
+ "id": "deadline",
16
+ "operation": "deadline",
17
+ "method": "GET",
18
+ "path": "/api/v1/deadline",
19
+ "expect": {
20
+ "visitor": { "status": 200 },
21
+ "user": { "status": 200 },
22
+ "admin": { "status": 200 }
23
+ }
24
+ },
25
+ {
26
+ "id": "register",
27
+ "operation": "register",
28
+ "method": "POST",
29
+ "path": "/api/v1/auth/register",
30
+ "body": { "empNo": "${newEmpNo}", "name": "Matrix Person", "email": "${newEmpNo}@example.com", "password": "${password}" },
31
+ "expect": {
32
+ "visitor": { "status": 201 },
33
+ "user": { "status": 201 },
34
+ "admin": { "status": 201 }
35
+ }
36
+ },
37
+ {
38
+ "id": "login",
39
+ "operation": "login",
40
+ "method": "POST",
41
+ "path": "/api/v1/auth/login",
42
+ "body": { "empNo": "${actorEmpNo}", "password": "${password}" },
43
+ "expect": {
44
+ "visitor": { "status": 401, "code": "INVALID_CREDENTIALS" },
45
+ "user": { "status": 200 },
46
+ "admin": { "status": 200 }
47
+ }
48
+ },
49
+ {
50
+ "id": "logout",
51
+ "operation": "logout",
52
+ "method": "POST",
53
+ "path": "/api/v1/auth/logout",
54
+ "expect": {
55
+ "visitor": { "status": 204 },
56
+ "user": { "status": 204 },
57
+ "admin": { "status": 204 }
58
+ }
59
+ },
60
+ {
61
+ "id": "me",
62
+ "operation": "me",
63
+ "method": "GET",
64
+ "path": "/api/v1/auth/me",
65
+ "expect": {
66
+ "visitor": { "status": 401, "code": "UNAUTHENTICATED" },
67
+ "user": { "status": 200 },
68
+ "admin": { "status": 200 }
69
+ }
70
+ },
71
+ {
72
+ "id": "order-logout-no-csrf",
73
+ "operation": "check order: a write without a CSRF token",
74
+ "method": "POST",
75
+ "path": "/api/v1/auth/logout",
76
+ "csrf": "none",
77
+ "expect": {
78
+ "visitor": { "status": 403, "code": "CSRF_INVALID" },
79
+ "user": { "status": 403, "code": "CSRF_INVALID" },
80
+ "admin": { "status": 403, "code": "CSRF_INVALID" }
81
+ }
82
+ },
83
+ {
84
+ "id": "order-unknown-write-no-csrf",
85
+ "operation": "check order: 401 before CSRF on a path outside the contract",
86
+ "method": "POST",
87
+ "path": "/api/v1/matrix-unknown",
88
+ "csrf": "none",
89
+ "outsideSpec": true,
90
+ "expect": {
91
+ "visitor": { "status": 401, "code": "UNAUTHENTICATED" },
92
+ "user": { "status": 403, "code": "CSRF_INVALID" },
93
+ "admin": { "status": 403, "code": "CSRF_INVALID" }
94
+ }
95
+ },
96
+ {
97
+ "id": "order-unknown-write-csrf",
98
+ "operation": "check order: CSRF before 404 on a path outside the contract",
99
+ "method": "POST",
100
+ "path": "/api/v1/matrix-unknown",
101
+ "outsideSpec": true,
102
+ "expect": {
103
+ "visitor": { "status": 401, "code": "UNAUTHENTICATED" },
104
+ "user": { "status": 404, "code": "NOT_FOUND" },
105
+ "admin": { "status": 404, "code": "NOT_FOUND" }
106
+ }
107
+ }
108
+ ]
109
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "permission-matrix.schema.json",
4
+ "title": "Permission matrix",
5
+ "description": "PRD §7 as data: one row per operation (or check-order case), one expected cell per actor. Read by backend/test/matrix. Later PRs add their rows and actors.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["rows"],
9
+ "properties": {
10
+ "rows": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "items": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["id", "operation", "method", "path", "expect"],
17
+ "properties": {
18
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
19
+ "operation": { "type": "string", "minLength": 1 },
20
+ "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
21
+ "path": { "type": "string", "pattern": "^/api/v1/" },
22
+ "body": { "type": "object" },
23
+ "csrf": { "enum": ["valid", "none"], "default": "valid" },
24
+ "outsideSpec": { "type": "boolean", "default": false },
25
+ "expect": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "minProperties": 1,
29
+ "properties": {
30
+ "visitor": { "$ref": "#/$defs/cell" },
31
+ "user": { "$ref": "#/$defs/cell" },
32
+ "member": { "$ref": "#/$defs/cell" },
33
+ "leader": { "$ref": "#/$defs/cell" },
34
+ "admin": { "$ref": "#/$defs/cell" }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ },
41
+ "$defs": {
42
+ "cell": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["status"],
46
+ "properties": {
47
+ "status": { "type": "integer", "minimum": 200, "maximum": 599 },
48
+ "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$" }
49
+ }
50
+ }
51
+ }
52
+ }