Skip to content

Commit d2a6de3

Browse files
devakesuCopilot
andauthored
v1.9.4 (#470)
* fix: session cleanup, delete account storage, stale exam cache, and UI polish (v1.9.4) fix(auth): remove direct storage.objects DML from delete_user_account RPC Supabase blocks DELETE FROM storage.objects directly; extract avatar cleanup to the client using the Storage JS API before calling the RPC. Adds migration 20260223000001_fix_delete_user_account_storage.sql. fix(session): clear sb-* Supabase auth cookie on all logout paths - /api/logout: now expires sb-{projectRef}-auth-token and .0/.1 chunks - proxy Scenario A: unauthenticated redirect now deletes the sb-* cookie - proxy terms loop: was redirecting to /logout (404); now redirects to / with full cookie clearing (ezygo_access_token, terms_version, csrf_token, terms_redirect_count, sb-*) - login-form: clears localStorage and prefetchedSettings from sessionStorage on mount when no active session is detected (csrf_token_memory preserved) fix(scores): invalidate exam query keys on semester/year change useSetSemester and useSetAcademicYear mutations now invalidate ["exams"], ["exam-answers"], and ["exam-questions"] in their onSuccess handlers, preventing stale data on the /scores page after settings changes. feat(loading): add optional message prop to Loading component Renders a muted caption below the spinner when minimal={true}, used on the scores page: "Waiting on Ezygo to stop ghosting us 👻" fix(notifications): fix empty state vertical spacing Replace py-24 / flex-1 (too tall) with min-h-[50vh] on the empty state container for balanced proportional spacing. * fix: PR review feedback — loading JSDoc, delete-account storage timeout, proxy cookie helper, logout secure flag (#471) * Initial plan * fix: apply PR review feedback (loading JSDoc, delete-account timeout, proxy helper, logout secure flag) Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> * fix: paginate avatar cleanup, use logger, guard storage clear on lock timeout (#472) * Initial plan * fix: paginate avatar list in delete-account, use logger, guard storage clear on lock timeout Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> * fix(delete-account): wrap avatar storage cleanup in try/catch; add login-form storage cleanup tests (#473) * Initial plan * fix(delete-account): wrap avatar storage cleanup in own try/catch; add login-form storage cleanup tests Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> * fix(logout): clear terms_redirect_count on logout; fix proxy comment (#474) * Initial plan * fix(logout): clear terms_redirect_count on logout and fix proxy comment Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devakesu <61821107+devakesu@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 8365085 commit d2a6de3

15 files changed

Lines changed: 327 additions & 32 deletions

File tree

.example.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass
4141
# (calculate-version job). A GitHub Secret here would always be stale after
4242
# an auto-version bump. Keep in sync with package.json for local dev only.
4343
# 🔨 Build-time (auto-derived from git tag by pipeline — not a GitHub Secret)
44-
NEXT_PUBLIC_APP_VERSION=1.9.3
44+
NEXT_PUBLIC_APP_VERSION=1.9.4
4545

4646
# ⚠️ Your production domain WITHOUT https://
4747
# All URL-based variables are derived from this.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ghostclass",
3-
"version": "1.9.3",
3+
"version": "1.9.4",
44
"private": true,
55
"engines": {
66
"node": "^20.19.0 || >=22.12.0",

public/api-docs/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ openapi: 3.1.0
55

66
info:
77
title: GhostClass API
8-
version: 1.9.3
8+
version: 1.9.4
99
description: |
1010
**GhostClass API** provides endpoints for managing attendance synchronization with EzyGo.
1111

src/app/(protected)/notifications/NotificationsClient.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export default function NotificationsPage() {
249249
const isEmpty = virtualItems.length === 0;
250250

251251
return (
252-
<div ref={parentRef} className="bg-background relative overflow-auto">
252+
<div ref={parentRef} className="bg-background relative overflow-auto flex flex-col">
253253
<header className="sticky top-0 z-20 w-full backdrop-blur-xl bg-background/80 border-b border-border/40">
254254
<div className="container mx-auto max-w-2xl px-4 pt-6 pb-4 flex items-center justify-between">
255255
<div className="flex items-center gap-3">
@@ -265,9 +265,9 @@ export default function NotificationsPage() {
265265
</div>
266266
</header>
267267

268-
<main className="container mx-auto max-w-2xl">
268+
<main className="container mx-auto max-w-2xl flex-1 flex flex-col">
269269
{isEmpty ? (
270-
<div className="flex flex-col items-center justify-center py-24 text-center">
270+
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
271271
<div className="h-20 w-20 rounded-full bg-muted/30 flex items-center justify-center mb-4">
272272
<BellOff className="h-9 w-9 text-muted-foreground/50" aria-hidden="true"/>
273273
</div>

src/app/(protected)/scores/ScoresClient.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ function ExamDetailDrawer({
525525
{/* Loading */}
526526
{isLoading && (
527527
<div className="flex items-center justify-center py-12">
528-
<Loading minimal />
528+
<Loading minimal message="Waiting on Ezygo to stop ghosting us 👻" />
529529
</div>
530530
)}
531531

@@ -764,7 +764,7 @@ export default function ScoresClient() {
764764
if (isLoading || (!exams && !isError)) {
765765
return (
766766
<div className="flex-1 flex items-center justify-center p-8">
767-
<Loading minimal />
767+
<Loading minimal message="Waiting on Ezygo to stop ghosting us 👻" />
768768
</div>
769769
);
770770
}

src/app/actions/user.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,22 @@ export async function clearTermsVersionCookie() {
105105
secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production',
106106
httpOnly: true,
107107
});
108+
}
109+
110+
/**
111+
* Server action to clear the terms_redirect_count cookie.
112+
* Should be called during logout to reset the redirect loop protection
113+
* counter so a subsequent user on the same browser starts from zero.
114+
*/
115+
export async function clearTermsRedirectCountCookie() {
116+
const cookieStore = await cookies();
117+
cookieStore.set({
118+
name: "terms_redirect_count",
119+
value: "",
120+
path: "/",
121+
maxAge: 0,
122+
sameSite: "strict",
123+
secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production',
124+
httpOnly: true,
125+
});
108126
}

src/app/api/logout/route.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { cookies } from "next/headers";
23
import { clearAuthCookie } from "@/lib/security/auth-cookie";
34
import { removeCsrfToken, validateCsrfToken } from "@/lib/security/csrf";
4-
import { clearTermsVersionCookie } from "@/app/actions/user";
5+
import { clearTermsVersionCookie, clearTermsRedirectCountCookie } from "@/app/actions/user";
56

67
export async function POST(req: NextRequest) {
78
// CSRF protection: Prevent unauthorized logout attacks
@@ -20,5 +21,43 @@ export async function POST(req: NextRequest) {
2021
await clearAuthCookie();
2122
await removeCsrfToken();
2223
await clearTermsVersionCookie();
24+
await clearTermsRedirectCountCookie();
25+
26+
// Clear the Supabase SSR session cookie (@supabase/ssr sets this as
27+
// sb-{project-ref}-auth-token). On account deletion supabase.auth.signOut()
28+
// fails client-side because the auth user is already gone, so this cookie
29+
// would otherwise remain. We derive the name from NEXT_PUBLIC_SUPABASE_URL
30+
// which is always available in the server runtime.
31+
try {
32+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
33+
// URL pattern: https://{project-ref}.supabase.co
34+
const projectRef = new URL(supabaseUrl).hostname.split(".")[0];
35+
if (projectRef) {
36+
const cookieStore = await cookies();
37+
const cookieName = `sb-${projectRef}-auth-token`;
38+
cookieStore.set(cookieName, "", {
39+
path: "/",
40+
expires: new Date(0),
41+
sameSite: "lax",
42+
secure: process.env.HTTPS === "true" || process.env.NODE_ENV === "production",
43+
});
44+
// @supabase/ssr may also write a chunked variant: sb-{ref}-auth-token.0, .1 …
45+
// Clear any chunks present in the current request cookies.
46+
const allCookies = cookieStore.getAll();
47+
for (const c of allCookies) {
48+
if (c.name.startsWith(`${cookieName}.`)) {
49+
cookieStore.set(c.name, "", {
50+
path: "/",
51+
expires: new Date(0),
52+
sameSite: "lax",
53+
secure: process.env.HTTPS === "true" || process.env.NODE_ENV === "production",
54+
});
55+
}
56+
}
57+
}
58+
} catch {
59+
// Non-critical: if URL parsing fails the cookie just expires naturally
60+
}
61+
2362
return NextResponse.json({ ok: true });
2463
}

src/components/loading.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ import { handleLogout } from "@/lib/security/auth";
2121
* @param minimal - When true, hides the timeout warning and action buttons.
2222
* Use this when Loading is embedded inside a component (e.g. a chart)
2323
* rather than as a full-page loader.
24+
* @param message - Optional caption rendered below the spinner when `minimal={true}`.
25+
* Has no effect when `minimal` is false or omitted.
2426
*
2527
* @example
2628
* ```tsx
27-
* <Loading /> // full-page with logout/refresh buttons after 15s
28-
* <Loading minimal /> // spinner + message only, no buttons
29+
* <Loading /> // full-page with logout/refresh buttons after 15s
30+
* <Loading minimal /> // spinner only, no caption, no buttons
31+
* <Loading minimal message="Loading data…" /> // spinner + caption, no buttons
2932
* ```
3033
*/
31-
export function Loading({ minimal = false }: { minimal?: boolean }) {
34+
export function Loading({ minimal = false, message }: { minimal?: boolean; message?: string }) {
3235
const [showWarning, setShowWarning] = useState(false);
3336

3437
useEffect(() => {
@@ -58,6 +61,10 @@ export function Loading({ minimal = false }: { minimal?: boolean }) {
5861
<div className="flex flex-col items-center gap-6 text-center max-w-md">
5962
<Ring2 size="45" stroke="4" speed="1" color="#3b82f6" aria-hidden="true" />
6063

64+
{minimal && message && (
65+
<p className="text-sm text-muted-foreground">{message}</p>
66+
)}
67+
6168
{!minimal && (
6269
<div className="space-y-2">
6370
<p className="text-lg font-medium text-muted-foreground italic leading-relaxed">

src/components/user/__tests__/login-form.test.tsx

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React from "react";
44

55
// vi.mock factories are hoisted to the top of the file, so variables used inside
66
// them must also be hoisted via vi.hoisted().
7-
const { mockNProgressStart, mockNProgressDone, mockAxiosPost, mockRouter } = vi.hoisted(() => {
7+
const { mockNProgressStart, mockNProgressDone, mockAxiosPost, mockRouter, mockGetSession } = vi.hoisted(() => {
88
const push = vi.fn();
99
return {
1010
mockNProgressStart: vi.fn(),
@@ -13,6 +13,8 @@ const { mockNProgressStart, mockNProgressDone, mockAxiosPost, mockRouter } = vi.
1313
// A single stable router object prevents useEffect([router, supabase]) from
1414
// re-running on every render (which would cause an infinite loop in tests).
1515
mockRouter: { push, replace: vi.fn(), prefetch: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn() },
16+
// Hoisted so it can be used inside the vi.mock factory for @/lib/supabase/client.
17+
mockGetSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
1618
};
1719
});
1820

@@ -27,6 +29,26 @@ vi.mock("next/navigation", () => ({
2729
useParams: () => ({}),
2830
}));
2931

32+
// --- Supabase client mock (overrides vitest.setup.ts global) ---
33+
// Uses the hoisted mockGetSession so individual tests can control getSession behavior.
34+
vi.mock("@/lib/supabase/client", () => ({
35+
createClient: vi.fn(() => ({
36+
auth: {
37+
getSession: mockGetSession,
38+
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
39+
signOut: vi.fn().mockResolvedValue({ error: null }),
40+
},
41+
from: vi.fn(() => ({
42+
select: vi.fn().mockReturnThis(),
43+
insert: vi.fn().mockReturnThis(),
44+
update: vi.fn().mockReturnThis(),
45+
delete: vi.fn().mockReturnThis(),
46+
eq: vi.fn().mockReturnThis(),
47+
single: vi.fn(() => Promise.resolve({ data: null, error: null })),
48+
})),
49+
})),
50+
}));
51+
3052
// --- NProgress mock ---
3153
vi.mock("nprogress", () => ({
3254
default: { start: mockNProgressStart, done: mockNProgressDone },
@@ -78,6 +100,7 @@ vi.mock("@/lib/logger", () => ({
78100
// --- auth helper ---
79101
vi.mock("@/lib/security/auth", () => ({
80102
isAuthSessionMissingError: vi.fn().mockReturnValue(false),
103+
isSupabaseLockTimeoutError: vi.fn().mockReturnValue(false),
81104
}));
82105

83106
// --- Password-reset form ---
@@ -96,6 +119,7 @@ vi.mock("@/lib/security/csrf-constants", () => ({
96119
}));
97120

98121
import { LoginForm } from "../login-form";
122+
import { isAuthSessionMissingError, isSupabaseLockTimeoutError } from "@/lib/security/auth";
99123

100124
// ---------------------------------------------------------------------------
101125
// Helpers
@@ -124,6 +148,11 @@ function fillValidForm(passwordInput: HTMLElement) {
124148
describe("LoginForm – NProgress integration", () => {
125149
beforeEach(() => {
126150
vi.clearAllMocks();
151+
// Reset mockGetSession to default (no session, no error) before each test.
152+
mockGetSession.mockResolvedValue({ data: { session: null }, error: null });
153+
// Reset auth helpers to default (both return false).
154+
vi.mocked(isAuthSessionMissingError).mockReturnValue(false);
155+
vi.mocked(isSupabaseLockTimeoutError).mockReturnValue(false);
127156
});
128157

129158
it("calls NProgress.start() when the form is submitted", async () => {
@@ -154,3 +183,59 @@ describe("LoginForm – NProgress integration", () => {
154183
await waitFor(() => expect(mockNProgressDone).toHaveBeenCalled());
155184
});
156185
});
186+
187+
describe("LoginForm – mount-time storage cleanup", () => {
188+
// Replace the global Storage objects with mocks so we can reliably track calls.
189+
// vi.spyOn on the Storage prototype can fail silently in jsdom.
190+
let mockLocalStorage: { clear: ReturnType<typeof vi.fn>; removeItem: ReturnType<typeof vi.fn>; getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>; key: ReturnType<typeof vi.fn>; length: number };
191+
let mockSessionStorage: { clear: ReturnType<typeof vi.fn>; removeItem: ReturnType<typeof vi.fn>; getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>; key: ReturnType<typeof vi.fn>; length: number };
192+
193+
beforeEach(() => {
194+
vi.clearAllMocks();
195+
// Reset auth helpers and session mock to clean defaults.
196+
vi.mocked(isAuthSessionMissingError).mockReturnValue(false);
197+
vi.mocked(isSupabaseLockTimeoutError).mockReturnValue(false);
198+
mockGetSession.mockResolvedValue({ data: { session: null }, error: null });
199+
200+
mockLocalStorage = { clear: vi.fn(), removeItem: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), key: vi.fn(), length: 0 };
201+
mockSessionStorage = { clear: vi.fn(), removeItem: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), key: vi.fn(), length: 0 };
202+
Object.defineProperty(global, "localStorage", { writable: true, configurable: true, value: mockLocalStorage });
203+
Object.defineProperty(global, "sessionStorage", { writable: true, configurable: true, value: mockSessionStorage });
204+
});
205+
206+
it("clears localStorage and sessionStorage when there is no session and no error", async () => {
207+
// Default: getSession returns { session: null, error: null }.
208+
// isAuthSessionMissingError and isSupabaseLockTimeoutError both return false.
209+
render(<LoginForm />);
210+
// Use waitFor because checkUser() is async: render() resolves immediately on the
211+
// initial (pre-loading) frame, before the useEffect async session check completes.
212+
await waitFor(() => expect(mockLocalStorage.clear).toHaveBeenCalled());
213+
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith("prefetchedSettings");
214+
});
215+
216+
it("clears localStorage and sessionStorage when there is no session and the error is a session-missing error", async () => {
217+
const sessionError = new Error("Auth session missing");
218+
mockGetSession.mockResolvedValue({ data: { session: null }, error: sessionError });
219+
vi.mocked(isAuthSessionMissingError).mockReturnValue(true);
220+
221+
render(<LoginForm />);
222+
await waitFor(() => expect(mockLocalStorage.clear).toHaveBeenCalled());
223+
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith("prefetchedSettings");
224+
});
225+
226+
it("does not clear storage when getSession returns a lock-timeout error", async () => {
227+
const lockError = new Error(
228+
"Exclusive Navigator LockManager lock timed out on auth-token",
229+
);
230+
mockGetSession.mockResolvedValue({ data: { session: null }, error: lockError });
231+
vi.mocked(isSupabaseLockTimeoutError).mockReturnValue(true);
232+
233+
render(<LoginForm />);
234+
// Wait for the session check to have run (mockGetSession called), then flush
235+
// remaining async work so the effect has fully completed before asserting.
236+
await waitFor(() => expect(mockGetSession).toHaveBeenCalled());
237+
await act(async () => {});
238+
expect(mockLocalStorage.clear).not.toHaveBeenCalled();
239+
expect(mockSessionStorage.removeItem).not.toHaveBeenCalledWith("prefetchedSettings");
240+
});
241+
});

0 commit comments

Comments
 (0)