Skip to content

Commit 17991db

Browse files
committed
audit: Phase 1 fixes — shared STEPS, rate limits, a11y, bug fixes (42 items)
- Extract shared INTAKE_STEPS constant, unify all 12 intake pages - Fix summary progress dots (all showed "done", none "active") - Define missing CSS vars: --sand-100, --sky-400, --peach-200 - Fix next.config.ts misleading comment about env var baking - Add rate limiting to feed POST, sync, and PDF routes - Sanitize childName in PDF generation route - Replace emoji theme toggles with ThemeToggle component (11 pages) - Add ARIA: SkipStageDialog, UserMenu, BottomNav, game buttons - Add keyboard support: Escape on dialogs, Space on gender cards - Fix landing page inverted subtitle for authenticated users - Fix report page localStorage key mismatch (underscores to hyphens) - Add structured logging to auth module (config, dynamodb, session) - Improve sync retry: stop on 401/400, only retry on 5xx - Fix logout fetch redirect waste (redirect: manual) - Add feed page error feedback on create/react/delete failures - Add vitest @/ path alias, add test:unit to amplify.yml preBuild
1 parent abb919f commit 17991db

31 files changed

Lines changed: 212 additions & 142 deletions

File tree

amplify.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ frontend:
55
commands:
66
- nvm use 22
77
- npm ci
8+
- npm run test:unit
89
build:
910
commands:
1011
- npm run build

app/api/feed/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export async function POST(request: NextRequest) {
118118
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
119119
}
120120

121+
const { apiRateLimiter } = await import("@/app/lib/rateLimit");
122+
const rl = apiRateLimiter.check(`feed-post:${user.id}`);
123+
if (!rl.allowed) return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
124+
121125
try {
122126
const body = await request.json();
123127
const { action } = body;

app/api/report/pdf/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ export async function POST(req: NextRequest) {
215215
const authResult = await requireApiAuth(req);
216216
if (authResult instanceof NextResponse) return authResult;
217217

218+
const { aiRateLimiter } = await import("../../../lib/rateLimit");
219+
const rl = aiRateLimiter.check(authResult.id);
220+
if (!rl.allowed) return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
221+
218222
let body: PdfRequestBody;
219223

220224
try {
@@ -230,7 +234,8 @@ export async function POST(req: NextRequest) {
230234
);
231235
}
232236

233-
const { report, childName, sessionDate, scores, childAge, assessmentDuration } = body;
237+
const { report, sessionDate, scores, childAge, assessmentDuration } = body;
238+
const childName = String(body.childName || "Child").slice(0, 100).replace(/[^\p{L}\p{N}\s'-]/gu, "");
234239

235240
try {
236241
const pdfDoc = await PDFDocument.create();

app/api/sync/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export async function POST(req: NextRequest) {
4444
const authResult = await requireApiAuth(req);
4545
if (authResult instanceof NextResponse) return authResult;
4646

47+
const { apiRateLimiter } = await import("../../lib/rateLimit");
48+
const rl = apiRateLimiter.check(`sync:${authResult.id}`);
49+
if (!rl.allowed) return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
50+
4751
let body: SyncRequestBody;
4852

4953
try {

app/components/BottomNav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default function BottomNav() {
1717

1818
return (
1919
<nav
20+
aria-label="Bottom navigation"
2021
style={{
2122
position: "fixed",
2223
bottom: 0,

app/components/SkipStageDialog.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ export default function SkipStageDialog({ onConfirm }: SkipStageDialogProps) {
3030
}
3131

3232
return (
33-
<div style={{
33+
<div
34+
role="dialog"
35+
aria-modal="true"
36+
aria-labelledby="skip-dialog-title"
37+
tabIndex={-1}
38+
onKeyDown={(e) => { if (e.key === "Escape") setShowConfirm(false); }}
39+
style={{
3440
position: "fixed",
3541
top: 0, left: 0, right: 0, bottom: 0,
3642
background: "rgba(0,0,0,0.4)",
@@ -43,7 +49,7 @@ export default function SkipStageDialog({ onConfirm }: SkipStageDialogProps) {
4349
maxWidth: 380,
4450
textAlign: "center",
4551
}}>
46-
<h3 style={{
52+
<h3 id="skip-dialog-title" style={{
4753
fontFamily: "'Fredoka',sans-serif",
4854
fontWeight: 600,
4955
fontSize: "1.1rem",
@@ -64,6 +70,7 @@ export default function SkipStageDialog({ onConfirm }: SkipStageDialogProps) {
6470
<button
6571
className="btn btn-outline"
6672
onClick={() => setShowConfirm(false)}
73+
autoFocus
6774
style={{ minHeight: 40, padding: "8px 20px" }}
6875
>
6976
Cancel

app/components/UserMenu.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export default function UserMenu() {
2626
<button
2727
onClick={() => setOpen((o) => !o)}
2828
aria-label="User menu"
29+
aria-expanded={open}
30+
aria-haspopup="true"
2931
style={{
3032
display: "flex",
3133
alignItems: "center",
@@ -78,6 +80,8 @@ export default function UserMenu() {
7880
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.18)", zIndex: 199 }}
7981
/>
8082
<div
83+
role="menu"
84+
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
8185
style={{
8286
position: "absolute",
8387
top: "calc(100% + 6px)",
@@ -117,6 +121,7 @@ export default function UserMenu() {
117121
</div>
118122
</div>
119123
<button
124+
role="menuitem"
120125
onClick={() => {
121126
setOpen(false);
122127
logout();

app/contexts/AuthContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
5757
await fetch("/api/auth/logout", {
5858
method: "POST",
5959
credentials: "include",
60+
redirect: "manual",
6061
});
6162
} catch {
6263
// Ignore network errors — still clear local state

app/feed/page.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default function FeedPage() {
5656
const [loading, setLoading] = useState(true);
5757
const [showCompose, setShowCompose] = useState(false);
5858
const [anonymous, setAnonymous] = useState(true);
59+
const [feedError, setFeedError] = useState<string | null>(null);
5960

6061
// Use user ID from auth context (no duplicate fetch)
6162
const userId = user?.id || "";
@@ -93,11 +94,12 @@ export default function FeedPage() {
9394
headers: { "Content-Type": "application/json" },
9495
body: JSON.stringify({ content: content.trim(), category, anonymous }),
9596
});
97+
setFeedError(null);
9698
setContent("");
9799
setShowCompose(false);
98100
await loadPosts();
99101
} catch {
100-
// Failed to create post
102+
setFeedError("Something went wrong. Please try again.");
101103
} finally {
102104
setPosting(false);
103105
}
@@ -110,9 +112,10 @@ export default function FeedPage() {
110112
headers: { "Content-Type": "application/json" },
111113
body: JSON.stringify({ action: "react", postId: post.postId, createdAt: post.createdAt, type }),
112114
});
115+
setFeedError(null);
113116
await loadPosts();
114117
} catch {
115-
// Failed to toggle reaction
118+
setFeedError("Something went wrong. Please try again.");
116119
}
117120
};
118121

@@ -123,9 +126,10 @@ export default function FeedPage() {
123126
headers: { "Content-Type": "application/json" },
124127
body: JSON.stringify({ action: "delete", postId: post.postId, createdAt: post.createdAt }),
125128
});
129+
setFeedError(null);
126130
await loadPosts();
127131
} catch {
128-
// Failed to delete
132+
setFeedError("Something went wrong. Please try again.");
129133
}
130134
};
131135

@@ -290,6 +294,14 @@ export default function FeedPage() {
290294
</div>
291295
)}
292296

297+
{/* Error Banner */}
298+
{feedError && (
299+
<div style={{ padding: "10px 16px", borderRadius: 12, background: "var(--peach-100)", color: "var(--text-primary)", fontSize: "0.85rem", marginBottom: 12, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
300+
<span>{feedError}</span>
301+
<button onClick={() => setFeedError(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)", fontSize: "1rem" }}>&#10005;</button>
302+
</div>
303+
)}
304+
293305
{/* Category Filter */}
294306
<div
295307
className="fade fade-2"

app/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@
2929

3030
--sky-100: #e8f0fb;
3131
--sky-300: #93b8ef;
32+
--sky-400: #5a9be6;
3233
--peach-100: #fce8df;
34+
--peach-200: #f8cbb8;
3335
--peach-300: #f0a882;
36+
--sand-100: #f5efe6;
3437
--lavender-100: #ede8f8;
3538

3639
--feature-green: #e3ede6;
@@ -77,8 +80,11 @@
7780

7881
--sky-100: #0f1e2e;
7982
--sky-300: #4a7eb5;
83+
--sky-400: #6da0d0;
8084
--peach-100: #2a1510;
85+
--peach-200: #6e3525;
8186
--peach-300: #c4754a;
87+
--sand-100: #1e1a14;
8288
--lavender-100: #1a152a;
8389

8490
--feature-green: #152a1a;

0 commit comments

Comments
 (0)