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