@johnnyboy11234/esg-idea-contest 0.3.0 → 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.
Files changed (96) hide show
  1. package/backend/api/openapi.yaml +83 -0
  2. package/backend/cmd/server/main.go +35 -2
  3. package/backend/db/migration/V005__idea.sql +49 -0
  4. package/backend/db/migration/V006__login_lock.sql +15 -0
  5. package/backend/db/migration/V007__login_attempt.sql +15 -0
  6. package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
  7. package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
  8. package/backend/go.mod +17 -3
  9. package/backend/go.sum +48 -7
  10. package/backend/internal/account/account_test.go +6 -6
  11. package/backend/internal/account/integration_test.go +45 -9
  12. package/backend/internal/account/keyboard.go +75 -0
  13. package/backend/internal/account/keyboard_test.go +43 -0
  14. package/backend/internal/account/logging_integration_test.go +1 -1
  15. package/backend/internal/account/password.go +125 -0
  16. package/backend/internal/account/validate.go +4 -21
  17. package/backend/internal/api/api.gen.go +224 -0
  18. package/backend/internal/auth/internal/store/login_attempt.go +53 -0
  19. package/backend/internal/auth/internal/store/login_lock.go +77 -0
  20. package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
  21. package/backend/internal/auth/internal/store/session.go +15 -7
  22. package/backend/internal/auth/login.go +16 -4
  23. package/backend/internal/auth/login_integration_test.go +14 -1
  24. package/backend/internal/auth/login_ip_integration_test.go +153 -0
  25. package/backend/internal/auth/login_lock_integration_test.go +205 -0
  26. package/backend/internal/auth/login_test.go +15 -0
  27. package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
  28. package/backend/internal/auth/purge.go +93 -0
  29. package/backend/internal/auth/purge_integration_test.go +220 -0
  30. package/backend/internal/auth/purge_test.go +51 -0
  31. package/backend/internal/auth/session.go +0 -6
  32. package/backend/internal/auth/session_integration_test.go +3 -3
  33. package/backend/internal/auth/throttle.go +122 -0
  34. package/backend/internal/deadline/gate.go +80 -0
  35. package/backend/internal/deadline/gate_integration_test.go +67 -0
  36. package/backend/internal/deadline/gate_test.go +61 -0
  37. package/backend/internal/idea/create.go +352 -0
  38. package/backend/internal/idea/create_integration_test.go +650 -0
  39. package/backend/internal/idea/handler.go +148 -0
  40. package/backend/internal/idea/handler_integration_test.go +107 -0
  41. package/backend/internal/idea/integration_test.go +149 -0
  42. package/backend/internal/idea/internal/store/list.go +146 -0
  43. package/backend/internal/idea/internal/store/list_test.go +33 -0
  44. package/backend/internal/idea/internal/store/store.go +166 -0
  45. package/backend/internal/idea/list.go +166 -0
  46. package/backend/internal/idea/list_integration_test.go +252 -0
  47. package/backend/internal/idea/list_test.go +96 -0
  48. package/backend/internal/idea/service.go +53 -0
  49. package/backend/internal/server/api.go +17 -0
  50. package/backend/internal/server/public.go +1 -0
  51. package/backend/internal/server/slides.go +21 -0
  52. package/backend/internal/shared/config/config.go +44 -1
  53. package/backend/internal/shared/config/config_test.go +21 -13
  54. package/backend/internal/shared/db/intx_integration_test.go +179 -0
  55. package/backend/internal/shared/dbtest/dbtest.go +21 -3
  56. package/backend/internal/shared/dbtest/faults.go +162 -0
  57. package/backend/internal/shared/job/job.go +54 -0
  58. package/backend/internal/shared/job/job_test.go +105 -0
  59. package/backend/internal/shared/web/multipart.go +175 -0
  60. package/backend/internal/shared/web/multipart_test.go +169 -0
  61. package/backend/internal/shared/web/problem.go +1 -0
  62. package/backend/internal/shared/web/request.go +42 -1
  63. package/backend/internal/shared/web/web_test.go +36 -0
  64. package/backend/internal/storage/expectedfilename.go +43 -0
  65. package/backend/internal/storage/expectedfilename_test.go +16 -0
  66. package/backend/internal/storage/internal/store/pending.go +41 -0
  67. package/backend/internal/storage/minio.go +139 -0
  68. package/backend/internal/storage/minio_integration_test.go +87 -0
  69. package/backend/internal/storage/minio_test.go +84 -0
  70. package/backend/internal/storage/pending.go +144 -0
  71. package/backend/internal/storage/pending_integration_test.go +324 -0
  72. package/backend/internal/storage/pending_test.go +23 -0
  73. package/backend/internal/storage/validator.go +100 -0
  74. package/backend/internal/storage/validator_test.go +95 -0
  75. package/backend/test/contract/expected_filename_test.go +60 -0
  76. package/backend/test/contract/password_test.go +46 -0
  77. package/backend/test/matrix/load_test.go +12 -0
  78. package/backend/test/matrix/matrix_integration_test.go +86 -5
  79. package/backend/test/migration/pending_file_deletion_test.go +46 -0
  80. package/docs/API/api-endpoints.md +1 -1
  81. package/docs/API/error-codes.md +1 -1
  82. package/frontend/src/app/App.tsx +11 -2
  83. package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
  84. package/frontend/src/features/auth/IdleWarning.tsx +66 -0
  85. package/frontend/src/shared/api/activity.ts +20 -0
  86. package/frontend/src/shared/api/http.ts +2 -0
  87. package/frontend/src/shared/api/index.ts +1 -0
  88. package/frontend/src/shared/i18n/locales/en.ts +1 -0
  89. package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
  90. package/package.json +1 -1
  91. package/testdata/expected-filename.json +93 -0
  92. package/testdata/password.json +425 -0
  93. package/testdata/permission-matrix.json +48 -0
  94. package/testdata/schema/expected-filename.schema.json +32 -0
  95. package/testdata/schema/password.schema.json +59 -0
  96. package/testdata/schema/permission-matrix.schema.json +21 -0
@@ -3,10 +3,14 @@
3
3
  package matrix
4
4
 
5
5
  import (
6
+ "bytes"
7
+ "context"
6
8
  "database/sql"
7
9
  "encoding/json"
8
10
  "fmt"
11
+ "io"
9
12
  "log/slog"
13
+ "mime/multipart"
10
14
  "net/http"
11
15
  "net/http/httptest"
12
16
  "strings"
@@ -20,8 +24,11 @@ import (
20
24
  "github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
21
25
  "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
22
26
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
27
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
23
28
  "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
29
+ shareddb "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
24
30
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
31
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
25
32
  "github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
26
33
  )
27
34
 
@@ -57,10 +64,21 @@ func newEnv(t *testing.T) *env {
57
64
  Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
58
65
  Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
59
66
  Account: account.Handler{
60
- Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.StubPolicy{}},
67
+ Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.DefaultPolicy()},
61
68
  Fail: errs.Write,
62
69
  },
63
70
  Deadline: deadline.Handler{Store: deadline.SQLStore{DB: db}},
71
+ Ideas: idea.Handler{
72
+ Service: &idea.Service{
73
+ DB: db, Audit: auditSvc,
74
+ Gate: deadline.Gate{Store: deadline.SQLStore{DB: db}},
75
+ Slides: server.IdeaSlideRules(),
76
+ Objects: discardObjects{},
77
+ Aborter: discardObjects{},
78
+ },
79
+ ExpectedFilename: storage.ExpectedFilenameBase,
80
+ Fail: errs.Write,
81
+ },
64
82
  }
65
83
  chain := server.NewPublic(log, server.Mount(si),
66
84
  auth.Middleware(sessions, server.IsPublic, log),
@@ -68,6 +86,35 @@ func newEnv(t *testing.T) *env {
68
86
  return &env{db: db, plain: chain, checked: apispec.Load(t).Validate(t, chain)}
69
87
  }
70
88
 
89
+ // discardObjects stands in for MinIO: the matrix is about who may call what,
90
+ // not about storage.
91
+ type discardObjects struct{}
92
+
93
+ func (discardObjects) Put(_ context.Context, _ string, r io.Reader, _ int64, _ string) error {
94
+ _, err := io.Copy(io.Discard, r)
95
+ return err
96
+ }
97
+
98
+ func (discardObjects) AbortUpload(context.Context, string, shareddb.Outcome) {}
99
+
100
+ // stateDeadline is the deadline expression that puts the contest in a state.
101
+ var stateDeadline = map[string]string{
102
+ "NOT_OPEN": "NULL",
103
+ "OPEN": "NOW(6) + INTERVAL 1 DAY",
104
+ "CLOSED": "NOW(6) - INTERVAL 1 DAY",
105
+ }
106
+
107
+ func (e *env) setState(t *testing.T, state string) {
108
+ t.Helper()
109
+ expr, ok := stateDeadline[state]
110
+ if !ok {
111
+ t.Fatalf("unknown state %q", state)
112
+ }
113
+ if _, err := e.db.Exec(`UPDATE contest_setting SET deadline = ` + expr + ` WHERE id = 1`); err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ }
117
+
71
118
  func (e *env) do(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
72
119
  rec := httptest.NewRecorder()
73
120
  h.ServeHTTP(rec, req)
@@ -90,10 +137,11 @@ func (e *env) addUser(t *testing.T, empNo, role string) {
90
137
  }
91
138
  }
92
139
 
93
- // actor is one caller of the matrix: its employee number and, when logged in,
94
- // its session cookie.
140
+ // actor is one caller of the matrix: its employee number, account name and,
141
+ // when logged in, its session cookie.
95
142
  type actor struct {
96
143
  empNo string
144
+ name string
97
145
  session *http.Cookie
98
146
  }
99
147
 
@@ -124,7 +172,7 @@ func (e *env) newActor(t *testing.T, name string) actor {
124
172
  if rec.Code != 200 {
125
173
  t.Fatalf("login %s: %d %s", name, rec.Code, rec.Body)
126
174
  }
127
- return actor{empNo: empNo, session: cookie(rec, auth.SessionCookie)}
175
+ return actor{empNo: empNo, name: "N " + empNo, session: cookie(rec, auth.SessionCookie)}
128
176
  }
129
177
 
130
178
  func cookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
@@ -161,7 +209,11 @@ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
161
209
  body = strings.NewReader(s)
162
210
  }
163
211
  var req *http.Request
164
- if body != nil {
212
+ if r.Multipart != nil {
213
+ buf, ct := multipartBody(t, r.Multipart, a)
214
+ req = httptest.NewRequest(r.Method, r.Path, buf)
215
+ req.Header.Set("Content-Type", ct)
216
+ } else if body != nil {
165
217
  req = httptest.NewRequest(r.Method, r.Path, body)
166
218
  req.Header.Set("Content-Type", "application/json")
167
219
  } else {
@@ -180,9 +232,38 @@ func (e *env) request(t *testing.T, r Row, a actor) *http.Request {
180
232
  return req
181
233
  }
182
234
 
235
+ func multipartBody(t *testing.T, m *Multipart, a actor) (*bytes.Buffer, string) {
236
+ t.Helper()
237
+ sub := strings.NewReplacer("${actorEmpNo}", a.empNo, "${actorName}", a.name)
238
+ var buf bytes.Buffer
239
+ mw := multipart.NewWriter(&buf)
240
+ for k, v := range m.Fields {
241
+ if err := mw.WriteField(k, sub.Replace(v)); err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ }
245
+ w, err := mw.CreateFormFile(m.File.Field, sub.Replace(m.File.Filename))
246
+ if err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ switch m.File.Content {
250
+ case "pdf":
251
+ _, _ = w.Write([]byte("%PDF-1.7\nmatrix\n%%EOF\n"))
252
+ default:
253
+ t.Fatalf("unknown file content %q", m.File.Content)
254
+ }
255
+ if err := mw.Close(); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ return &buf, mw.FormDataContentType()
259
+ }
260
+
183
261
  func TestPermissionMatrix(t *testing.T) {
184
262
  e := newEnv(t)
185
263
  for _, r := range load(t) {
264
+ if r.State != "" {
265
+ e.setState(t, r.State)
266
+ }
186
267
  for _, name := range Actors {
187
268
  want, ok := r.Expect[name]
188
269
  if !ok {
@@ -0,0 +1,46 @@
1
+ //go:build integration
2
+
3
+ package migration_test
4
+
5
+ import (
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
10
+ )
11
+
12
+ func TestPendingFileDeletionRules(t *testing.T) {
13
+ d := dbtest.New(t)
14
+ ins := func(key, reason string) error {
15
+ _, err := d.Exec(`INSERT INTO pending_file_deletion (object_key, reason) VALUES (?, ?)`, key, reason)
16
+ return err
17
+ }
18
+ for _, r := range []string{"REPLACED", "DELETED", "UPLOAD_ABORTED"} {
19
+ if err := ins("slides/a/b.pdf", r); err != nil {
20
+ t.Errorf("reason %s (same key again): want accepted, got %v", r, err)
21
+ }
22
+ }
23
+ if err := ins(strings.Repeat("k", 128), "DELETED"); err != nil {
24
+ t.Errorf("128-char key: %v", err)
25
+ }
26
+ for name, c := range map[string][2]string{
27
+ "unknown reason": {"slides/x.pdf", "EXPIRED"},
28
+ "lower-case reason": {"slides/x.pdf", "replaced"},
29
+ "empty key": {"", "DELETED"},
30
+ "129-char key": {strings.Repeat("k", 129), "DELETED"},
31
+ } {
32
+ if err := ins(c[0], c[1]); err == nil {
33
+ t.Errorf("%s: want rejected", name)
34
+ }
35
+ }
36
+
37
+ var attempts int
38
+ var due bool
39
+ if err := d.QueryRow(`SELECT attempts, next_attempt_at <= NOW(6) AND created_at <= NOW(6)
40
+ FROM pending_file_deletion WHERE reason = 'DELETED' LIMIT 1`).Scan(&attempts, &due); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if attempts != 0 || !due {
44
+ t.Errorf("defaults: attempts=%d due=%v, want 0 and due now", attempts, due)
45
+ }
46
+ }
@@ -40,6 +40,6 @@ Every write (`POST`, `PUT`, `DELETE`, including login, register and logout) MUST
40
40
  Notes:
41
41
 
42
42
  - `DELETE /ideas/{id}/members/{empNo}` works on both `PENDING` and `ACCEPTED` rows (reject or remove); `DELETE …/members/me` works on the caller's own row (withdraw or leave). `me` is never a 6-digit employee ID, so the two paths cannot collide (PRD §6.7).
43
- - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
43
+ - List endpoints enforce a maximum page size of 100 (`GET /ideas`: `page`, `size`, filters, `q`, `sort`, `order`; PRD §6.6). `GET /admin/audit-logs` uses keyset pagination with `cursor` and `limit` (PRD §6.11).
44
44
  - Unknown paths: 401 without a session, 404 with one; wrong method 405; unsupported `Accept` 406 (PRD §6.1, OI-15).
45
45
  - Operations endpoints are on the management port and are not routed by the public ingress (PRD §8.3).
@@ -37,7 +37,7 @@ Format: RFC 9457 `application/problem+json`.
37
37
 
38
38
  - `code` is the stable contract; `detail` is English and not for display (the UI localizes by `code`).
39
39
  - With several field errors, `errors[]` lists all of them in form-field order; the top-level `code` equals `errors[0].code` when that is a registered specific code, otherwise `VALIDATION_FAILED`.
40
- - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4).
40
+ - Generic field codes: `REQUIRED`, `INVALID_FORMAT`, `OUT_OF_RANGE`, `INVALID_ENUM`. Per-field rules (which error a field reports first) are in PRD §6.2. The password is the exception: `errors[]` lists every violated rule (PRD §6.4), each as `{ "field": "password", "code": "PASSWORD_POLICY", "rule": "<rule id>" }` in table order. `rule` appears only on these entries.
41
41
  - `requestId` is a server-generated UUID. An incoming `X-Request-Id` is reused only if it is a valid UUID. It is returned in the response header and the error body.
42
42
  - 500 responses carry only `INTERNAL_ERROR` and `requestId`: no stack traces, SQL or paths.
43
43
  - A malformed ID in a path is a 404, not a 400.
@@ -1,18 +1,27 @@
1
- import { useEffect } from 'react';
1
+ import { useCallback, useEffect } from 'react';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
4
4
  import { setUnauthenticatedHandler } from '@/shared/api';
5
5
  import { routes } from './router';
6
- import { handleSessionEnded } from './session';
6
+ import { IdleWarning } from '@/features/auth/IdleWarning';
7
+ import { handleSessionEnded, useMe } from './session';
7
8
 
8
9
  const queryClient = new QueryClient();
9
10
  const browserRouter = createBrowserRouter(routes);
10
11
 
12
+ /** Mounted inside the providers so it can read `me`; ends the session like a 401 when the idle time is up. */
13
+ function Idle({ router, client }: { router: DataRouter; client: QueryClient }) {
14
+ const { data: me } = useMe();
15
+ const onExpire = useCallback(() => handleSessionEnded(client, router), [client, router]);
16
+ return <IdleWarning signedIn={!!me} onExpire={onExpire} />;
17
+ }
18
+
11
19
  export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
12
20
  useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
13
21
  return (
14
22
  <QueryClientProvider client={client}>
15
23
  <RouterProvider router={router} />
24
+ <Idle router={router} client={client} />
16
25
  </QueryClientProvider>
17
26
  );
18
27
  }
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Time of the last answered server request (Frontend.md §8.1, OI-22). Any answer except a 401 means
3
+ * the server saw the session, so the idle clock restarts; the idle-timeout warning reads it.
4
+ */
5
+ let last = Date.now();
6
+ const listeners = new Set<() => void>();
7
+
8
+ export const lastActivity = () => last;
9
+
10
+ export function markActivity(): void {
11
+ last = Date.now();
12
+ listeners.forEach((fn) => fn());
13
+ }
14
+
15
+ export function subscribeActivity(fn: () => void): () => void {
16
+ listeners.add(fn);
17
+ return () => {
18
+ listeners.delete(fn);
19
+ };
20
+ }
@@ -1,3 +1,4 @@
1
+ import { markActivity } from './activity';
1
2
  import { API_BASE, clearCsrfToken, getCsrfToken, refreshCsrfToken } from './csrf';
2
3
  import { parseApiError, type ApiError } from './problem';
3
4
 
@@ -73,6 +74,7 @@ export async function apiFetch(path: string, init: ApiRequestInit = {}): Promise
73
74
  res = await send(await refreshCsrfToken());
74
75
  }
75
76
 
77
+ if (res.status !== 401) markActivity();
76
78
  if (res.ok) {
77
79
  if (write && SESSION_CHANGING.has(path)) clearCsrfToken();
78
80
  return res;
@@ -4,3 +4,4 @@ export { apiFetch, setUnauthenticatedHandler } from './http';
4
4
  export type { ApiRequestInit } from './http';
5
5
  export { clearCsrfToken, getCsrfToken } from './csrf';
6
6
  export type { paths, components } from './types.generated';
7
+ export { lastActivity, markActivity, subscribeActivity } from './activity';
@@ -57,6 +57,7 @@ export const en = {
57
57
  leaderEmpNo: 'Leader employee ID',
58
58
  },
59
59
  pages: {
60
+ idle: { title: 'Are you still there?', body: 'You have been inactive for a while. For your security your session will end in about 2 minutes unless you continue.', continue: 'Continue' },
60
61
  login: { title: 'Sign in to your account', description: 'Enter your employee ID and password.', noAccount: 'No account yet?', panelTitle: 'ESG Idea Contest', panelBody: 'Register, form a team and submit your Idea.', submitting: 'Signing in…', sessionEnded: 'Your session has ended. Please sign in again.' },
61
62
  register: {
62
63
  title: 'Create an account',
@@ -54,6 +54,7 @@ export const zhTW: Dictionary = {
54
54
  leaderEmpNo: '組長員工編號',
55
55
  },
56
56
  pages: {
57
+ idle: { title: '您還在嗎?', body: '您已閒置一段時間。為了安全,若不繼續使用,您的登入將在約 2 分鐘後結束。', continue: '繼續' },
57
58
  login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', sessionEnded: '您的登入已結束,請重新登入。' },
58
59
  register: {
59
60
  title: '建立帳號',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnyboy11234/esg-idea-contest",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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,93 @@
1
+ {
2
+ "description": "Shared by backend (Go) and frontend (TS). Expected slide filename base per PRD 6.8 and the 255 UTF-8 byte rule of PRD 6.2 (length measured with .pptx). Inputs may be NFD; wantBase is always NFC.",
3
+ "cases": [
4
+ {
5
+ "id": "cool-idea-pdf-example",
6
+ "category": "COOL_IDEA",
7
+ "org": "BSID",
8
+ "leaderName": "\u738b\u5c0f\u660e",
9
+ "leaderEmpNo": "089290",
10
+ "ideaName": "\u6210\u7acb\u4f9b\u61c9\u5546\u6c38\u7e8c\u5b78\u9662",
11
+ "wantBase": "I_BSID_\u738b\u5c0f\u660e_089290_\u6210\u7acb\u4f9b\u61c9\u5546\u6c38\u7e8c\u5b78\u9662",
12
+ "wantBytes": 56,
13
+ "wantTooLong": false
14
+ },
15
+ {
16
+ "id": "outstanding-impact-example",
17
+ "category": "OUTSTANDING_IMPACT",
18
+ "org": "AAID",
19
+ "leaderName": "\u838a\u80b2\u6210",
20
+ "leaderEmpNo": "054616",
21
+ "ideaName": "AI\u79d8\u5bc6\u6b66\u5668\u667a\u80fd\u5c0d\u8a71",
22
+ "wantBase": "O_AAID_\u838a\u80b2\u6210_054616_AI\u79d8\u5bc6\u6b66\u5668\u667a\u80fd\u5c0d\u8a71",
23
+ "wantBytes": 55,
24
+ "wantTooLong": false
25
+ },
26
+ {
27
+ "id": "name-with-inner-space",
28
+ "category": "COOL_IDEA",
29
+ "org": "TSID",
30
+ "leaderName": "Alice Wang",
31
+ "leaderEmpNo": "000001",
32
+ "ideaName": "Green Plant 2.0",
33
+ "wantBase": "I_TSID_Alice Wang_000001_Green Plant 2.0",
34
+ "wantBytes": 45,
35
+ "wantTooLong": false
36
+ },
37
+ {
38
+ "id": "idea-name-nfd-becomes-nfc",
39
+ "category": "COOL_IDEA",
40
+ "org": "ICSD",
41
+ "leaderName": "Zoe",
42
+ "leaderEmpNo": "000002",
43
+ "ideaName": "Cafe\u0301 \u30ab\u3099",
44
+ "wantBase": "I_ICSD_Zoe_000002_Caf\u00e9 \u30ac",
45
+ "wantBytes": 32,
46
+ "wantTooLong": false
47
+ },
48
+ {
49
+ "id": "leader-name-nfd-becomes-nfc",
50
+ "category": "OUTSTANDING_IMPACT",
51
+ "org": "PLED",
52
+ "leaderName": "Jose\u0301",
53
+ "leaderEmpNo": "000003",
54
+ "ideaName": "Reuse",
55
+ "wantBase": "O_PLED_Jos\u00e9_000003_Reuse",
56
+ "wantBytes": 30,
57
+ "wantTooLong": false
58
+ },
59
+ {
60
+ "id": "exactly-255-bytes",
61
+ "category": "OUTSTANDING_IMPACT",
62
+ "org": "AAID",
63
+ "leaderName": "\u838a\u80b2\u6210",
64
+ "leaderEmpNo": "054616",
65
+ "ideaName": "\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275abcd",
66
+ "wantBase": "O_AAID_\u838a\u80b2\u6210_054616_\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275abcd",
67
+ "wantBytes": 255,
68
+ "wantTooLong": false
69
+ },
70
+ {
71
+ "id": "256-bytes-too-long",
72
+ "category": "OUTSTANDING_IMPACT",
73
+ "org": "AAID",
74
+ "leaderName": "\u838a\u80b2\u6210",
75
+ "leaderEmpNo": "054616",
76
+ "ideaName": "\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275abcde",
77
+ "wantBase": "O_AAID_\u838a\u80b2\u6210_054616_\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275\u5275abcde",
78
+ "wantBytes": 256,
79
+ "wantTooLong": true
80
+ },
81
+ {
82
+ "id": "emoji-four-byte-too-long",
83
+ "category": "COOL_IDEA",
84
+ "org": "DEID",
85
+ "leaderName": "\u738b\u5c0f\u660e",
86
+ "leaderEmpNo": "089290",
87
+ "ideaName": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
88
+ "wantBase": "I_DEID_\u738b\u5c0f\u660e_089290_\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
89
+ "wantBytes": 269,
90
+ "wantTooLong": true
91
+ }
92
+ ]
93
+ }