@johnnyboy11234/esg-idea-contest 0.2.3 → 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.
- package/backend/internal/account/logging_integration_test.go +3 -3
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +50 -0
- package/backend/test/matrix/matrix_integration_test.go +210 -0
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/package.json +1 -1
- package/testdata/permission-matrix.json +109 -0
- package/testdata/schema/permission-matrix.schema.json +52 -0
|
@@ -53,9 +53,9 @@ func TestRegisterLogsNeverContainPassword(t *testing.T) {
|
|
|
53
53
|
pw2 = "An0ther-Secret-Horse-2"
|
|
54
54
|
pw3 = "Dup-Secret-Horse-3"
|
|
55
55
|
)
|
|
56
|
-
e.register(reg("089290", "Wang", "wang@example.com", pw1))
|
|
57
|
-
e.register(reg("089290", "Wang", "wang2@example.com", pw2))
|
|
58
|
-
e.register(reg("089291", "Lee", "lee@example.com", "short"))
|
|
56
|
+
e.register(reg("089290", "Wang", "wang@example.com", pw1)) // 201
|
|
57
|
+
e.register(reg("089290", "Wang", "wang2@example.com", pw2)) // duplicate
|
|
58
|
+
e.register(reg("089291", "Lee", "lee@example.com", "short")) // weak
|
|
59
59
|
e.register(`{"empNo":"089292","name":"X","email":"x@example.com","password":"` + pw3 + `"`) // malformed
|
|
60
60
|
|
|
61
61
|
out := buf.String()
|
|
@@ -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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { render, screen, waitFor
|
|
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,
|
|
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.
|
|
16
|
-
--primary-foreground: oklch(0.
|
|
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.
|
|
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.
|
|
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-
|
|
16
|
+
className="mb-6 text-2xl font-semibold outline-none"
|
|
44
17
|
>
|
|
45
18
|
{title}
|
|
46
19
|
</h1>
|
|
47
20
|
);
|
|
48
21
|
}
|
|
49
22
|
|
|
50
|
-
|
|
51
|
-
|
|
23
|
+
/** Every page fills the viewport; there is no header, navigation or footer around it. */
|
|
24
|
+
export function Screen({
|
|
52
25
|
children,
|
|
26
|
+
padded = true,
|
|
53
27
|
}: {
|
|
54
|
-
header: ReactNode;
|
|
55
28
|
children: ReactNode;
|
|
29
|
+
padded?: boolean;
|
|
56
30
|
}) {
|
|
57
|
-
const { t } = useTranslation();
|
|
58
|
-
return (
|
|
59
|
-
<div className="min-h-screen">
|
|
60
|
-
<a
|
|
61
|
-
href="#main"
|
|
62
|
-
className="sr-only focus:not-sr-only focus:absolute focus:z-10 focus:bg-background focus:p-2"
|
|
63
|
-
>
|
|
64
|
-
{t("nav.skip")}
|
|
65
|
-
</a>
|
|
66
|
-
<header className="border-b">{header}</header>
|
|
67
|
-
<DeadlineBanner />
|
|
68
|
-
<main id="main" className="mx-auto max-w-5xl p-4">
|
|
69
|
-
{children}
|
|
70
|
-
</main>
|
|
71
|
-
</div>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function PublicLayout({ children }: { children: ReactNode }) {
|
|
76
|
-
const { t } = useTranslation();
|
|
77
31
|
return (
|
|
78
|
-
<
|
|
79
|
-
header={
|
|
80
|
-
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 p-4">
|
|
81
|
-
<Link to="/login" className="font-semibold">
|
|
82
|
-
{t("app.name")}
|
|
83
|
-
</Link>
|
|
84
|
-
<LanguageSwitch />
|
|
85
|
-
</div>
|
|
86
|
-
}
|
|
87
|
-
>
|
|
88
|
-
{children}
|
|
89
|
-
</Frame>
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Ends the session. Whatever the answer, the browser must stop acting as this user: logout is
|
|
95
|
-
* idempotent on the server, and on a network failure the user is told instead of being left unsure.
|
|
96
|
-
*/
|
|
97
|
-
function SignOutButton() {
|
|
98
|
-
const { t } = useTranslation();
|
|
99
|
-
const client = useQueryClient();
|
|
100
|
-
const navigate = useNavigate();
|
|
101
|
-
const out = useMutation({
|
|
102
|
-
mutationFn: logout,
|
|
103
|
-
onSuccess: () => {
|
|
104
|
-
replaceIdentity(client, null);
|
|
105
|
-
void navigate("/login", { replace: true });
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
return (
|
|
109
|
-
<>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
className="rounded-md border px-2 py-1 text-sm disabled:opacity-50"
|
|
113
|
-
disabled={out.isPending}
|
|
114
|
-
onClick={() => out.mutate()}
|
|
115
|
-
>
|
|
116
|
-
{t("nav.signOut")}
|
|
117
|
-
</button>
|
|
118
|
-
{out.isError && (
|
|
119
|
-
<span role="alert" className="text-sm text-destructive">
|
|
120
|
-
{t("errors.UNKNOWN")}
|
|
121
|
-
</span>
|
|
122
|
-
)}
|
|
123
|
-
</>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Logged-in layout; navigation comes from the route table and the role. */
|
|
128
|
-
export function AppShell({ me, children }: { me: Me; children: ReactNode }) {
|
|
129
|
-
const { t } = useTranslation();
|
|
130
|
-
return (
|
|
131
|
-
<Frame
|
|
132
|
-
header={
|
|
133
|
-
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-4 p-4">
|
|
134
|
-
<nav aria-label={t("nav.main")}>
|
|
135
|
-
<ul className="flex flex-wrap items-center gap-4">
|
|
136
|
-
{navFor(me.role).map((r) => (
|
|
137
|
-
<li key={r.path}>
|
|
138
|
-
<NavLink
|
|
139
|
-
to={r.path}
|
|
140
|
-
className="underline-offset-4 hover:underline aria-[current=page]:font-semibold"
|
|
141
|
-
>
|
|
142
|
-
{t(`nav.${r.navKey!}`)}
|
|
143
|
-
</NavLink>
|
|
144
|
-
</li>
|
|
145
|
-
))}
|
|
146
|
-
</ul>
|
|
147
|
-
</nav>
|
|
148
|
-
<div className="flex items-center gap-4">
|
|
149
|
-
{me.role === "ADMIN" && (
|
|
150
|
-
<span className="rounded-md border px-2 py-0.5 text-sm">
|
|
151
|
-
{t("nav.administrator")}
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
154
|
-
<span className="text-sm">{me.name}</span>
|
|
155
|
-
<LanguageSwitch />
|
|
156
|
-
<SignOutButton />
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
}
|
|
160
|
-
>
|
|
32
|
+
<main id="main" className={padded ? "min-h-screen p-6 md:p-10" : "min-h-screen"}>
|
|
161
33
|
{children}
|
|
162
|
-
</
|
|
34
|
+
</main>
|
|
163
35
|
);
|
|
164
36
|
}
|
|
@@ -9,7 +9,7 @@ export function JoinRequests() {
|
|
|
9
9
|
return (
|
|
10
10
|
<>
|
|
11
11
|
<PageHeading title={t('nav.joinRequests')} />
|
|
12
|
-
<section className="mb-
|
|
12
|
+
<section className="mb-6 max-w-md rounded-xl border bg-card p-6">
|
|
13
13
|
<h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.apply')}</h2>
|
|
14
14
|
<form onSubmit={(e: FormEvent) => e.preventDefault()}>
|
|
15
15
|
<Field label={t('fields.leaderEmpNo')}>{(p) => <input {...p} name="leaderEmpNo" required />}</Field>
|
|
@@ -17,7 +17,7 @@ export function JoinRequests() {
|
|
|
17
17
|
<Button type="submit">{t('pages.joinRequests.submit')}</Button>
|
|
18
18
|
</form>
|
|
19
19
|
</section>
|
|
20
|
-
<section>
|
|
20
|
+
<section className="rounded-xl border bg-card p-6">
|
|
21
21
|
<h2 className="mb-2 text-lg font-semibold">{t('pages.joinRequests.mine')}</h2>
|
|
22
22
|
<p className="text-sm">{t('pages.joinRequests.empty')}</p>
|
|
23
23
|
</section>
|
|
@@ -9,7 +9,7 @@ export function NotFound() {
|
|
|
9
9
|
<>
|
|
10
10
|
<PageHeading title={t('notFound.title')} />
|
|
11
11
|
<p className="mb-4">{t('notFound.body')}</p>
|
|
12
|
-
<Link to="/" className="underline">
|
|
12
|
+
<Link to="/" className="font-medium text-primary underline underline-offset-4">
|
|
13
13
|
{t('notFound.home')}
|
|
14
14
|
</Link>
|
|
15
15
|
</>
|
|
@@ -10,7 +10,7 @@ export function AdminDeadline() {
|
|
|
10
10
|
return (
|
|
11
11
|
<>
|
|
12
12
|
<PageHeading title={t('nav.deadline')} />
|
|
13
|
-
<form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md">
|
|
13
|
+
<form onSubmit={(e: FormEvent) => e.preventDefault()} className="max-w-md rounded-xl border bg-card p-6">
|
|
14
14
|
<Field label={t('pages.adminDeadline.label')} hint={t('pages.adminDeadline.hint')}>
|
|
15
15
|
{(p) => <input {...p} name="deadline" type="datetime-local" required />}
|
|
16
16
|
</Field>
|
|
@@ -29,7 +29,34 @@ function useAuthHeading(title: string) {
|
|
|
29
29
|
return ref;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/** Brand mark shared by both hero layouts and the form header. */
|
|
33
|
+
function LogoMark({ className = "size-16" }: { className?: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
className={`flex items-center justify-center rounded-2xl bg-primary text-primary-foreground ${className}`}
|
|
38
|
+
>
|
|
39
|
+
<Leaf className="size-1/2" />
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function HeroCopy({ title, body }: { title: string; body: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
className="flex flex-col items-center gap-4 text-center"
|
|
49
|
+
>
|
|
50
|
+
<LogoMark />
|
|
51
|
+
<p className="max-w-sm text-3xl font-semibold text-balance whitespace-pre-line text-primary">
|
|
52
|
+
{title}
|
|
53
|
+
</p>
|
|
54
|
+
<p className="max-w-xs text-sm text-balance text-foreground/80">{body}</p>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Design `Login - Employee`: 500px photo panel on the left (lg and up), form centred on the right. */
|
|
33
60
|
export function Login() {
|
|
34
61
|
const { t } = useTranslation();
|
|
35
62
|
const heading = useAuthHeading(t("nav.signIn"));
|
|
@@ -44,8 +71,17 @@ export function Login() {
|
|
|
44
71
|
? t("pages.login.sessionEnded")
|
|
45
72
|
: undefined;
|
|
46
73
|
return (
|
|
47
|
-
<div className="grid
|
|
48
|
-
<div
|
|
74
|
+
<div className="grid min-h-screen bg-card lg:grid-cols-[500px_1fr]">
|
|
75
|
+
<div
|
|
76
|
+
className="relative hidden items-center justify-center bg-cover bg-center p-10 lg:flex"
|
|
77
|
+
style={{ backgroundImage: "url(/auth-hero-login.jpg)" }}
|
|
78
|
+
>
|
|
79
|
+
<HeroCopy
|
|
80
|
+
title={t("pages.login.panelTitle")}
|
|
81
|
+
body={t("pages.login.panelBody")}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center justify-center p-6 md:p-12">
|
|
49
85
|
{/* A new session means a new identity: nothing cached for the previous one may survive. The gate then
|
|
50
86
|
sees `me` and redirects to the validated `next` (Frontend.md §6.4). */}
|
|
51
87
|
<LoginForm
|
|
@@ -54,32 +90,24 @@ export function Login() {
|
|
|
54
90
|
onLoggedIn={(me) => replaceIdentity(client, me)}
|
|
55
91
|
/>
|
|
56
92
|
</div>
|
|
57
|
-
<div
|
|
58
|
-
aria-hidden="true"
|
|
59
|
-
className="hidden flex-col items-center justify-center gap-3 bg-muted p-10 text-center lg:flex"
|
|
60
|
-
>
|
|
61
|
-
<div className="flex size-12 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
62
|
-
<Leaf className="size-6" />
|
|
63
|
-
</div>
|
|
64
|
-
<p className="text-xl font-semibold">{t("pages.login.panelTitle")}</p>
|
|
65
|
-
<p className="max-w-xs text-sm text-balance text-muted-foreground">
|
|
66
|
-
{t("pages.login.panelBody")}
|
|
67
|
-
</p>
|
|
68
|
-
</div>
|
|
69
93
|
</div>
|
|
70
94
|
);
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
/**
|
|
97
|
+
/** Design `Sign Up - Employee`: a full-bleed photo with the form card centred on it. */
|
|
74
98
|
export function Register() {
|
|
75
99
|
const { t } = useTranslation();
|
|
76
100
|
const navigate = useNavigate();
|
|
77
101
|
const heading = useAuthHeading(t("nav.register"));
|
|
78
102
|
return (
|
|
79
|
-
<div
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
<div
|
|
104
|
+
className="flex min-h-screen items-center justify-center bg-cover bg-center px-4 py-10"
|
|
105
|
+
style={{ backgroundImage: "url(/auth-hero-register.jpg)" }}
|
|
106
|
+
>
|
|
107
|
+
<Card className="w-full max-w-md shadow-lg">
|
|
108
|
+
<CardHeader className="items-center text-center">
|
|
109
|
+
<LogoMark className="size-14" />
|
|
110
|
+
<CardTitle className="pt-2 text-2xl">
|
|
83
111
|
<h1 ref={heading} tabIndex={-1} className="outline-none">
|
|
84
112
|
{t("pages.register.title")}
|
|
85
113
|
</h1>
|
|
@@ -88,11 +116,11 @@ export function Register() {
|
|
|
88
116
|
</CardHeader>
|
|
89
117
|
<CardContent>
|
|
90
118
|
<RegisterForm
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
onRegistered={() => {
|
|
120
|
+
const state: RegisteredState = { notice: "registered" };
|
|
121
|
+
void navigate("/login", { replace: true, state });
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
96
124
|
</CardContent>
|
|
97
125
|
</Card>
|
|
98
126
|
</div>
|
|
@@ -62,12 +62,12 @@ export function IdeaList() {
|
|
|
62
62
|
</Button>
|
|
63
63
|
)}
|
|
64
64
|
</form>
|
|
65
|
-
<table className="w-full text-left text-sm">
|
|
65
|
+
<table className="w-full rounded-xl border bg-card text-left text-sm">
|
|
66
66
|
<caption className="sr-only">{t('nav.ideas')}</caption>
|
|
67
67
|
<thead>
|
|
68
68
|
<tr>
|
|
69
69
|
{(['ideaName', 'category', 'org', 'direction', 'leader', 'members'] as const).map((c) => (
|
|
70
|
-
<th key={c} scope="col" className="border-b p-
|
|
70
|
+
<th key={c} scope="col" className="border-b p-3 font-medium text-muted-foreground">
|
|
71
71
|
{t(`fields.${c}`)}
|
|
72
72
|
</th>
|
|
73
73
|
))}
|
|
@@ -86,7 +86,7 @@ export function IdeaCreate() {
|
|
|
86
86
|
return (
|
|
87
87
|
<>
|
|
88
88
|
<PageHeading title={t('pages.ideaCreate.title')} />
|
|
89
|
-
<form onSubmit={noSubmit} className="max-w-2xl">
|
|
89
|
+
<form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
|
|
90
90
|
<IdeaFields withSlides />
|
|
91
91
|
<Button type="submit" disabled={!open}>
|
|
92
92
|
{t('pages.ideaCreate.submit')}
|
|
@@ -98,8 +98,8 @@ export function IdeaCreate() {
|
|
|
98
98
|
|
|
99
99
|
function Section({ title, children }: { title: string; children?: ReactNode }) {
|
|
100
100
|
return (
|
|
101
|
-
<section className="mb-6">
|
|
102
|
-
<h2 className="mb-
|
|
101
|
+
<section className="mb-4 rounded-xl border bg-card p-6">
|
|
102
|
+
<h2 className="mb-3 text-lg font-semibold">{title}</h2>
|
|
103
103
|
{children}
|
|
104
104
|
</section>
|
|
105
105
|
);
|
|
@@ -133,7 +133,7 @@ export function IdeaDetail() {
|
|
|
133
133
|
<Button variant="destructive">{t('pages.ideaDetail.delete')}</Button>
|
|
134
134
|
</div>
|
|
135
135
|
</Section>
|
|
136
|
-
<Link to="/ideas" className="underline">
|
|
136
|
+
<Link to="/ideas" className="font-medium text-primary underline underline-offset-4">
|
|
137
137
|
{t('pages.ideaDetail.back')}
|
|
138
138
|
</Link>
|
|
139
139
|
</>
|
|
@@ -145,7 +145,7 @@ export function IdeaEdit() {
|
|
|
145
145
|
return (
|
|
146
146
|
<>
|
|
147
147
|
<PageHeading title={t('pages.ideaEdit.title')} />
|
|
148
|
-
<form onSubmit={noSubmit} className="max-w-2xl">
|
|
148
|
+
<form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
|
|
149
149
|
<IdeaFields withSlides={false} />
|
|
150
150
|
<div className="flex gap-2">
|
|
151
151
|
<Button type="submit">{t('common.save')}</Button>
|
|
@@ -13,12 +13,12 @@ import { routes } from "../router";
|
|
|
13
13
|
|
|
14
14
|
const API = "http://localhost:3000/api/v1";
|
|
15
15
|
const USER = { empNo: "089290", name: "Wang", email: "w@x.io", role: "USER" };
|
|
16
|
-
|
|
16
|
+
type Account = typeof USER | { empNo: string; name: string; email: null; role: "ADMIN" };
|
|
17
17
|
|
|
18
18
|
/** A visitor on `path`; `account` is who a successful login returns. `GET /auth/me` follows the session. */
|
|
19
|
-
async function open(path: string, account:
|
|
19
|
+
async function open(path: string, account: Account = USER) {
|
|
20
20
|
clearCsrfToken();
|
|
21
|
-
let session:
|
|
21
|
+
let session: Account | null = null;
|
|
22
22
|
const calls: { login: unknown[]; logoutCsrf: (string | null)[] } = {
|
|
23
23
|
login: [],
|
|
24
24
|
logoutCsrf: [],
|
|
@@ -87,15 +87,6 @@ describe("Login page", () => {
|
|
|
87
87
|
await signIn(user, " 089290 ", "secret");
|
|
88
88
|
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
89
89
|
expect(calls.login).toEqual([{ empNo: "089290", password: "secret" }]);
|
|
90
|
-
expect(await screen.findByText("Wang")).toBeInTheDocument();
|
|
91
|
-
expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("signs an Admin in, to the same page, with the Administrator badge", async () => {
|
|
95
|
-
const { router, user } = await open("/login", ADMIN);
|
|
96
|
-
await signIn(user, "000001");
|
|
97
|
-
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
98
|
-
expect(await screen.findByText("Administrator")).toBeInTheDocument();
|
|
99
90
|
});
|
|
100
91
|
|
|
101
92
|
it("follows a role-allowed next", async () => {
|
|
@@ -174,45 +165,3 @@ describe("Login page", () => {
|
|
|
174
165
|
).toBeInTheDocument();
|
|
175
166
|
});
|
|
176
167
|
});
|
|
177
|
-
|
|
178
|
-
describe("Sign out", () => {
|
|
179
|
-
it("ends the session with the session-bound CSRF token and returns to a clean /login", async () => {
|
|
180
|
-
const { router, calls, user } = await open("/login");
|
|
181
|
-
await signIn(user);
|
|
182
|
-
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
183
|
-
await screen.findByRole("heading", {
|
|
184
|
-
level: 1,
|
|
185
|
-
name: "Sign in to your account",
|
|
186
|
-
});
|
|
187
|
-
expect(calls.logoutCsrf).toEqual(["tok-session"]);
|
|
188
|
-
expect(router.state.location.pathname).toBe("/login");
|
|
189
|
-
expect(router.state.location.search).toBe("");
|
|
190
|
-
expect(
|
|
191
|
-
screen.queryByText("Your session has ended. Please sign in again."),
|
|
192
|
-
).not.toBeInTheDocument();
|
|
193
|
-
expect(screen.queryByText("Wang")).not.toBeInTheDocument();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("a second login after logout uses a fresh CSRF token", async () => {
|
|
197
|
-
const { router, user } = await open("/login");
|
|
198
|
-
await signIn(user);
|
|
199
|
-
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
200
|
-
await screen.findByRole("heading", {
|
|
201
|
-
level: 1,
|
|
202
|
-
name: "Sign in to your account",
|
|
203
|
-
});
|
|
204
|
-
await signIn(user);
|
|
205
|
-
await waitFor(() => expect(router.state.location.pathname).toBe("/ideas"));
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("tells the user when sign-out fails and keeps them signed in", async () => {
|
|
209
|
-
const { router, user } = await open("/login");
|
|
210
|
-
await signIn(user);
|
|
211
|
-
server.use(
|
|
212
|
-
http.post(`${API}/auth/logout`, () => problem(500, "INTERNAL_ERROR")),
|
|
213
|
-
);
|
|
214
|
-
await user.click(await screen.findByRole("button", { name: "Sign out" }));
|
|
215
|
-
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
|
216
|
-
expect(router.state.location.pathname).toBe("/ideas");
|
|
217
|
-
});
|
|
218
|
-
});
|
|
@@ -71,7 +71,7 @@ describe('Register page: markup', () => {
|
|
|
71
71
|
|
|
72
72
|
it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), also with errors shown', async (locale) => {
|
|
73
73
|
const { container, user } = await open(locale);
|
|
74
|
-
await screen.findByRole('
|
|
74
|
+
await screen.findByRole('heading', { level: 1 });
|
|
75
75
|
expect((await axe(container)).violations).toEqual([]);
|
|
76
76
|
await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Create account' : '建立帳號' }));
|
|
77
77
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type RouteObject,
|
|
8
8
|
} from "react-router";
|
|
9
9
|
import { decide, safeNext } from "./access";
|
|
10
|
-
import {
|
|
10
|
+
import { Screen } from "./layouts";
|
|
11
11
|
import { NotFound } from "./pages/NotFound";
|
|
12
12
|
import { Login, Register } from "./pages/auth";
|
|
13
13
|
import { IdeaCreate, IdeaDetail, IdeaEdit, IdeaList } from "./pages/ideas";
|
|
@@ -94,11 +94,7 @@ function Gate({ route }: { route?: (typeof ROUTES)[number] }) {
|
|
|
94
94
|
) : (
|
|
95
95
|
<NotFound />
|
|
96
96
|
);
|
|
97
|
-
return
|
|
98
|
-
<AppShell me={me}>{page}</AppShell>
|
|
99
|
-
) : (
|
|
100
|
-
<PublicLayout>{page}</PublicLayout>
|
|
101
|
-
);
|
|
97
|
+
return <Screen padded={route?.access !== "public"}>{page}</Screen>;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
function RootRedirect() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type FormEvent, type Ref } from "react";
|
|
2
|
+
import { Leaf } from "lucide-react";
|
|
2
3
|
import { useTranslation } from "react-i18next";
|
|
3
4
|
import { Link } from "react-router";
|
|
4
5
|
import { ApiError } from "@/shared/api";
|
|
@@ -65,11 +66,17 @@ export function LoginForm({
|
|
|
65
66
|
|
|
66
67
|
return (
|
|
67
68
|
<form
|
|
68
|
-
className="flex w-full max-w-
|
|
69
|
+
className="flex w-full max-w-sm flex-col gap-6"
|
|
69
70
|
onSubmit={(e) => void onSubmit(e)}
|
|
70
71
|
>
|
|
71
72
|
<FieldGroup>
|
|
72
73
|
<div className="flex flex-col items-center gap-1 text-center">
|
|
74
|
+
<div
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
className="mb-2 flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
|
|
77
|
+
>
|
|
78
|
+
<Leaf className="size-7" />
|
|
79
|
+
</div>
|
|
73
80
|
<h1
|
|
74
81
|
ref={heading}
|
|
75
82
|
tabIndex={-1}
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|