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
8 changes: 5 additions & 3 deletions frontend/app/(protected)/map/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import dynamic from "next/dynamic";
import { clientOnlyBundleBoundary } from "@/lib/bundle-config";

const MapPageContent = dynamic(() => import("./MapPageContent"), {
ssr: false,
});
const MapPageContent = dynamic(
() => import("./MapPageContent"),
clientOnlyBundleBoundary,
);

export default function MapPage() {
return <MapPageContent />;
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/[locale]/(protected)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import NotificationBell from "@/components/layout/NotificationBell";
import { SessionExpiryGuard } from "@/components/SessionExpiryGuard";

export const dynamic = "force-dynamic";

Expand All @@ -20,6 +21,7 @@ export default async function ProtectedLayout({

return (
<div className="min-h-screen bg-gray-50">
<SessionExpiryGuard />
<nav className="sticky top-0 z-40 border-b border-gray-200 bg-white">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-6">
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/[locale]/(protected)/settings/data/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
// Helpers
// ---------------------------------------------------------------------------

async function logout(): Promise<boolean> {
return clearSession();
function logout(): boolean {
return clearSession().ok;
}

const COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
Expand Down
6 changes: 5 additions & 1 deletion frontend/app/[locale]/(protected)/settings/security/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useState } from "react";
import { clearSession } from "@/lib/auth-session";

// ─────────────────────────────────────────────
// Types
Expand Down Expand Up @@ -268,7 +269,10 @@ function DangerZone() {
try {
// Replace with: await api.delete('/users/me')
await new Promise((r) => setTimeout(r, 800));
// On success the auth layer should clear the session and redirect.
const cleared = clearSession();
if (!cleared.ok) {
throw new Error("Unable to clear the session safely.");
}
window.location.href = "/login";
} catch {
setError("Failed to delete account. Please try again.");
Expand Down
15 changes: 9 additions & 6 deletions frontend/app/[locale]/login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { Link, useRouter } from "@/i18n/navigation";
import { loginSchema, type LoginInput } from "@/lib/schemas/auth";
import { ApiError, apiRequest } from "@/lib/api-client";
import { API_V1_BASE, ApiError, apiRequest } from "@/lib/api-client";
import {
resolvePostLoginPath,
storeSession,
Expand All @@ -25,8 +25,6 @@ import {
Input,
} from "@/components/ui";

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";

function ResetSuccessToast({ message }: { message: string }) {
const [visible, setVisible] = useState(true);

Expand Down Expand Up @@ -91,23 +89,28 @@ export function LoginForm() {
});

const target = resolvePostLoginPath(searchParams.get("redirect"));
const resumeId = searchParams.get("resume");

const oauthHref = (provider: "google" | "github") =>
`${API_BASE}/api/v1/auth/${provider}`;
`${API_V1_BASE}/auth/${provider}`;

async function onSubmit(values: LoginInput) {
setSubmitError(null);
try {
const data = await apiRequest<LoginResponse>(
"/api/v1/auth/login",
`${API_V1_BASE}/auth/login`,
{
method: "POST",
body: values,
credentials: "include",
anonymous: true,
},
);
storeSession(data);
const stored = storeSession(data, resumeId ?? undefined);
if (!stored.ok) {
setSubmitError({ kind: "api", messageKey: "errors.status.unknown" });
return;
}
// replace, so Back does not land the user on a login page they have
// already passed through.
router.replace(target);
Expand Down
17 changes: 17 additions & 0 deletions frontend/components/SessionExpiryGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import { useCallback, useEffect } from "react";
import { refreshSession } from "@/lib/api-client";
import {
initCrossTabLogoutSync,
useSessionExpiryWarning,
} from "@/lib/session-expiry-warning";

export function SessionExpiryGuard() {
const onRefresh = useCallback(() => refreshSession(), []);

useSessionExpiryWarning({ onRefresh });
useEffect(() => initCrossTabLogoutSync(), []);

return null;
}
43 changes: 43 additions & 0 deletions frontend/lib/__tests__/image-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import imageLoader, { getOptimizedImageUrl } from "../image-loader";

describe("Next image loader", () => {
it("uses the default-export ImageLoader contract", () => {
const result = imageLoader({
src: "/documents/photo.jpg?version=2#preview",
width: 640,
quality: 80,
});

expect(result).toBe(
"/_next/image?url=%2Fdocuments%2Fphoto.jpg%3Fversion%3D2&w=640&q=80#preview",
);
});

it("keeps the named URL helper compatible with the loader", () => {
expect(getOptimizedImageUrl("/photo.jpg", 320, 70)).toBe(
imageLoader({ src: "/photo.jpg", width: 320, quality: 70 }),
);
});

it("returns signed URLs unchanged rather than proxying them", () => {
const signedUrl =
"https://cdn.example.test/photo.jpg?X-Amz-Signature=abc&X-Amz-Expires=60";

expect(imageLoader({ src: signedUrl, width: 640 })).toBe(signedUrl);
});

it("leaves inline and object URLs unchanged", () => {
expect(imageLoader({ src: "data:image/png;base64,abc", width: 640 })).toBe(
"data:image/png;base64,abc",
);
expect(imageLoader({ src: "blob:https://example.test/image", width: 640 })).toBe(
"blob:https://example.test/image",
);
});

it("rejects protocol-relative URLs", () => {
expect(() => imageLoader({ src: "//cdn.example.test/photo.jpg", width: 640 })).toThrow(
"Protocol-relative image URLs are not supported",
);
});
});
153 changes: 153 additions & 0 deletions frontend/lib/__tests__/session-expiry-warning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { act, renderHook } from "@testing-library/react";
import {
getTokenExpiryMs,
useSessionExpiryWarning,
} from "../session-expiry-warning";

const values: Record<string, string> = {};
let storageBlocked = false;
const localStorage = {
getItem: jest.fn((key: string) => {
if (storageBlocked) throw new DOMException("blocked", "SecurityError");
return values[key] ?? null;
}),
};

function makeToken(expiresInSeconds: number): string {
const payload = btoa(
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresInSeconds }),
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `header.${payload}.signature`;
}

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
Object.keys(values).forEach((key) => delete values[key]);
values["auth-token"] = makeToken(600);
storageBlocked = false;
Object.defineProperty(window, "localStorage", {
configurable: true,
value: localStorage,
});
localStorage.getItem.mockClear();
Object.defineProperty(window, "confirm", {
configurable: true,
value: jest.fn(() => false),
});
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});

describe("session expiry warning", () => {
it("parses base64url JWT payloads without requiring padding", () => {
const token =
"header.eyJleHAiOjE4OTM0NTYwMDAsIngiOiL_wiJ9.signature";

expect(getTokenExpiryMs(token)).toBe(
1893456000000 - new Date("2026-01-01T00:00:00.000Z").getTime(),
);
});

it("postpones the prompt until a continuous typing stream stops", () => {
values["auth-token"] = makeToken(360);
const onRefresh = jest.fn().mockResolvedValue(undefined);
const editor = document.createElement("textarea");
document.body.appendChild(editor);
const { unmount } = renderHook(() =>
useSessionExpiryWarning({ onRefresh }),
);

act(() => {
for (let index = 0; index < 400; index += 1) {
editor.dispatchEvent(new Event("input", { bubbles: true }));
jest.advanceTimersByTime(200);
}
});

expect(window.confirm).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(250);
jest.advanceTimersByTime(60 * 1000);
});
expect(window.confirm).toHaveBeenCalledTimes(1);
unmount();
editor.remove();
});

it("handles blocked localStorage without throwing from activity", () => {
const onRefresh = jest.fn().mockResolvedValue(undefined);
const editor = document.createElement("input");
document.body.appendChild(editor);
const { unmount } = renderHook(() =>
useSessionExpiryWarning({ onRefresh }),
);
storageBlocked = true;

expect(() => {
act(() => {
editor.dispatchEvent(new Event("focusin", { bubbles: true }));
editor.dispatchEvent(new Event("input", { bubbles: true }));
jest.advanceTimersByTime(10 * 60 * 1000);
});
}).not.toThrow();
expect(window.confirm).not.toHaveBeenCalled();
unmount();
editor.remove();
});

it("uses the latest refresh callback without restarting on identity changes", async () => {
values["auth-token"] = makeToken(1200);
const firstRefresh = jest.fn().mockResolvedValue(undefined);
const secondRefresh = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(window, "confirm", {
configurable: true,
value: jest.fn(() => true),
});
const { rerender, unmount } = renderHook(
({ onRefresh }: { onRefresh: () => Promise<void> }) =>
useSessionExpiryWarning({ onRefresh }),
{ initialProps: { onRefresh: firstRefresh } },
);

rerender({ onRefresh: secondRefresh });
await act(async () => {
jest.advanceTimersByTime(5 * 60 * 1000);
await Promise.resolve();
await Promise.resolve();
});

expect(firstRefresh).not.toHaveBeenCalled();
expect(secondRefresh).toHaveBeenCalledTimes(1);
unmount();
});

it("cleans the timer and listeners on unmount", () => {
const editor = document.createElement("textarea");
document.body.appendChild(editor);
const { unmount } = renderHook(() =>
useSessionExpiryWarning({ onRefresh: jest.fn().mockResolvedValue(undefined) }),
);

act(() => {
jest.advanceTimersByTime(4 * 60 * 1000);
editor.dispatchEvent(new Event("input", { bubbles: true }));
});
unmount();

act(() => {
editor.dispatchEvent(new Event("input", { bubbles: true }));
jest.advanceTimersByTime(10 * 60 * 1000);
});
expect(window.confirm).not.toHaveBeenCalled();
editor.remove();
});
});
4 changes: 2 additions & 2 deletions frontend/lib/__tests__/session-expiry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("Concurrent 401 → single refresh (Issue #1024)", () => {
// the refresh call returns 200 with a new token,
// and retried calls return 200.
(global as any).fetch = jest.fn((url: string) => {
if (url.includes("/auth/refresh")) {
if (url.includes("/api/v1/auth/refresh")) {
refreshCallCount += 1;
return Promise.resolve(
new Response(
Expand Down Expand Up @@ -100,7 +100,7 @@ describe("Concurrent 401 → single refresh (Issue #1024)", () => {
(window as any).location = { href: "" };

(global as any).fetch = jest.fn((url: string) => {
if (url.includes("/auth/refresh")) {
if (url.includes("/api/v1/auth/refresh")) {
return Promise.resolve(
new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 401,
Expand Down
Loading
Loading