diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts new file mode 100644 index 0000000..53f1f2f --- /dev/null +++ b/src/__tests__/auth.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock next/cache +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +// Mock next/navigation +const mockRedirect = vi.fn() +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + mockRedirect(url) + throw new Error(`NEXT_REDIRECT:${url}`) + }, +})) + +// Mock Supabase server client +const mockSignInWithPassword = vi.fn() +const mockSignOut = vi.fn() + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + auth: { + signInWithPassword: mockSignInWithPassword, + signOut: mockSignOut, + }, + }), +})) + +import { login, logout } from "@/app/login/actions" + +describe("login action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns error when email or password is missing", async () => { + const formData = new FormData() + const result = await login(formData) + expect(result).toEqual({ error: "Email and password are required" }) + }) + + test("returns error on invalid credentials", async () => { + mockSignInWithPassword.mockResolvedValue({ + error: { message: "Invalid login credentials" }, + }) + + const formData = new FormData() + formData.set("email", "test@example.com") + formData.set("password", "wrong-password") + + const result = await login(formData) + expect(result).toEqual({ error: "Invalid email or password" }) + }) + + test("redirects to / on successful login", async () => { + mockSignInWithPassword.mockResolvedValue({ error: null }) + + const formData = new FormData() + formData.set("email", "test@example.com") + formData.set("password", "correct-password") + + await expect(login(formData)).rejects.toThrow("NEXT_REDIRECT:/") + expect(mockRedirect).toHaveBeenCalledWith("/") + }) +}) + +describe("logout action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("signs out and redirects to /login", async () => { + mockSignOut.mockResolvedValue({ error: null }) + + await expect(logout()).rejects.toThrow("NEXT_REDIRECT:/login") + expect(mockSignOut).toHaveBeenCalled() + expect(mockRedirect).toHaveBeenCalledWith("/login") + }) +}) diff --git a/src/__tests__/middleware.test.ts b/src/__tests__/middleware.test.ts new file mode 100644 index 0000000..43170f9 --- /dev/null +++ b/src/__tests__/middleware.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { NextRequest } from "next/server" + +// Mock Supabase SSR +const mockGetUser = vi.fn() +vi.mock("@supabase/ssr", () => ({ + createServerClient: () => ({ + auth: { + getUser: mockGetUser, + }, + }), +})) + +import { middleware } from "@/middleware" + +function createRequest(pathname: string) { + return new NextRequest(new URL(pathname, "http://localhost:3000")) +} + +describe("middleware", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:54321" + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "test-anon-key" + }) + + test("redirects unauthenticated users from /settings to /login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const response = await middleware(createRequest("/settings")) + expect(response.status).toBe(307) + expect(new URL(response.headers.get("location")!).pathname).toBe("/login") + }) + + test("allows unauthenticated users to access /", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const response = await middleware(createRequest("/")) + expect(response.status).toBe(200) + }) + + test("allows authenticated users to access /settings", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1", email: "test@example.com" } }, + }) + + const response = await middleware(createRequest("/settings")) + expect(response.status).toBe(200) + }) + + test("redirects authenticated users away from /login", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1", email: "test@example.com" } }, + }) + + const response = await middleware(createRequest("/login")) + expect(response.status).toBe(307) + expect(new URL(response.headers.get("location")!).pathname).toBe("/") + }) +}) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1876405..0e91410 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,23 @@ -import type { Metadata } from "next"; -import "./globals.css"; +import type { Metadata } from "next" +import "./globals.css" +import Header from "@/components/Header" export const metadata: Metadata = { title: "Site Status", description: "Uptime monitoring for Roots of Progress", -}; +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - {children} + +
+ {children} + - ); + ) } diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts new file mode 100644 index 0000000..c7d97a5 --- /dev/null +++ b/src/app/login/actions.ts @@ -0,0 +1,35 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" + +export async function login(formData: FormData) { + const supabase = await createClient() + + const email = formData.get("email") as string + const password = formData.get("password") as string + + if (!email || !password) { + return { error: "Email and password are required" } + } + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + if (error) { + return { error: "Invalid email or password" } + } + + revalidatePath("/", "layout") + redirect("/") +} + +export async function logout() { + const supabase = await createClient() + await supabase.auth.signOut() + revalidatePath("/", "layout") + redirect("/login") +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..afac6b9 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useState } from "react" +import { login } from "./actions" +import Link from "next/link" + +export default function LoginPage() { + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function handleSubmit(formData: FormData) { + setPending(true) + setError(null) + const result = await login(formData) + if (result?.error) { + setError(result.error) + setPending(false) + } + } + + return ( +
+
+
+
+ Roots of Progress +
+
+ Site Status +
+
+ +
+

+ Log in +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ + +
+
+ + + ← Back to status page + +
+
+ ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..8768b78 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,76 @@ +import Link from "next/link" +import { createClient } from "@/lib/supabase/server" +import { logout } from "@/app/login/actions" + +export default async function Header() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + return ( +
+
+ + + Roots of Progress + + + + Site Status + + + + +
+
+ ) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..8fbe42e --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,62 @@ +import { createServerClient } from "@supabase/ssr" +import { NextResponse, type NextRequest } from "next/server" + +const protectedRoutes = ["/settings"] + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + const { + data: { user }, + } = await supabase.auth.getUser() + + const pathname = request.nextUrl.pathname + + if ( + !user && + protectedRoutes.some((route) => pathname.startsWith(route)) + ) { + const url = request.nextUrl.clone() + url.pathname = "/login" + return NextResponse.redirect(url) + } + + if (user && pathname === "/login") { + const url = request.nextUrl.clone() + url.pathname = "/" + return NextResponse.redirect(url) + } + + return supabaseResponse +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +} diff --git a/vitest.config.ts b/vitest.config.ts index 324c1ee..366fb53 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import path from "path"; export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, test: { include: ["src/**/*.test.ts", "src/**/*.test.tsx"], },