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
5 changes: 5 additions & 0 deletions src/__tests__/auth-forms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* for the login and sign-up flows (issues #105 / FE-014).
*/
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

Check warning on line 6 in src/__tests__/auth-forms.test.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

'userEvent' is defined but never used
import { describe, it, expect, vi, beforeEach } from "vitest";

// ── mocks ──────────────────────────────────────────────────────────────────
Expand All @@ -13,10 +13,15 @@
),
}));

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => ({ get: vi.fn().mockReturnValue(null) }),
}));

vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

vi.mock("framer-motion", () => {
const React = require("react");

Check failure on line 24 in src/__tests__/auth-forms.test.tsx

View workflow job for this annotation

GitHub Actions / build-and-check (18.x)

A `require()` style import is forbidden
const motion: Record<string, React.FC<React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>> = {};
["div", "form", "h2", "p"].forEach((tag) => {
motion[tag] = ({ children, ...rest }) => React.createElement(tag, rest, children);
Expand Down
154 changes: 154 additions & 0 deletions src/__tests__/useAuthState-useSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Unit tests for useAuthState and useSession hooks (issue #282 / FE-161).
*/
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// ── shared mocks ────────────────────────────────────────────────────────────
const mockReplace = vi.fn();

vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => ({ get: vi.fn().mockReturnValue(null) }),
}));

vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));

const mockGetToken = vi.fn<() => string | null>();
const mockLogout = vi.fn();

vi.mock("@/lib/auth", () => ({
getToken: () => mockGetToken(),
logout: () => mockLogout(),
}));

// ── helpers ─────────────────────────────────────────────────────────────────
function makeJwt(exp: number): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = btoa(JSON.stringify({ sub: "user1", exp }));
return `${header}.${payload}.sig`;
}

const FUTURE_EXP = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const PAST_EXP = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago

// ── useSession ───────────────────────────────────────────────────────────────
import { useSession, isTokenExpired, getTokenExpiry } from "../hooks/useSession";
import { toast } from "react-toastify";

describe("isTokenExpired", () => {
it("returns true for a token with a past exp", () => {
expect(isTokenExpired(makeJwt(PAST_EXP))).toBe(true);
});

it("returns false for a token with a future exp", () => {
expect(isTokenExpired(makeJwt(FUTURE_EXP))).toBe(false);
});

it("returns true for a malformed token", () => {
expect(isTokenExpired("not.a.jwt")).toBe(true);
});
});

describe("getTokenExpiry", () => {
it("returns the expiry timestamp in ms", () => {
expect(getTokenExpiry(makeJwt(FUTURE_EXP))).toBe(FUTURE_EXP * 1000);
});

it("returns null for a malformed token", () => {
expect(getTokenExpiry("bad")).toBeNull();
});
});

describe("useSession", () => {
beforeEach(() => {
vi.useFakeTimers();
mockReplace.mockClear();
mockLogout.mockClear();
(toast.error as ReturnType<typeof vi.fn>).mockClear();
});

afterEach(() => {
vi.useRealTimers();
});

it("redirects to /login when no token is present", () => {
mockGetToken.mockReturnValue(null);
renderHook(() => useSession());
expect(mockReplace).toHaveBeenCalledWith("/login");
});

it("redirects to /login?expired=1 and shows toast when token is expired", () => {
mockGetToken.mockReturnValue(makeJwt(PAST_EXP));
renderHook(() => useSession());
expect(mockLogout).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/session has expired/i),
);
expect(mockReplace).toHaveBeenCalledWith("/login?expired=1");
});

it("does not redirect when token is valid", () => {
mockGetToken.mockReturnValue(makeJwt(FUTURE_EXP));
renderHook(() => useSession());
expect(mockReplace).not.toHaveBeenCalled();
});

it("polls every 60 s and redirects when token expires mid-session", () => {
mockGetToken.mockReturnValue(makeJwt(FUTURE_EXP));
renderHook(() => useSession());
expect(mockReplace).not.toHaveBeenCalled();

// Simulate token expiring on next poll
mockGetToken.mockReturnValue(makeJwt(PAST_EXP));
act(() => { vi.advanceTimersByTime(60_000); });

expect(mockReplace).toHaveBeenCalledWith("/login?expired=1");
});

it("handles unexpected errors gracefully (redirects to /login)", () => {
mockGetToken.mockImplementation(() => { throw new Error("storage error"); });
renderHook(() => useSession());
expect(mockLogout).toHaveBeenCalled();
expect(mockReplace).toHaveBeenCalledWith("/login");
});
});

// ── useAuthState ─────────────────────────────────────────────────────────────
import { useAuthState } from "../hooks/useAuthState";

describe("useAuthState", () => {
beforeEach(() => {
vi.useFakeTimers();
mockReplace.mockClear();
});

afterEach(() => {
vi.useRealTimers();
});

it("starts with isLoading=true and isAuthenticated=false", () => {
mockGetToken.mockReturnValue(makeJwt(FUTURE_EXP));
const { result } = renderHook(() => useAuthState());
expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthenticated).toBe(false);
});

it("sets isLoading=false and isAuthenticated=true after timer resolves", async () => {
mockGetToken.mockReturnValue(makeJwt(FUTURE_EXP));
const { result } = renderHook(() => useAuthState());

await act(async () => {
vi.advanceTimersByTime(200);
});

expect(result.current.isLoading).toBe(false);
expect(result.current.isAuthenticated).toBe(true);
});

it("redirects to /login when no token (via useSession)", () => {
mockGetToken.mockReturnValue(null);
renderHook(() => useAuthState());
expect(mockReplace).toHaveBeenCalledWith("/login");
});
});
28 changes: 28 additions & 0 deletions src/app/(public)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ export default function EventsPage() {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
fetchEvents()
.then((data) => {
setEvents(data);
setError(null);
})
.catch((err) => {
setError(err.message || 'Failed to load events. Please try again.');
setEvents([]);
})
.finally(() => setLoading(false));
}, []);

const handleRetry = () => {
setLoading(true);
setError(null);
fetchEvents()
.then((data) => {
setEvents(data);
setError(null);
})
.catch((err) => {
setError(err.message || 'Failed to load events. Please try again.');
setEvents([]);
})
.finally(() => setLoading(false));
};

const filteredEvents = useMemo(() => {
let list = viewMode === 'featured' ? events.filter((e) => e.featured) : events;
if (activeFilters.length > 0) list = list.filter((e) => activeFilters.includes(e.category));
Expand Down
99 changes: 95 additions & 4 deletions src/components/auth/SessionExpiredBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,105 @@
"use client";

import { useSearchParams } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "react-toastify";
import { getToken, logout } from "@/lib/auth";
import { getTokenExpiry } from "@/hooks/useSession";

const WARN_BEFORE_MS = 5 * 60 * 1000; // 5 minutes
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";

export default function SessionExpiredBanner() {
const router = useRouter();
const params = useSearchParams();
if (params.get("expired") !== "1") return null;
const [showWarning, setShowWarning] = useState(false);
const [extending, setExtending] = useState(false);

const handleLogout = useCallback(() => {
logout();
router.replace("/login");
}, [router]);

const handleExtend = useCallback(async () => {
setExtending(true);
try {
const token = getToken();
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
if (data?.token) {
localStorage.setItem("auth_token", data.token);
}
setShowWarning(false);
toast.success("Session extended successfully.");
} catch {
toast.error("Could not extend session. Please sign in again.");
handleLogout();
} finally {
setExtending(false);
}
}, [handleLogout]);

useEffect(() => {
const check = () => {
const token = getToken();
if (!token) return;
const expiry = getTokenExpiry(token);
if (expiry === null) return;
const remaining = expiry - Date.now();
if (remaining <= 0) {
logout();
router.replace("/login?expired=1");
} else if (remaining <= WARN_BEFORE_MS) {
setShowWarning(true);
} else {
setShowWarning(false);
}
};

check();
const id = setInterval(check, 30_000);
return () => clearInterval(id);
}, [router]);

// Static expired message (redirected from useSession)
if (params.get("expired") === "1") {
return (
<div
role="alert"
className="rounded-lg bg-yellow-500/20 border border-yellow-500/40 px-4 py-3 text-sm text-yellow-300 text-center"
>
Your session has expired. Please sign in again.
</div>
);
}

if (!showWarning) return null;

return (
<div role="alert" className="rounded-lg bg-yellow-500/20 border border-yellow-500/40 px-4 py-3 text-sm text-yellow-300 text-center">
Your session has expired. Please sign in again.
<div
role="alert"
className="rounded-lg bg-orange-500/20 border border-orange-500/40 px-4 py-3 text-sm text-orange-300 flex flex-col sm:flex-row items-center justify-between gap-3"
>
<span>Your session will expire in less than 5 minutes.</span>
<div className="flex gap-2">
<button
onClick={handleExtend}
disabled={extending}
className="rounded bg-orange-500 px-3 py-1 text-white text-xs font-medium hover:bg-orange-600 disabled:opacity-50"
>
{extending ? "Extending…" : "Extend Session"}
</button>
<button
onClick={handleLogout}
className="rounded border border-orange-400 px-3 py-1 text-orange-300 text-xs font-medium hover:bg-orange-500/20"
>
Log Out
</button>
</div>
</div>
);
}
52 changes: 35 additions & 17 deletions src/components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import z from "zod";
import { Input } from "../ui/input";
import { TbUserPlus } from "react-icons/tb";
import { FcGoogle } from "react-icons/fc";
import { Button } from "../button";
import Link from "next/link";
import { toast } from "react-toastify";
Expand Down Expand Up @@ -45,33 +46,27 @@ export default function LoginForm() {
}
};

const handleGoogleLogin = () => {
try {
window.location.href = "/api/auth/google";
} catch {
toast.error("Google sign-in failed. Please try again.");
}
};

const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.5,
staggerChildren: 0.12,
},
},
visible: { opacity: 1, transition: { duration: 0.5, staggerChildren: 0.12 } },
};

const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 },
},
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
};

const headerVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.5 },
},
visible: { opacity: 1, scale: 1, transition: { duration: 0.5 } },
};

return (
Expand Down Expand Up @@ -135,6 +130,29 @@ export default function LoginForm() {
</motion.form>
</div>

<motion.div className="flex items-center gap-4 my-4" variants={itemVariants}>
<div className="flex-1 h-px bg-primary-black" />
<span className="text-sm lg:text-xl">or continue with</span>
<div className="flex-1 h-px bg-primary-black" />
</motion.div>

<motion.div variants={itemVariants}>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.2 }}>
<Button
onClick={handleGoogleLogin}
variant="outline"
disabled={isSubmitting}
className="w-full py-4"
type="button"
>
<div className="flex items-center justify-center gap-2">
<FcGoogle size={20} />
<span className="text-sm font-medium">Continue with Google</span>
</div>
</Button>
</motion.div>
</motion.div>

<motion.p className="text-center lg:text-xl mt-6" variants={itemVariants}>
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="font-bold">
Expand Down
Loading
Loading