Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/providers/theme_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
final _ = _loadTheme();
return _preloadedTheme ?? ThemeMode.system;
return _preloadedTheme ?? ThemeMode.light;
}

Future<void> _loadTheme() async {
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghostclass",
"version": "4.4.3",
"version": "4.4.4",
"private": true,
"engines": {
"node": ">=22.12.0",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/app/(protected)/__tests__/ProtectedLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +73,10 @@ describe('ProtectedLayout', () => {
</ProtectedLayout>
);

await waitFor(() => {
expect(screen.getByTestId('navbar')).toBeInTheDocument();
});

expect(screen.getByTestId('navbar')).toBeInTheDocument();
expect(screen.getByTestId('footer')).toBeInTheDocument();
expect(screen.getByTestId('child')).toBeInTheDocument();
Expand All @@ -84,6 +89,10 @@ describe('ProtectedLayout', () => {
</ProtectedLayout>
);

await waitFor(() => {
expect(screen.getByTestId('motion-div')).toBeInTheDocument();
});

// Initial state: visible
expect(screen.getByTestId('motion-div').getAttribute('data-animate')).toBe('visible');

Expand Down
11 changes: 7 additions & 4 deletions src/app/(protected)/__tests__/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -52,8 +53,10 @@ describe('ProtectedLayout', () => {
</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();
});
});
});
100 changes: 43 additions & 57 deletions src/app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Navbar>; 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) => {
Expand All @@ -41,15 +31,15 @@ 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);
} else if (shouldShow && isHiddenRef.current) {
isHiddenRef.current = false;
setIsHidden(false);
}

lastScrollY.current = latest;
ticking.current = false;
});
Expand All @@ -62,33 +52,12 @@ export default function ProtectedLayout({
};
}, [scrollY]);

// useInstitutions is also called inside <Navbar>; 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 (
<ErrorBoundary>
<div className="flex min-h-screen flex-col" suppressHydrationWarning>
<Toaster />
{/* Middleware already verified auth server-side; content renders immediately. */}
{/* Client-side session validation runs in useEffect above as defense-in-depth. */}
<>
<LazyMotion features={domAnimation}>
<LazyMotion features={domAnimation}>
<motion.div
variants={{
visible: { y: 0 },
Expand All @@ -101,10 +70,6 @@ export default function ProtectedLayout({
isHidden ? "pointer-events-none" : "pointer-events-auto"
)}
style={{ paddingRight: "var(--scrollbar-width, 0px)" }}
// The inert attribute disables keyboard/screen reader interaction with hidden elements
// Browser support: Chrome 102+, Safari 15.5+, Firefox 112+ (March 2023+)
// Feature detection ensures graceful degradation on older browsers
// Only apply inert when the feature is supported and element should be hidden
{...((isHidden &&
typeof HTMLElement !== "undefined" &&
HTMLElement?.prototype &&
Expand All @@ -114,19 +79,40 @@ export default function ProtectedLayout({
>
<Navbar />
</motion.div>
</LazyMotion>

<main className="flex-1 w-full bg-background pt-20">
<OutageProvider>
<ErrorBoundary>
{children}
</ErrorBoundary>
</OutageProvider>
</main>

<Footer />
</>
</LazyMotion>

<main className="flex-1 w-full bg-background pt-20">
<OutageProvider>
<ErrorBoundary>{children}</ErrorBoundary>
</OutageProvider>
</main>

<Footer />
</>
</div>
</ErrorBoundary>
);
}

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 <ProtectedChrome>{children}</ProtectedChrome>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})),
Expand Down
14 changes: 8 additions & 6 deletions src/components/layout/__tests__/private-navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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";
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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -47,6 +46,7 @@ vi.mock("@/hooks/users/institutions", () => ({
}));

vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(),
useQueryClient: vi.fn(),
}));

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(<Navbar />);
Expand Down
Loading
Loading