@johnnyboy11234/esg-idea-contest 0.3.1 → 0.3.2
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 +36 -0
- package/backend/cmd/server/main.go +1 -0
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/password.go +8 -2
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/api/api.gen.go +38 -0
- package/backend/internal/idea/create.go +5 -1
- package/backend/internal/idea/create_integration_test.go +33 -3
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +3 -0
- package/backend/internal/idea/service.go +2 -1
- package/backend/internal/server/api.go +5 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/matrix/matrix_integration_test.go +6 -0
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +45 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/password.json +164 -7
- package/testdata/permission-matrix.json +23 -0
- package/testdata/schema/permission-matrix.schema.json +1 -1
|
@@ -21,6 +21,26 @@ export interface paths {
|
|
|
21
21
|
patch?: never;
|
|
22
22
|
trace?: never;
|
|
23
23
|
};
|
|
24
|
+
"/auth/login": {
|
|
25
|
+
parameters: {
|
|
26
|
+
query?: never;
|
|
27
|
+
header?: never;
|
|
28
|
+
path?: never;
|
|
29
|
+
cookie?: never;
|
|
30
|
+
};
|
|
31
|
+
get?: never;
|
|
32
|
+
put?: never;
|
|
33
|
+
/**
|
|
34
|
+
* Log in with employee ID and password (issues a new session)
|
|
35
|
+
* @description Sets the session cookie and returns the caller. Unknown, malformed and disabled accounts and wrong passwords all answer the same 401. The CSRF token changes with the session, so the client refetches it.
|
|
36
|
+
*/
|
|
37
|
+
post: operations["login"];
|
|
38
|
+
delete?: never;
|
|
39
|
+
options?: never;
|
|
40
|
+
head?: never;
|
|
41
|
+
patch?: never;
|
|
42
|
+
trace?: never;
|
|
43
|
+
};
|
|
24
44
|
"/auth/logout": {
|
|
25
45
|
parameters: {
|
|
26
46
|
query?: never;
|
|
@@ -38,6 +58,43 @@ export interface paths {
|
|
|
38
58
|
patch?: never;
|
|
39
59
|
trace?: never;
|
|
40
60
|
};
|
|
61
|
+
"/auth/me": {
|
|
62
|
+
parameters: {
|
|
63
|
+
query?: never;
|
|
64
|
+
header?: never;
|
|
65
|
+
path?: never;
|
|
66
|
+
cookie?: never;
|
|
67
|
+
};
|
|
68
|
+
/** The caller (also the idle-timeout "continue" call) */
|
|
69
|
+
get: operations["getMe"];
|
|
70
|
+
put?: never;
|
|
71
|
+
post?: never;
|
|
72
|
+
delete?: never;
|
|
73
|
+
options?: never;
|
|
74
|
+
head?: never;
|
|
75
|
+
patch?: never;
|
|
76
|
+
trace?: never;
|
|
77
|
+
};
|
|
78
|
+
"/auth/register": {
|
|
79
|
+
parameters: {
|
|
80
|
+
query?: never;
|
|
81
|
+
header?: never;
|
|
82
|
+
path?: never;
|
|
83
|
+
cookie?: never;
|
|
84
|
+
};
|
|
85
|
+
get?: never;
|
|
86
|
+
put?: never;
|
|
87
|
+
/**
|
|
88
|
+
* Self-service registration (no session is created)
|
|
89
|
+
* @description Validation order: format, password policy, uniqueness (PRD 6.3). Sets no cookie.
|
|
90
|
+
*/
|
|
91
|
+
post: operations["register"];
|
|
92
|
+
delete?: never;
|
|
93
|
+
options?: never;
|
|
94
|
+
head?: never;
|
|
95
|
+
patch?: never;
|
|
96
|
+
trace?: never;
|
|
97
|
+
};
|
|
41
98
|
"/deadline": {
|
|
42
99
|
parameters: {
|
|
43
100
|
query?: never;
|
|
@@ -65,7 +122,11 @@ export interface paths {
|
|
|
65
122
|
/** List Ideas visible to the caller */
|
|
66
123
|
get: operations["listIdeas"];
|
|
67
124
|
put?: never;
|
|
68
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Create an Idea with its slides (User only)
|
|
127
|
+
* @description One atomic multipart request (PRD 6.6). Order: role 403, state 403, 400 (missing or repeated part), 422 text fields (incl. FILENAME_TOO_LONG), file 413 / 422 EMPTY_FILE / 415 / 422 FILENAME_MISMATCH, 409 IDEA_DUPLICATE, then upload (503 STORAGE_UNAVAILABLE). A transaction whose outcome is unknown answers 503 SERVICE_UNAVAILABLE (the Idea may exist). The Leader is the caller; any leader value in the form is ignored. The body is read by the handler, so the generated wrapper does not bind it.
|
|
128
|
+
*/
|
|
129
|
+
post: operations["createIdea"];
|
|
69
130
|
delete?: never;
|
|
70
131
|
options?: never;
|
|
71
132
|
head?: never;
|
|
@@ -89,6 +150,26 @@ export interface paths {
|
|
|
89
150
|
patch?: never;
|
|
90
151
|
trace?: never;
|
|
91
152
|
};
|
|
153
|
+
"/ideas/{id}/slides": {
|
|
154
|
+
parameters: {
|
|
155
|
+
query?: never;
|
|
156
|
+
header?: never;
|
|
157
|
+
path?: never;
|
|
158
|
+
cookie?: never;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Download the Idea's slides (Leader, Member, Admin)
|
|
162
|
+
* @description The SLIDES_DOWNLOAD audit row is committed before the first byte is sent (PRD 6.11). The file is named by the current expected filename (PRD 6.6). Range is not supported; HEAD answers 405 (no audit row). The Accept header is not checked on this route. MinIO unavailable before the first byte answers 503 STORAGE_UNAVAILABLE; a storage failure after the headers are sent aborts the connection (the body is shorter than Content-Length). A transaction whose outcome is unknown answers 503 SERVICE_UNAVAILABLE and serves nothing.
|
|
163
|
+
*/
|
|
164
|
+
get: operations["downloadSlides"];
|
|
165
|
+
put?: never;
|
|
166
|
+
post?: never;
|
|
167
|
+
delete?: never;
|
|
168
|
+
options?: never;
|
|
169
|
+
head?: never;
|
|
170
|
+
patch?: never;
|
|
171
|
+
trace?: never;
|
|
172
|
+
};
|
|
92
173
|
}
|
|
93
174
|
export type webhooks = Record<string, never>;
|
|
94
175
|
export interface components {
|
|
@@ -96,6 +177,35 @@ export interface components {
|
|
|
96
177
|
CsrfTokenResponse: {
|
|
97
178
|
token: string;
|
|
98
179
|
};
|
|
180
|
+
LoginRequest: {
|
|
181
|
+
/** @description Any string; a badly formatted ID is an ordinary login failure */
|
|
182
|
+
empNo: string;
|
|
183
|
+
/** Format: password */
|
|
184
|
+
password: string;
|
|
185
|
+
};
|
|
186
|
+
LoginResponse: {
|
|
187
|
+
empNo: string;
|
|
188
|
+
name: string;
|
|
189
|
+
/** @description null for Admin */
|
|
190
|
+
email: string | null;
|
|
191
|
+
/** @enum {string} */
|
|
192
|
+
role: "USER" | "ADMIN";
|
|
193
|
+
};
|
|
194
|
+
RegisterRequest: {
|
|
195
|
+
/** @description Exactly 6 ASCII digits */
|
|
196
|
+
empNo: string;
|
|
197
|
+
/** @description 1-50 code points after clean-up */
|
|
198
|
+
name: string;
|
|
199
|
+
/** @description Printable ASCII with exactly one @, at most 254 characters; stored lower-cased */
|
|
200
|
+
email: string;
|
|
201
|
+
/** Format: password */
|
|
202
|
+
password: string;
|
|
203
|
+
};
|
|
204
|
+
RegisterResponse: {
|
|
205
|
+
empNo: string;
|
|
206
|
+
name: string;
|
|
207
|
+
email: string;
|
|
208
|
+
};
|
|
99
209
|
Problem: {
|
|
100
210
|
/** @example about:blank */
|
|
101
211
|
type: string;
|
|
@@ -112,6 +222,8 @@ export interface components {
|
|
|
112
222
|
field: string;
|
|
113
223
|
code: string;
|
|
114
224
|
expected?: string;
|
|
225
|
+
/** @description PASSWORD_POLICY only, the violated rule id (PRD 6.4) */
|
|
226
|
+
rule?: string;
|
|
115
227
|
};
|
|
116
228
|
Deadline: {
|
|
117
229
|
/** Format: date-time */
|
|
@@ -128,6 +240,23 @@ export interface components {
|
|
|
128
240
|
empNo: string;
|
|
129
241
|
name: string;
|
|
130
242
|
};
|
|
243
|
+
CreateIdeaForm: {
|
|
244
|
+
/** @description COOL_IDEA or OUTSTANDING_IMPACT */
|
|
245
|
+
category: string;
|
|
246
|
+
/** @description AAID, BSID, ICSD, TSID, PLED or DEID */
|
|
247
|
+
org: string;
|
|
248
|
+
/** @description One of the five ESG directions (PRD 6.6) */
|
|
249
|
+
direction: string;
|
|
250
|
+
/** @description 1-75 code points after clean-up; no / \ : * ? " < > | */
|
|
251
|
+
name: string;
|
|
252
|
+
/** @description 1-300 code points after clean-up */
|
|
253
|
+
summary: string;
|
|
254
|
+
/**
|
|
255
|
+
* Format: binary
|
|
256
|
+
* @description One PDF or PPTX, at most 5 MiB, named {I|O}_{org}_{leaderName}_{leaderEmpNo}_{name}.{pdf|pptx}
|
|
257
|
+
*/
|
|
258
|
+
slides: string;
|
|
259
|
+
};
|
|
131
260
|
IdeaSummary: {
|
|
132
261
|
/** Format: uuid */
|
|
133
262
|
id: string;
|
|
@@ -235,6 +364,53 @@ export interface operations {
|
|
|
235
364
|
500: components["responses"]["Problem"];
|
|
236
365
|
};
|
|
237
366
|
};
|
|
367
|
+
login: {
|
|
368
|
+
parameters: {
|
|
369
|
+
query?: never;
|
|
370
|
+
header?: {
|
|
371
|
+
/** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
|
|
372
|
+
"X-CSRF-Token"?: components["parameters"]["CsrfToken"];
|
|
373
|
+
};
|
|
374
|
+
path?: never;
|
|
375
|
+
cookie?: never;
|
|
376
|
+
};
|
|
377
|
+
requestBody: {
|
|
378
|
+
content: {
|
|
379
|
+
"application/json": components["schemas"]["LoginRequest"];
|
|
380
|
+
};
|
|
381
|
+
};
|
|
382
|
+
responses: {
|
|
383
|
+
/** @description Logged in */
|
|
384
|
+
200: {
|
|
385
|
+
headers: {
|
|
386
|
+
/** @description __Host-session=<opaque ID>; HttpOnly; Secure; SameSite=Lax; Path=/ */
|
|
387
|
+
"Set-Cookie"?: string;
|
|
388
|
+
[name: string]: unknown;
|
|
389
|
+
};
|
|
390
|
+
content: {
|
|
391
|
+
"application/json": components["schemas"]["LoginResponse"];
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
400: components["responses"]["Problem"];
|
|
395
|
+
401: components["responses"]["Problem"];
|
|
396
|
+
403: components["responses"]["Problem"];
|
|
397
|
+
413: components["responses"]["Problem"];
|
|
398
|
+
415: components["responses"]["Problem"];
|
|
399
|
+
/** @description Throttled (LOGIN_THROTTLED) */
|
|
400
|
+
429: {
|
|
401
|
+
headers: {
|
|
402
|
+
/** @description Seconds until a new attempt may succeed */
|
|
403
|
+
"Retry-After"?: number;
|
|
404
|
+
[name: string]: unknown;
|
|
405
|
+
};
|
|
406
|
+
content: {
|
|
407
|
+
"application/problem+json": components["schemas"]["Problem"];
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
500: components["responses"]["Problem"];
|
|
411
|
+
503: components["responses"]["Problem"];
|
|
412
|
+
};
|
|
413
|
+
};
|
|
238
414
|
logout: {
|
|
239
415
|
parameters: {
|
|
240
416
|
query?: never;
|
|
@@ -258,6 +434,63 @@ export interface operations {
|
|
|
258
434
|
500: components["responses"]["Problem"];
|
|
259
435
|
};
|
|
260
436
|
};
|
|
437
|
+
getMe: {
|
|
438
|
+
parameters: {
|
|
439
|
+
query?: never;
|
|
440
|
+
header?: never;
|
|
441
|
+
path?: never;
|
|
442
|
+
cookie?: never;
|
|
443
|
+
};
|
|
444
|
+
requestBody?: never;
|
|
445
|
+
responses: {
|
|
446
|
+
/** @description The authenticated caller */
|
|
447
|
+
200: {
|
|
448
|
+
headers: {
|
|
449
|
+
[name: string]: unknown;
|
|
450
|
+
};
|
|
451
|
+
content: {
|
|
452
|
+
"application/json": components["schemas"]["LoginResponse"];
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
401: components["responses"]["Problem"];
|
|
456
|
+
500: components["responses"]["Problem"];
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
register: {
|
|
460
|
+
parameters: {
|
|
461
|
+
query?: never;
|
|
462
|
+
header?: {
|
|
463
|
+
/** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
|
|
464
|
+
"X-CSRF-Token"?: components["parameters"]["CsrfToken"];
|
|
465
|
+
};
|
|
466
|
+
path?: never;
|
|
467
|
+
cookie?: never;
|
|
468
|
+
};
|
|
469
|
+
requestBody: {
|
|
470
|
+
content: {
|
|
471
|
+
"application/json": components["schemas"]["RegisterRequest"];
|
|
472
|
+
};
|
|
473
|
+
};
|
|
474
|
+
responses: {
|
|
475
|
+
/** @description Account created; log in separately */
|
|
476
|
+
201: {
|
|
477
|
+
headers: {
|
|
478
|
+
[name: string]: unknown;
|
|
479
|
+
};
|
|
480
|
+
content: {
|
|
481
|
+
"application/json": components["schemas"]["RegisterResponse"];
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
400: components["responses"]["Problem"];
|
|
485
|
+
403: components["responses"]["Problem"];
|
|
486
|
+
409: components["responses"]["Problem"];
|
|
487
|
+
413: components["responses"]["Problem"];
|
|
488
|
+
415: components["responses"]["Problem"];
|
|
489
|
+
422: components["responses"]["Problem"];
|
|
490
|
+
500: components["responses"]["Problem"];
|
|
491
|
+
503: components["responses"]["Problem"];
|
|
492
|
+
};
|
|
493
|
+
};
|
|
261
494
|
getDeadline: {
|
|
262
495
|
parameters: {
|
|
263
496
|
query?: never;
|
|
@@ -285,6 +518,21 @@ export interface operations {
|
|
|
285
518
|
query?: {
|
|
286
519
|
page?: number;
|
|
287
520
|
size?: number;
|
|
521
|
+
/** @description Closed set; an invalid value is 422 INVALID_ENUM (never ignored). Validated in the handler. */
|
|
522
|
+
org?: string;
|
|
523
|
+
category?: string;
|
|
524
|
+
direction?: string;
|
|
525
|
+
leaderEmpNo?: string;
|
|
526
|
+
/** @description ISO 8601 instant with offset or Z; inclusive. Validated in the handler. */
|
|
527
|
+
createdFrom?: string;
|
|
528
|
+
/** @description ISO 8601 instant with offset or Z; exclusive. Validated in the handler. */
|
|
529
|
+
createdTo?: string;
|
|
530
|
+
/** @description Case-insensitive "name contains"; `%` and `_` are literal. */
|
|
531
|
+
q?: string;
|
|
532
|
+
/** @description createdAt (default) or name; anything else is 422 INVALID_ENUM. Validated in the handler. */
|
|
533
|
+
sort?: string;
|
|
534
|
+
/** @description asc or desc (default desc for createdAt, asc for name); anything else is 422 INVALID_ENUM. Validated in the handler. */
|
|
535
|
+
order?: string;
|
|
288
536
|
};
|
|
289
537
|
header?: never;
|
|
290
538
|
path?: never;
|
|
@@ -306,6 +554,43 @@ export interface operations {
|
|
|
306
554
|
500: components["responses"]["Problem"];
|
|
307
555
|
};
|
|
308
556
|
};
|
|
557
|
+
createIdea: {
|
|
558
|
+
parameters: {
|
|
559
|
+
query?: never;
|
|
560
|
+
header?: {
|
|
561
|
+
/** @description Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400). */
|
|
562
|
+
"X-CSRF-Token"?: components["parameters"]["CsrfToken"];
|
|
563
|
+
};
|
|
564
|
+
path?: never;
|
|
565
|
+
cookie?: never;
|
|
566
|
+
};
|
|
567
|
+
requestBody: {
|
|
568
|
+
content: {
|
|
569
|
+
"multipart/form-data": components["schemas"]["CreateIdeaForm"];
|
|
570
|
+
};
|
|
571
|
+
};
|
|
572
|
+
responses: {
|
|
573
|
+
/** @description Created; Location is /api/v1/ideas/{id} */
|
|
574
|
+
201: {
|
|
575
|
+
headers: {
|
|
576
|
+
Location?: string;
|
|
577
|
+
[name: string]: unknown;
|
|
578
|
+
};
|
|
579
|
+
content: {
|
|
580
|
+
"application/json": components["schemas"]["IdeaDetail"];
|
|
581
|
+
};
|
|
582
|
+
};
|
|
583
|
+
400: components["responses"]["Problem"];
|
|
584
|
+
401: components["responses"]["Problem"];
|
|
585
|
+
403: components["responses"]["Problem"];
|
|
586
|
+
409: components["responses"]["Problem"];
|
|
587
|
+
413: components["responses"]["Problem"];
|
|
588
|
+
415: components["responses"]["Problem"];
|
|
589
|
+
422: components["responses"]["Problem"];
|
|
590
|
+
500: components["responses"]["Problem"];
|
|
591
|
+
503: components["responses"]["Problem"];
|
|
592
|
+
};
|
|
593
|
+
};
|
|
309
594
|
getIdea: {
|
|
310
595
|
parameters: {
|
|
311
596
|
query?: never;
|
|
@@ -332,4 +617,37 @@ export interface operations {
|
|
|
332
617
|
500: components["responses"]["Problem"];
|
|
333
618
|
};
|
|
334
619
|
};
|
|
620
|
+
downloadSlides: {
|
|
621
|
+
parameters: {
|
|
622
|
+
query?: never;
|
|
623
|
+
header?: never;
|
|
624
|
+
path: {
|
|
625
|
+
/** @description A malformed ID answers the uniform 404, not 400. */
|
|
626
|
+
id: components["parameters"]["IdeaId"];
|
|
627
|
+
};
|
|
628
|
+
cookie?: never;
|
|
629
|
+
};
|
|
630
|
+
requestBody?: never;
|
|
631
|
+
responses: {
|
|
632
|
+
/** @description The stored file */
|
|
633
|
+
200: {
|
|
634
|
+
headers: {
|
|
635
|
+
/** @description attachment; filename="<ASCII fallback>"; filename*=UTF-8''<percent-encoded> (RFC 6266) */
|
|
636
|
+
"Content-Disposition"?: string;
|
|
637
|
+
"Content-Length"?: number;
|
|
638
|
+
"Cache-Control"?: "no-store";
|
|
639
|
+
"X-Content-Type-Options"?: "nosniff";
|
|
640
|
+
[name: string]: unknown;
|
|
641
|
+
};
|
|
642
|
+
content: {
|
|
643
|
+
"application/pdf": string;
|
|
644
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": string;
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
401: components["responses"]["Problem"];
|
|
648
|
+
404: components["responses"]["Problem"];
|
|
649
|
+
500: components["responses"]["Problem"];
|
|
650
|
+
503: components["responses"]["Problem"];
|
|
651
|
+
};
|
|
652
|
+
};
|
|
335
653
|
}
|
|
@@ -56,15 +56,38 @@ export const en = {
|
|
|
56
56
|
members: 'Members',
|
|
57
57
|
leaderEmpNo: 'Leader employee ID',
|
|
58
58
|
},
|
|
59
|
+
// Working drafts until the owner supplies official labels and Org full names (OI-17).
|
|
60
|
+
enums: {
|
|
61
|
+
category: { COOL_IDEA: 'Cool Idea Award', OUTSTANDING_IMPACT: 'Outstanding Impact Award' },
|
|
62
|
+
org: { AAID: 'AAID', BSID: 'BSID', ICSD: 'ICSD', TSID: 'TSID', PLED: 'PLED', DEID: 'DEID' },
|
|
63
|
+
direction: {
|
|
64
|
+
GREEN_MANUFACTURING: 'Green Manufacturing',
|
|
65
|
+
RESPONSIBLE_SUPPLY_CHAIN: 'Building a Responsible Supply Chain',
|
|
66
|
+
INCLUSIVE_WORKPLACE: 'Creating a Healthy and Inclusive Workplace',
|
|
67
|
+
TALENT_DEVELOPMENT: 'Talent Development',
|
|
68
|
+
CARING_FOR_DISADVANTAGED: 'Caring for the Disadvantaged',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
passwordRules: {
|
|
72
|
+
title: 'Your password must:',
|
|
73
|
+
LENGTH: 'be 12–64 characters long',
|
|
74
|
+
CHARSET: 'use only printable ASCII characters (no accents, CJK, emoji or tabs)',
|
|
75
|
+
COMPLEXITY: 'contain at least 3 of: upper case, lower case, digit, symbol (space counts)',
|
|
76
|
+
COMMON_PASSWORD: 'not be one of the 10,000 most common passwords',
|
|
77
|
+
KEYBOARD_RUN: 'not contain 4 or more adjacent keyboard keys (qwer, asdf, 1234)',
|
|
78
|
+
SEQUENCE: 'not contain 4 or more letters or digits in sequence (abcd, 4321)',
|
|
79
|
+
REPEAT: 'not repeat a character 3 or more times in a row (aaa)',
|
|
80
|
+
CONTAINS_EMP_NO: 'not contain your employee ID',
|
|
81
|
+
status: { met: 'met', unmet: 'not met', idle: 'not checked yet', server: 'checked when you submit' },
|
|
82
|
+
},
|
|
59
83
|
pages: {
|
|
60
84
|
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' },
|
|
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.' },
|
|
85
|
+
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…', retryIn: 'Try again in {{seconds}} s', canRetry: 'You can sign in again.', sessionEnded: 'Your session has ended. Please sign in again.' },
|
|
62
86
|
register: {
|
|
63
87
|
title: 'Create an account',
|
|
64
88
|
description: 'Enter your details below to register.',
|
|
65
89
|
empNoHint: '6 digits.',
|
|
66
90
|
emailHint: 'Letters, digits and symbols only, no spaces.',
|
|
67
|
-
passwordHint: '12–64 printable ASCII characters, with at least 3 of: upper case, lower case, digit, symbol.',
|
|
68
91
|
mismatch: 'The passwords do not match.',
|
|
69
92
|
haveAccount: 'Already registered?',
|
|
70
93
|
submit: 'Create account',
|
|
@@ -73,8 +96,26 @@ export const en = {
|
|
|
73
96
|
success: 'Your account was created. Sign in to continue.',
|
|
74
97
|
},
|
|
75
98
|
ideaList: { search: 'Search', apply: 'Apply filters', create: 'Create an Idea', empty: 'No Ideas yet.' },
|
|
76
|
-
ideaForm: {
|
|
77
|
-
|
|
99
|
+
ideaForm: {
|
|
100
|
+
slidesHint: 'PDF or PPTX, up to 5 MiB, named exactly as shown above.',
|
|
101
|
+
choose: 'Choose…',
|
|
102
|
+
leaderHint: 'You are the Leader of the Idea you create.',
|
|
103
|
+
nameHint: 'The characters / \\ : * ? " < > | are not allowed.',
|
|
104
|
+
count: '{{n}} / {{max}} characters',
|
|
105
|
+
expectedTitle: 'Expected file name',
|
|
106
|
+
expectedPending: 'Choose a category and an organization and enter the Idea name to see the expected file name.',
|
|
107
|
+
expectedHint: 'Name your slide file exactly like this before you choose it. The file is not renamed for you.',
|
|
108
|
+
copy: 'Copy the {{ext}} file name',
|
|
109
|
+
copied: 'File name copied.',
|
|
110
|
+
copyFailed: 'The file name could not be copied. Select it and copy it by hand.',
|
|
111
|
+
expectedIs: 'Expected: {{name}}',
|
|
112
|
+
},
|
|
113
|
+
ideaCreate: {
|
|
114
|
+
title: 'Create an Idea',
|
|
115
|
+
submit: 'Submit Idea',
|
|
116
|
+
submitting: 'Submitting…',
|
|
117
|
+
closedHint: 'Submissions are not open, so the Idea cannot be submitted now.',
|
|
118
|
+
},
|
|
78
119
|
ideaEdit: { title: 'Edit Idea' },
|
|
79
120
|
ideaDetail: {
|
|
80
121
|
title: 'Idea',
|
|
@@ -53,15 +53,38 @@ export const zhTW: Dictionary = {
|
|
|
53
53
|
members: '成員',
|
|
54
54
|
leaderEmpNo: '組長員工編號',
|
|
55
55
|
},
|
|
56
|
+
// 正式名稱由負責人提供前的暫定標籤(OI-17)。
|
|
57
|
+
enums: {
|
|
58
|
+
category: { COOL_IDEA: '酷點子獎', OUTSTANDING_IMPACT: '卓越影響獎' },
|
|
59
|
+
org: { AAID: 'AAID', BSID: 'BSID', ICSD: 'ICSD', TSID: 'TSID', PLED: 'PLED', DEID: 'DEID' },
|
|
60
|
+
direction: {
|
|
61
|
+
GREEN_MANUFACTURING: '綠色製造',
|
|
62
|
+
RESPONSIBLE_SUPPLY_CHAIN: '打造負責任供應鏈',
|
|
63
|
+
INCLUSIVE_WORKPLACE: '營造健康共融職場',
|
|
64
|
+
TALENT_DEVELOPMENT: '人才培育',
|
|
65
|
+
CARING_FOR_DISADVANTAGED: '關懷弱勢',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
passwordRules: {
|
|
69
|
+
title: '密碼必須:',
|
|
70
|
+
LENGTH: '長度為 12–64 個字元',
|
|
71
|
+
CHARSET: '僅使用可列印的 ASCII 字元(不可含重音字母、中日韓文字、表情符號或 Tab)',
|
|
72
|
+
COMPLEXITY: '至少包含大寫、小寫、數字、符號其中 3 類(空白算符號)',
|
|
73
|
+
COMMON_PASSWORD: '不可是最常見的 10,000 個密碼之一',
|
|
74
|
+
KEYBOARD_RUN: '不可包含 4 個以上相鄰的鍵盤按鍵(qwer、asdf、1234)',
|
|
75
|
+
SEQUENCE: '不可包含 4 個以上連續的字母或數字(abcd、4321)',
|
|
76
|
+
REPEAT: '同一字元不可連續出現 3 次以上(aaa)',
|
|
77
|
+
CONTAINS_EMP_NO: '不可包含您的員工編號',
|
|
78
|
+
status: { met: '已符合', unmet: '未符合', idle: '尚未檢查', server: '送出時檢查' },
|
|
79
|
+
},
|
|
56
80
|
pages: {
|
|
57
81
|
idle: { title: '您還在嗎?', body: '您已閒置一段時間。為了安全,若不繼續使用,您的登入將在約 2 分鐘後結束。', continue: '繼續' },
|
|
58
|
-
login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', sessionEnded: '您的登入已結束,請重新登入。' },
|
|
82
|
+
login: { title: '登入您的帳號', description: '請輸入員工編號與密碼。', noAccount: '還沒有帳號?', panelTitle: 'ESG 創意競賽', panelBody: '註冊、組隊並提交您的創意。', submitting: '登入中…', retryIn: '{{seconds}} 秒後可再試', canRetry: '您可以再次登入。', sessionEnded: '您的登入已結束,請重新登入。' },
|
|
59
83
|
register: {
|
|
60
84
|
title: '建立帳號',
|
|
61
85
|
description: '請在下方輸入資料以完成註冊。',
|
|
62
86
|
empNoHint: '6 位數字。',
|
|
63
87
|
emailHint: '僅限英數字與符號,不可包含空白。',
|
|
64
|
-
passwordHint: '12–64 個可列印 ASCII 字元,且至少包含大寫、小寫、數字、符號其中 3 類。',
|
|
65
88
|
mismatch: '兩次輸入的密碼不一致。',
|
|
66
89
|
haveAccount: '已經註冊?',
|
|
67
90
|
submit: '建立帳號',
|
|
@@ -70,8 +93,26 @@ export const zhTW: Dictionary = {
|
|
|
70
93
|
success: '帳號已建立,請登入。',
|
|
71
94
|
},
|
|
72
95
|
ideaList: { search: '搜尋', apply: '套用篩選', create: '建立提案', empty: '目前沒有提案。' },
|
|
73
|
-
ideaForm: {
|
|
74
|
-
|
|
96
|
+
ideaForm: {
|
|
97
|
+
slidesHint: 'PDF 或 PPTX,最大 5 MiB,檔名須與上方完全相同。',
|
|
98
|
+
choose: '請選擇…',
|
|
99
|
+
leaderHint: '您將成為所建立提案的組長。',
|
|
100
|
+
nameHint: '不可包含 / \\ : * ? " < > | 等字元。',
|
|
101
|
+
count: '{{n}} / {{max}} 字',
|
|
102
|
+
expectedTitle: '應使用的檔名',
|
|
103
|
+
expectedPending: '選擇獎項類別與單位並輸入提案名稱後,即會顯示應使用的檔名。',
|
|
104
|
+
expectedHint: '請在選擇檔案前,將簡報檔案命名為下列名稱。系統不會自動為您改名。',
|
|
105
|
+
copy: '複製 {{ext}} 檔名',
|
|
106
|
+
copied: '已複製檔名。',
|
|
107
|
+
copyFailed: '無法複製檔名,請手動選取後複製。',
|
|
108
|
+
expectedIs: '應為:{{name}}',
|
|
109
|
+
},
|
|
110
|
+
ideaCreate: {
|
|
111
|
+
title: '建立提案',
|
|
112
|
+
submit: '送出提案',
|
|
113
|
+
submitting: '送出中…',
|
|
114
|
+
closedHint: '目前不在收件期間,暫時無法送出提案。',
|
|
115
|
+
},
|
|
75
116
|
ideaEdit: { title: '編輯提案' },
|
|
76
117
|
ideaDetail: {
|
|
77
118
|
title: '提案',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Slide rules of PRD §6.8 (Frontend.md §5.5). The back end implements the same
|
|
2
|
+
// rules (backend/internal/storage); both run testdata/expected-filename.json.
|
|
3
|
+
// The server is the final judge: this is a pre-check that saves a doomed upload.
|
|
4
|
+
|
|
5
|
+
export const CATEGORIES = ['COOL_IDEA', 'OUTSTANDING_IMPACT'] as const;
|
|
6
|
+
export type Category = (typeof CATEGORIES)[number];
|
|
7
|
+
|
|
8
|
+
/** 5 MiB, inclusive (PRD §6.8). */
|
|
9
|
+
export const MAX_SLIDES_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
/** The longest expected filename in UTF-8 bytes (PRD §6.2), measured with `.pptx`. */
|
|
11
|
+
export const MAX_FILENAME_BYTES = 255;
|
|
12
|
+
const LONGEST_EXT = '.pptx';
|
|
13
|
+
|
|
14
|
+
export const SLIDE_EXTS = ['pdf', 'pptx'] as const;
|
|
15
|
+
export type SlideExt = (typeof SLIDE_EXTS)[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `{I|O}_{org}_{leaderName}_{leaderEmpNo}_{ideaName}` without extension, by plain
|
|
19
|
+
* concatenation, NFC-normalized. Inputs are the cleaned values.
|
|
20
|
+
*/
|
|
21
|
+
export function expectedFilenameBase(category: Category, org: string, leaderName: string, leaderEmpNo: string, ideaName: string): string {
|
|
22
|
+
const prefix = category === 'COOL_IDEA' ? 'I' : 'O';
|
|
23
|
+
return `${prefix}_${org}_${leaderName}_${leaderEmpNo}_${ideaName}`.normalize('NFC');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function utf8Bytes(s: string): number {
|
|
27
|
+
return new TextEncoder().encode(s).length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when base + `.pptx` exceeds 255 UTF-8 bytes → `FILENAME_TOO_LONG` (PRD §6.6). */
|
|
31
|
+
export function filenameTooLong(base: string): boolean {
|
|
32
|
+
return utf8Bytes(base + LONGEST_EXT) > MAX_FILENAME_BYTES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SlidesProblem = '' | 'REQUIRED' | 'FILE_TOO_LARGE' | 'EMPTY_FILE' | 'UNSUPPORTED_FILE_TYPE' | 'FILENAME_MISMATCH';
|
|
36
|
+
|
|
37
|
+
/** The part after the last dot, lower-cased; '' when there is none. */
|
|
38
|
+
function extOf(name: string): string {
|
|
39
|
+
const dot = name.lastIndexOf('.');
|
|
40
|
+
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
|
|
44
|
+
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // PK\3\4
|
|
45
|
+
|
|
46
|
+
const startsWith = (b: Uint8Array, magic: number[]) => magic.every((m, i) => b[i] === m);
|
|
47
|
+
|
|
48
|
+
async function head(file: Blob, n: number): Promise<Uint8Array> {
|
|
49
|
+
return new Uint8Array(await file.slice(0, n).arrayBuffer());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pre-check in the server's order (PRD §6.8): count, size, not empty, magic bytes
|
|
54
|
+
* with an agreeing extension, filename. Stops at the first failing step.
|
|
55
|
+
* `expectedBase` is null while the text fields cannot produce one; the filename
|
|
56
|
+
* step is then skipped (the server does not validate the file when text is invalid).
|
|
57
|
+
*
|
|
58
|
+
* The client only checks the ZIP signature for a PPTX; the central directory
|
|
59
|
+
* check stays with the server.
|
|
60
|
+
*/
|
|
61
|
+
export async function precheckSlides(files: readonly File[], expectedBase: string | null): Promise<SlidesProblem> {
|
|
62
|
+
if (files.length !== 1) return 'REQUIRED';
|
|
63
|
+
const file = files[0]!;
|
|
64
|
+
if (file.size > MAX_SLIDES_BYTES) return 'FILE_TOO_LARGE';
|
|
65
|
+
if (file.size === 0) return 'EMPTY_FILE';
|
|
66
|
+
const name = file.name.normalize('NFC');
|
|
67
|
+
const ext = extOf(name);
|
|
68
|
+
const bytes = await head(file, PDF_MAGIC.length);
|
|
69
|
+
const typeOk = (ext === 'pdf' && startsWith(bytes, PDF_MAGIC)) || (ext === 'pptx' && startsWith(bytes, ZIP_MAGIC));
|
|
70
|
+
if (!typeOk) return 'UNSUPPORTED_FILE_TYPE';
|
|
71
|
+
if (expectedBase === null) return '';
|
|
72
|
+
const base = name.slice(0, name.length - ext.length - 1);
|
|
73
|
+
return base === expectedBase.normalize('NFC') ? '' : 'FILENAME_MISMATCH';
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fixture from 'testdata/expected-filename.json';
|
|
2
|
+
import { expectedFilenameBase, filenameTooLong, MAX_SLIDES_BYTES, precheckSlides, utf8Bytes, type Category } from './index';
|
|
3
|
+
|
|
4
|
+
describe('expectedFilenameBase (testdata/expected-filename.json)', () => {
|
|
5
|
+
it.each(fixture.cases.map((c) => [c.id, c] as const))('%s', (_id, c) => {
|
|
6
|
+
const base = expectedFilenameBase(c.category as Category, c.org, c.leaderName, c.leaderEmpNo, c.ideaName);
|
|
7
|
+
expect(base).toBe(c.wantBase);
|
|
8
|
+
expect(utf8Bytes(base + '.pptx')).toBe(c.wantBytes);
|
|
9
|
+
expect(filenameTooLong(base)).toBe(c.wantTooLong);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const PDF = '%PDF-1.7\n';
|
|
14
|
+
const ZIP = 'PK\u0003\u0004rest';
|
|
15
|
+
const BASE = 'I_BSID_王小明_089290_成立供應商永續學院';
|
|
16
|
+
|
|
17
|
+
const file = (name: string, content: string | Uint8Array = PDF) => new File([content as BlobPart], name);
|
|
18
|
+
|
|
19
|
+
describe('precheckSlides (PRD §6.8 order)', () => {
|
|
20
|
+
it.each([
|
|
21
|
+
['no file', [], 'REQUIRED'],
|
|
22
|
+
['two files', [file(`${BASE}.pdf`), file(`${BASE}.pdf`)], 'REQUIRED'],
|
|
23
|
+
['empty', [file(`${BASE}.pdf`, '')], 'EMPTY_FILE'],
|
|
24
|
+
['PDF bytes, .pptx name', [file(`${BASE}.pptx`)], 'UNSUPPORTED_FILE_TYPE'],
|
|
25
|
+
['ZIP bytes, .pdf name', [file(`${BASE}.pdf`, ZIP)], 'UNSUPPORTED_FILE_TYPE'],
|
|
26
|
+
['.docx', [file(`${BASE}.docx`, ZIP)], 'UNSUPPORTED_FILE_TYPE'],
|
|
27
|
+
['no extension', [file(BASE)], 'UNSUPPORTED_FILE_TYPE'],
|
|
28
|
+
['wrong name', [file('slides.pdf')], 'FILENAME_MISMATCH'],
|
|
29
|
+
['name with a path', [file(`dir/${BASE}.pdf`)], 'FILENAME_MISMATCH'],
|
|
30
|
+
['PDF ok', [file(`${BASE}.pdf`)], ''],
|
|
31
|
+
['PPTX ok, upper-case extension', [file(`${BASE}.PPTX`, ZIP)], ''],
|
|
32
|
+
['NFD filename (macOS) matches', [file('I_ICSD_Zoe_000002_Café.pdf')], ''],
|
|
33
|
+
] as const)('%s → %j', async (_n, files, want) => {
|
|
34
|
+
const base = files[0]?.name.startsWith('I_ICSD') ? 'I_ICSD_Zoe_000002_Café' : BASE;
|
|
35
|
+
expect(await precheckSlides(files, base)).toBe(want);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('size is checked before emptiness and type; 5 MiB is allowed', async () => {
|
|
39
|
+
const big = file(`${BASE}.pdf`);
|
|
40
|
+
Object.defineProperty(big, 'size', { value: MAX_SLIDES_BYTES + 1 });
|
|
41
|
+
expect(await precheckSlides([big], BASE)).toBe('FILE_TOO_LARGE');
|
|
42
|
+
const exact = new File([PDF, new Uint8Array(MAX_SLIDES_BYTES - PDF.length)], `${BASE}.pdf`);
|
|
43
|
+
expect(exact.size).toBe(MAX_SLIDES_BYTES);
|
|
44
|
+
expect(await precheckSlides([exact], BASE)).toBe('');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('skips the filename step while no expected name exists', async () => {
|
|
48
|
+
expect(await precheckSlides([file('anything.pdf')], null)).toBe('');
|
|
49
|
+
expect(await precheckSlides([file('anything.pptx')], null)).toBe('UNSUPPORTED_FILE_TYPE');
|
|
50
|
+
});
|
|
51
|
+
});
|