From 01f30af148e49d66bec1fdc5118fad18267f2367 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Sat, 23 May 2026 19:20:34 +0000 Subject: [PATCH 1/3] fix: change default theme mode to light in ThemeNotifier --- .example.env | 4 ++-- mobile/lib/config/app_config.dart | 2 +- mobile/lib/providers/theme_provider.dart | 2 +- mobile/pubspec.yaml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- public/openapi/openapi.yaml | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.example.env b/.example.env index fe70aac8..3385beff 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.4.3 +NEXT_PUBLIC_APP_VERSION=4.4.4 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -359,7 +359,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.4.3 +MIN_APP_VERSION=4.4.4 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index f080edb8..43d104da 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.3'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.4'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/providers/theme_provider.dart b/mobile/lib/providers/theme_provider.dart index 68584552..1a5cbb8e 100644 --- a/mobile/lib/providers/theme_provider.dart +++ b/mobile/lib/providers/theme_provider.dart @@ -30,7 +30,7 @@ class ThemeNotifier extends Notifier { @override ThemeMode build() { final _ = _loadTheme(); - return _preloadedTheme ?? ThemeMode.system; + return _preloadedTheme ?? ThemeMode.light; } Future _loadTheme() async { diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9df748b8..67635f39 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.4.3+1 +version: 4.4.4+1 environment: sdk: ^3.11.4 diff --git a/package-lock.json b/package-lock.json index 01b79ff8..255eb2d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.4.3", + "version": "4.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.4.3", + "version": "4.4.4", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index 01bf7a60..a122e661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.4.3", + "version": "4.4.4", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 5d9999c8..5861bc8a 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.4.3 + version: 4.4.4 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. From 32fd1775e7f75385f3f95c2d389a68e716797983 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Sat, 23 May 2026 19:44:29 +0000 Subject: [PATCH 2/3] refactor: streamline CSRF token handling and improve internal request security --- src/app/(protected)/layout.tsx | 100 +++++++++------------ src/components/layout/private-navbar.tsx | 11 ++- src/hooks/users/__tests__/profile.test.tsx | 5 +- src/hooks/users/profile.ts | 6 +- src/lib/__tests__/axios.test.ts | 24 +++++ src/lib/axios.ts | 53 ++++++----- 6 files changed, 115 insertions(+), 84 deletions(-) diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index f08db517..2285c22e 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -8,31 +8,21 @@ import { Toaster } from "@/components/toaster"; import { LazyMotion, domAnimation, m as motion, useScroll } from "framer-motion"; import { cn } from "@/lib/utils"; import { ErrorBoundary } from "@/components/error-boundary"; -import { useCSRFToken } from "@/hooks/use-csrf-token"; +import { ensureCSRFToken } from "@/hooks/use-csrf-token"; import { OutageProvider } from "@/providers/outage-provider"; -export default function ProtectedLayout({ - children, -}: { - children: React.ReactNode; -}) { - // IMPORTANT — No loading state is tracked here. - // - // The server middleware (proxy.ts) already verified the Supabase session - // and would have redirected to "/" if the user isn't authenticated. - // By the time this layout renders, auth is guaranteed server-side. - // - // The useEffect below still validates the session client-side as - // defense-in-depth; if the session is invalid it calls handleLogout(). - // On success it is a no-op. On failure it redirects — no loading UI needed. +function ProtectedChrome({ children }: { children: React.ReactNode }) { const [isHidden, setIsHidden] = useState(false); const { scrollY } = useScroll(); const lastScrollY = useRef(0); const ticking = useRef(false); const isHiddenRef = useRef(false); - // Initialize CSRF token - useCSRFToken(); + // useInstitutions is also called inside ; React Query deduplicates the + // network request so there is no extra fetch here. We only subscribe to it at + // the layout level so that React Query starts pre-fetching institutions during + // the auth-check phase, giving the Navbar data sooner. + useInstitutions(); useEffect(() => { const unsubscribe = scrollY.on("change", (latest) => { @@ -41,7 +31,7 @@ export default function ProtectedLayout({ const previous = lastScrollY.current; const shouldHide = latest > previous && latest > 150; const shouldShow = latest <= previous || latest <= 150; - + if (shouldHide && !isHiddenRef.current) { isHiddenRef.current = true; setIsHidden(true); @@ -49,7 +39,7 @@ export default function ProtectedLayout({ isHiddenRef.current = false; setIsHidden(false); } - + lastScrollY.current = latest; ticking.current = false; }); @@ -62,33 +52,12 @@ export default function ProtectedLayout({ }; }, [scrollY]); - // useInstitutions is also called inside ; React Query deduplicates the - // network request so there is no extra fetch here. We only subscribe to it at - // the layout level so that React Query starts pre-fetching institutions during - // the auth-check phase, giving the Navbar data sooner. The layout itself does - // NOT gate on institution loading/error — a failed institution fetch should - // never make the entire protected area inaccessible. - useInstitutions(); - - useEffect(() => { - // No client-side auth gate here. - // - // The server middleware (`proxy.ts`) already validates the Supabase - // session before this layout renders. On local dev, the browser-side - // Supabase client can temporarily report no user immediately after login - // even when the server session is valid, which caused an unwanted logout. - // We keep the client session code out of the critical path and let the - // server remain the source of truth. - }, []); - return (
- {/* Middleware already verified auth server-side; content renders immediately. */} - {/* Client-side session validation runs in useEffect above as defense-in-depth. */} <> - + - - -
- - - {children} - - -
- -
- + + +
+ + {children} + +
+ +
+
); +} + +export default function ProtectedLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [isCsrfReady, setIsCsrfReady] = useState(false); + + useEffect(() => { + let cancelled = false; + ensureCSRFToken().finally(() => { + if (!cancelled) setIsCsrfReady(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + if (!isCsrfReady) return null; + + return {children}; } \ No newline at end of file diff --git a/src/components/layout/private-navbar.tsx b/src/components/layout/private-navbar.tsx index 34a91694..c618dc6c 100644 --- a/src/components/layout/private-navbar.tsx +++ b/src/components/layout/private-navbar.tsx @@ -13,7 +13,6 @@ import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { handleLogout } from "@/lib/security/auth"; import { useRouter } from "next/navigation"; -import { useProfile } from "@/hooks/users/profile"; import { Switch } from "@/components/ui/switch"; import { useDefaultInstitutionUser, @@ -29,7 +28,7 @@ import { SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { Building2, @@ -55,11 +54,11 @@ import { useNotifications } from "@/hooks/notifications/useNotifications"; import { isValidAvatarUrl } from "@/lib/utils"; import { useTopLoader } from "nextjs-toploader"; import { useTheme } from "@/providers/theme"; +import { UserProfile } from "@/types"; export const Navbar = () => { const router = useRouter(); const topLoader = useTopLoader(); - const { data: profile } = useProfile(); const { settings, updateBunkCalc, updateTarget, isLoading: settingsLoading } = useUserSettings(); const { theme, toggleTheme } = useTheme(); @@ -76,6 +75,12 @@ export const Navbar = () => { const updateDefaultInstitutionUser = useUpdateDefaultInstitutionUser(); const queryClient = useQueryClient(); const selectedInstitution = defaultInstitutionUser?.toString() ?? ""; + const { data: profile } = useQuery({ + queryKey: ["profile", true], + queryFn: async () => null, + enabled: false, + staleTime: 1000 * 60 * 5, + }); const pathname = usePathname(); // countOnly=true: skips the action-conflict query and infinite feed query (both with diff --git a/src/hooks/users/__tests__/profile.test.tsx b/src/hooks/users/__tests__/profile.test.tsx index 375fa431..bf6b73f9 100644 --- a/src/hooks/users/__tests__/profile.test.tsx +++ b/src/hooks/users/__tests__/profile.test.tsx @@ -54,7 +54,10 @@ describe("profile hooks", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual(mockProfile); - expect(axiosInstance.get).toHaveBeenCalledWith("/api/profile", expect.any(Object)); + expect(axiosInstance.get).toHaveBeenCalledWith( + "/api/profile", + expect.objectContaining({ params: { sync: "true", force: "true" } }), + ); }); it("should send sync and force params when requested", async () => { diff --git a/src/hooks/users/profile.ts b/src/hooks/users/profile.ts index c9f4ddde..c027d099 100644 --- a/src/hooks/users/profile.ts +++ b/src/hooks/users/profile.ts @@ -15,11 +15,11 @@ interface UpdateProfileData { export const useProfile = (options?: { initialData?: UserProfile; sync?: boolean; force?: boolean }) => { return useQuery({ - queryKey: ["profile", !!options?.sync, !!options?.force], + queryKey: ["profile"], queryFn: async () => { const params: Record = {}; - if (options?.sync) params.sync = "true"; - if (options?.force) params.force = "true"; + params.sync = "true"; + params.force = "true"; const res = await axiosInstance.get("/api/profile", { params: Object.keys(params).length > 0 ? params : undefined, diff --git a/src/lib/__tests__/axios.test.ts b/src/lib/__tests__/axios.test.ts index 14eca54e..a8d06e03 100644 --- a/src/lib/__tests__/axios.test.ts +++ b/src/lib/__tests__/axios.test.ts @@ -131,6 +131,26 @@ describe('axios lib', () => { expect(resultConfig.headers.get('x-csrf-token')).toBe('a'.repeat(64)); }); + it('initializes CSRF before internal requests when token is missing', async () => { + const config = { + url: '/api/profile', + method: 'get', + headers: new Map(), + } as any; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'c'.repeat(64) }), + }); + + // @ts-expect-error -- accessing private interceptor handler for testing + const interceptor = axiosInstance.interceptors.request.handlers[0].fulfilled; + const resultConfig = await interceptor(config); + + expect(mockFetch).toHaveBeenCalledWith('/api/csrf', expect.any(Object)); + expect(resultConfig.headers.get('x-csrf-token')).toBe('c'.repeat(64)); + }); + it('encrypts POST request body (JWE)', async () => { const config = { url: '/api/mutation', @@ -212,6 +232,8 @@ describe('axios lib', () => { config: { url: '/api/test', _authRetried: false }, response: { status: 401 } } as any; + + setCsrfToken('d'.repeat(64)); mockFetch.mockResolvedValueOnce({ ok: true, @@ -232,6 +254,8 @@ describe('axios lib', () => { config: { url: '/api/test', _authRetried: false }, response: { status: 401 } } as any; + + setCsrfToken('d'.repeat(64)); mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/src/lib/axios.ts b/src/lib/axios.ts index e9e61f0f..6f8e33c1 100644 --- a/src/lib/axios.ts +++ b/src/lib/axios.ts @@ -82,12 +82,13 @@ async function syncSession(): Promise { syncPromise = (async () => { try { - const csrfToken = getCsrfToken(); + const csrfToken = getCsrfToken() ?? (await refreshCsrfToken()); + if (!csrfToken) return false; const response = await fetch("/api/auth/sync", { method: "POST", headers: { "Content-Type": "application/json", - ...(csrfToken ? { [CSRF_HEADER]: csrfToken } : {}), + [CSRF_HEADER]: csrfToken, }, credentials: "include", }); @@ -241,31 +242,43 @@ async function encryptRequestPayload(config: JweAxiosConfig, method: string) { } } +function isInternalRequest(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + return parsed.origin === window.location.origin; + } catch { + // Non-parseable values (e.g. bare relative paths like "/api/...") are internal. + return !url.startsWith("http"); + } +} + +function isPublicRequest(url: string): boolean { + return url.includes("/api/csrf") || url.includes("/api/.well-known/jwks.json"); +} + +async function applyInternalRequestSecurity(config: JweAxiosConfig) { + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) config.headers.set(CSRF_HEADER, token); + + await encryptRequestPayload(config, config.method?.toLowerCase() || ""); + config.headers.set("Accept", "application/jose, application/json"); +} + axiosInstance.interceptors.request.use(async (config: JweAxiosConfig) => { if (isOutageDetected) return Promise.reject(new Error("Active service outage")); if (typeof window === "undefined") return config; - const token = getCsrfToken(); - if (token) config.headers.set(CSRF_HEADER, token); - const url = config.url || ""; - // H-5: Use URL parsing for isInternal detection instead of string heuristics. + // H-5: Use URL parsing for internal detection instead of string heuristics. // The previous check misclassified proxy paths containing "ezygo.app" as // external, causing them to skip CSRF/JWE headers. - const isInternal = (() => { - try { - const parsed = new URL(url, window.location.origin); - return parsed.origin === window.location.origin; - } catch { - // Non-parseable values (e.g. bare relative paths like "/api/...") are internal. - return !url.startsWith("http"); - } - })(); - const isPublic = url.includes("/api/csrf") || url.includes("/api/.well-known/jwks.json"); - - if (isInternal && !isPublic) { - await encryptRequestPayload(config, config.method?.toLowerCase() || ""); - config.headers.set("Accept", "application/jose, application/json"); + const isInternal = isInternalRequest(url); + if (isInternal && !isPublicRequest(url)) { + await applyInternalRequestSecurity(config); } // Deduplicate slashes in the final URL (path parts) From 1aec689bf9db0711a7d7801b31262da4fad98563 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Sat, 23 May 2026 19:48:15 +0000 Subject: [PATCH 3/3] test: update mocks to use React Query and implement waitFor for async layout assertions --- .../(protected)/__tests__/ProtectedLayout.test.tsx | 9 +++++++++ src/app/(protected)/__tests__/layout.test.tsx | 11 +++++++---- .../__tests__/private-navbar.coverage.test.tsx | 3 +++ .../layout/__tests__/private-navbar.test.tsx | 14 ++++++++------ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx index c1a023c3..178de56c 100644 --- a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx +++ b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx @@ -10,6 +10,7 @@ vi.mock("@/hooks/users/institutions", () => ({ })); vi.mock("@/hooks/use-csrf-token", () => ({ useCSRFToken: vi.fn(), + ensureCSRFToken: vi.fn().mockResolvedValue(null), })); // Mock components @@ -72,6 +73,10 @@ describe('ProtectedLayout', () => { ); + await waitFor(() => { + expect(screen.getByTestId('navbar')).toBeInTheDocument(); + }); + expect(screen.getByTestId('navbar')).toBeInTheDocument(); expect(screen.getByTestId('footer')).toBeInTheDocument(); expect(screen.getByTestId('child')).toBeInTheDocument(); @@ -84,6 +89,10 @@ describe('ProtectedLayout', () => { ); + await waitFor(() => { + expect(screen.getByTestId('motion-div')).toBeInTheDocument(); + }); + // Initial state: visible expect(screen.getByTestId('motion-div').getAttribute('data-animate')).toBe('visible'); diff --git a/src/app/(protected)/__tests__/layout.test.tsx b/src/app/(protected)/__tests__/layout.test.tsx index 77c3067f..711e3e52 100644 --- a/src/app/(protected)/__tests__/layout.test.tsx +++ b/src/app/(protected)/__tests__/layout.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { describe, it, vi, expect, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import ProtectedLayout from '../layout'; // Mock all the things @@ -10,6 +10,7 @@ vi.mock('@/hooks/users/institutions', () => ({ vi.mock('@/hooks/use-csrf-token', () => ({ useCSRFToken: vi.fn(), + ensureCSRFToken: vi.fn().mockResolvedValue(null), })); vi.mock('framer-motion', async (importOriginal) => { @@ -52,8 +53,10 @@ describe('ProtectedLayout', () => { ); - expect(screen.getByTestId('navbar')).toBeInTheDocument(); - expect(screen.getByTestId('footer')).toBeInTheDocument(); - expect(screen.getByTestId('child')).toBeInTheDocument(); + return waitFor(() => { + expect(screen.getByTestId('navbar')).toBeInTheDocument(); + expect(screen.getByTestId('footer')).toBeInTheDocument(); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); }); }); diff --git a/src/components/layout/__tests__/private-navbar.coverage.test.tsx b/src/components/layout/__tests__/private-navbar.coverage.test.tsx index 37c336a6..dcffcd45 100644 --- a/src/components/layout/__tests__/private-navbar.coverage.test.tsx +++ b/src/components/layout/__tests__/private-navbar.coverage.test.tsx @@ -51,6 +51,9 @@ vi.mock('@/hooks/users/institutions', () => ({ })); vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(() => ({ + data: { id: '123', username: 'testuser', email: 'test@example.com', avatar_url: null }, + })), useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn(), })), diff --git a/src/components/layout/__tests__/private-navbar.test.tsx b/src/components/layout/__tests__/private-navbar.test.tsx index 80c5baeb..9185e824 100644 --- a/src/components/layout/__tests__/private-navbar.test.tsx +++ b/src/components/layout/__tests__/private-navbar.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { useProfile } from "@/hooks/users/profile"; import { useUserSettings } from "@/providers/user-settings"; import { useInstitutions, useUpdateDefaultInstitutionUser, useDefaultInstitutionUser } from "@/hooks/users/institutions"; import { useTheme } from "@/providers/theme"; @@ -8,7 +7,7 @@ import { handleLogout } from "@/lib/security/auth"; import { isValidAvatarUrl } from "@/lib/utils"; import { Navbar } from "../private-navbar"; import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import React from "react"; // --------------------------------------------------------------------------- @@ -47,6 +46,7 @@ vi.mock("@/hooks/users/institutions", () => ({ })); vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(), useQueryClient: vi.fn(), })); @@ -140,7 +140,7 @@ describe("Navbar", () => { vi.clearAllMocks(); mockPathname.mockReturnValue("/dashboard"); - vi.mocked(useProfile).mockReturnValue({ + vi.mocked(useQuery).mockReturnValue({ data: { username: "testuser", email: "test@example.com", avatar_url: null }, isLoading: false } as never); @@ -152,6 +152,9 @@ describe("Navbar", () => { isLoading: false, } as never); + vi.mocked(useQuery).mockReturnValue({ + data: { username: "testuser", email: "test@example.com", avatar_url: null }, + } as never); vi.mocked(useInstitutions).mockReturnValue({ data: [], isLoading: false } as never); vi.mocked(useDefaultInstitutionUser).mockReturnValue({ data: null } as never); vi.mocked(useUpdateDefaultInstitutionUser).mockReturnValue({ mutate: vi.fn() } as never); @@ -239,9 +242,8 @@ describe("Navbar", () => { it("renders avatar image if URL is valid", () => { vi.mocked(isValidAvatarUrl).mockReturnValue(true); - vi.mocked(useProfile).mockReturnValue({ - data: { avatar_url: "http://example.com/avatar.png", username: "testuser" }, - isLoading: false + vi.mocked(useQuery).mockReturnValue({ + data: { avatar_url: "http://example.com/avatar.png", username: "testuser", email: "test@example.com" }, } as never); render();