diff --git a/src/__tests__/sites-crud.test.ts b/src/__tests__/sites-crud.test.ts new file mode 100644 index 0000000..6d61d16 --- /dev/null +++ b/src/__tests__/sites-crud.test.ts @@ -0,0 +1,156 @@ +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 mockFrom = vi.fn() +const mockGetUser = vi.fn() + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + from: mockFrom, + auth: { getUser: mockGetUser }, + }), +})) + +import { addSite, editSite, deleteSite } from "@/app/sites/actions" + +describe("addSite action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const formData = new FormData() + formData.set("name", "Test Site") + formData.set("url", "https://example.com") + + await expect(addSite(formData)).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("inserts site for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockInsert = vi.fn().mockReturnValue({ error: null }) + mockFrom.mockReturnValue({ insert: mockInsert }) + + const formData = new FormData() + formData.set("name", "Test Site") + formData.set("url", "https://example.com") + + await addSite(formData) + expect(mockFrom).toHaveBeenCalledWith("sites") + expect(mockInsert).toHaveBeenCalledWith({ + name: "Test Site", + url: "https://example.com", + }) + }) + + test("does nothing if name or url is empty", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + formData.set("name", "Test Site") + // url is missing + + await addSite(formData) + expect(mockFrom).not.toHaveBeenCalled() + }) +}) + +describe("editSite action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const formData = new FormData() + formData.set("name", "Updated") + formData.set("url", "https://example.com") + + await expect(editSite("site-1", formData)).rejects.toThrow( + "NEXT_REDIRECT:/login" + ) + }) + + test("updates site for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockUpdate = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ update: mockUpdate }) + + const formData = new FormData() + formData.set("name", "Updated Site") + formData.set("url", "https://updated.com") + + await editSite("site-1", formData) + expect(mockFrom).toHaveBeenCalledWith("sites") + expect(mockUpdate).toHaveBeenCalledWith({ + name: "Updated Site", + url: "https://updated.com", + }) + expect(mockEq).toHaveBeenCalledWith("id", "site-1") + }) + + test("does nothing if name or url is empty", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + // both fields empty + + await editSite("site-1", formData) + expect(mockFrom).not.toHaveBeenCalled() + }) +}) + +describe("deleteSite action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(deleteSite("site-1")).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("deletes site for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockDelete = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ delete: mockDelete }) + + await deleteSite("site-1") + expect(mockFrom).toHaveBeenCalledWith("sites") + expect(mockEq).toHaveBeenCalledWith("id", "site-1") + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 9615789..e6b3aa0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,8 @@ import Link from "next/link" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" import { formatTimeAgo, formatDuration } from "@/lib/format" +import AddSiteCard from "@/components/AddSiteCard" +import EditSiteButton from "@/components/EditSiteButton" export const revalidate = 0 @@ -37,14 +39,19 @@ async function getStatusData() { }) } + const { + data: { user }, + } = await supabase.auth.getUser() + return { sites: sitesWithChecks, incidents: (openIncidents ?? []) as IncidentWithSite[], + isAdmin: !!user, } } export default async function StatusPage() { - const { sites, incidents } = await getStatusData() + const { sites, incidents, isAdmin } = await getStatusData() return (
- {sites.length === 0 ? ( + {sites.length === 0 && !isAdmin ? (

No sites are being monitored yet.

) : (
{sites.map((site) => ( - -
- - - {site.name} - -
-
- {site.url} -
-
- {site.lastCheck - ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` - : "No checks yet"} -
- +
+
+ + + {site.name} + +
+
+
+ {site.url} +
+
+ {site.lastCheck + ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` + : "No checks yet"} +
+ + {isAdmin && ( +
+ +
+ )} +
))} + {isAdmin && } )} diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts new file mode 100644 index 0000000..c44080c --- /dev/null +++ b/src/app/sites/actions.ts @@ -0,0 +1,52 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" + +export async function addSite(formData: FormData) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + const name = (formData.get("name") as string)?.trim() + const url = (formData.get("url") as string)?.trim() + + if (!name || !url) return + + await supabase.from("sites").insert({ name, url }) + revalidatePath("/") +} + +export async function editSite(siteId: string, formData: FormData) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + const name = (formData.get("name") as string)?.trim() + const url = (formData.get("url") as string)?.trim() + + if (!name || !url) return + + await supabase.from("sites").update({ name, url }).eq("id", siteId) + revalidatePath("/") + revalidatePath(`/sites/${siteId}`) +} + +export async function deleteSite(siteId: string) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + await supabase.from("sites").delete().eq("id", siteId) + revalidatePath("/") +} diff --git a/src/components/AddSiteCard.tsx b/src/components/AddSiteCard.tsx new file mode 100644 index 0000000..348cfb4 --- /dev/null +++ b/src/components/AddSiteCard.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useState } from "react" +import { addSite } from "@/app/sites/actions" + +export default function AddSiteCard() { + const [isOpen, setIsOpen] = useState(false) + + if (!isOpen) { + return ( + + ) + } + + return ( +
+
{ + await addSite(formData) + setIsOpen(false) + }} + className="flex flex-col gap-2" + > + + +
+ + +
+
+
+ ) +} diff --git a/src/components/EditSiteButton.tsx b/src/components/EditSiteButton.tsx new file mode 100644 index 0000000..f042ecd --- /dev/null +++ b/src/components/EditSiteButton.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useState } from "react" +import { editSite, deleteSite } from "@/app/sites/actions" + +export default function EditSiteButton({ + siteId, + name, + url, +}: { + siteId: string + name: string + url: string +}) { + const [isEditing, setIsEditing] = useState(false) + + if (!isEditing) { + return ( + + ) + } + + return ( +
+
+ + +
+ + + +
+
+
+ ) +}