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