diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 24986fa52..8030a209d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import { redirect } from "next/navigation"; import DashboardSSEProvider from "@/components/DashboardSSEProvider"; import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext"; import RoastHypeWidget from "./RoastHypeWidget"; +import ScrollToTopButton from "@/components/ScrollToTopButton"; export default async function DashboardPage() { @@ -90,7 +91,7 @@ export default async function DashboardPage() { {/* Today Focus */}
- +
{/* Featured Section */} @@ -146,6 +147,8 @@ export default async function DashboardPage() { + + diff --git a/src/components/ScrollToTopButton.tsx b/src/components/ScrollToTopButton.tsx new file mode 100644 index 000000000..2a3097c63 --- /dev/null +++ b/src/components/ScrollToTopButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { ArrowUp } from "lucide-react"; + +export default function ScrollToTopButton({ threshold = 400 }: { threshold?: number }) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setVisible(window.scrollY > threshold); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => window.removeEventListener("scroll", handleScroll); + }, [threshold]); + + const scrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + + if (!visible) return null; + + return ( + + ); +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 92220e5fc..eb62aef9f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -281,6 +281,7 @@ export async function middleware(req: NextRequest) { (route) => pathname === route || pathname.startsWith(`${route}/`) ); + if ((isProtectedRoute || isAdminRoute) && !token) { const url = req.nextUrl.clone(); url.pathname = "/"; diff --git a/test/get-session-token.test.ts b/test/get-session-token.test.ts new file mode 100644 index 000000000..590fa3868 --- /dev/null +++ b/test/get-session-token.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// "server-only" throws outside a server context, so it must be stubbed for tests +vi.mock("server-only", () => ({})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + authOptions: {}, +})); + +vi.mock("@/lib/resolve-user", () => ({ + resolveAppUser: vi.fn(), +})); + +import { getServerSession } from "next-auth"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { getSessionWithToken } from "../src/lib/get-session-token"; + +describe("getSessionWithToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when there is no session", async () => { + (getServerSession as any).mockResolvedValue(null); + + const result = await getSessionWithToken(); + + expect(result).toBeNull(); + }); + + it("returns null when session is missing githubId", async () => { + (getServerSession as any).mockResolvedValue({ + githubLogin: "octocat", + accessToken: "token-123", + }); + + const result = await getSessionWithToken(); + + expect(result).toBeNull(); + }); + + it("returns null when session is missing githubLogin", async () => { + (getServerSession as any).mockResolvedValue({ + githubId: "12345", + accessToken: "token-123", + }); + + const result = await getSessionWithToken(); + + expect(result).toBeNull(); + }); + + it("returns null when session is missing accessToken", async () => { + (getServerSession as any).mockResolvedValue({ + githubId: "12345", + githubLogin: "octocat", + }); + + const result = await getSessionWithToken(); + + expect(result).toBeNull(); + }); + + it("returns null when resolveAppUser returns null", async () => { + (getServerSession as any).mockResolvedValue({ + githubId: "12345", + githubLogin: "octocat", + accessToken: "token-123", + }); + (resolveAppUser as any).mockResolvedValue(null); + + const result = await getSessionWithToken(); + + expect(resolveAppUser).toHaveBeenCalledWith("12345", "octocat"); + expect(result).toBeNull(); + }); + + it("returns SessionWithToken with correct shape when all conditions are met", async () => { + const session = { + githubId: "12345", + githubLogin: "octocat", + accessToken: "token-123", + }; + + (getServerSession as any).mockResolvedValue(session); + (resolveAppUser as any).mockResolvedValue({ id: "user-1" }); + + const result = await getSessionWithToken(); + + expect(result).toEqual({ + session, + accessToken: "token-123", + }); + }); + + it("populates accessToken field from the session", async () => { + const session = { + githubId: "67890", + githubLogin: "hubot", + accessToken: "gho_abcdef123456", + }; + + (getServerSession as any).mockResolvedValue(session); + (resolveAppUser as any).mockResolvedValue({ id: "user-2" }); + + const result = await getSessionWithToken(); + + expect(result?.accessToken).toBe("gho_abcdef123456"); + }); +}); \ No newline at end of file