Skip to content

Commit 35bafe1

Browse files
jasoncrawfordclaude
andcommitted
Add settings page with contacts CRUD
Creates /settings page (auth-gated) with contacts list, add form, and remove buttons. Server actions for adding and deleting contacts with authentication checks. Includes 5 new tests covering add/delete operations and unauthenticated access. Closes #10 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bad87bc commit 35bafe1

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

‎src/__tests__/contacts.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, test, expect, vi, beforeEach } from "vitest"
2+
3+
// Mock next/cache
4+
vi.mock("next/cache", () => ({
5+
revalidatePath: vi.fn(),
6+
}))
7+
8+
// Mock next/navigation
9+
const mockRedirect = vi.fn()
10+
vi.mock("next/navigation", () => ({
11+
redirect: (url: string) => {
12+
mockRedirect(url)
13+
throw new Error(`NEXT_REDIRECT:${url}`)
14+
},
15+
}))
16+
17+
// Mock Supabase server client
18+
const mockFrom = vi.fn()
19+
const mockGetUser = vi.fn()
20+
21+
vi.mock("@/lib/supabase/server", () => ({
22+
createClient: () =>
23+
Promise.resolve({
24+
from: mockFrom,
25+
auth: { getUser: mockGetUser },
26+
}),
27+
}))
28+
29+
import { addContact, deleteContact } from "@/app/settings/actions"
30+
31+
describe("addContact action", () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
})
35+
36+
test("redirects unauthenticated users to login", async () => {
37+
mockGetUser.mockResolvedValue({ data: { user: null } })
38+
39+
const formData = new FormData()
40+
formData.set("email", "test@example.com")
41+
42+
await expect(addContact(formData)).rejects.toThrow("NEXT_REDIRECT:/login")
43+
})
44+
45+
test("inserts contact for authenticated users", async () => {
46+
mockGetUser.mockResolvedValue({
47+
data: { user: { id: "user-1" } },
48+
})
49+
50+
const mockInsert = vi.fn().mockReturnValue({ error: null })
51+
mockFrom.mockReturnValue({ insert: mockInsert })
52+
53+
const formData = new FormData()
54+
formData.set("email", "test@example.com")
55+
56+
await addContact(formData)
57+
expect(mockFrom).toHaveBeenCalledWith("contacts")
58+
expect(mockInsert).toHaveBeenCalledWith({ email: "test@example.com" })
59+
})
60+
61+
test("does nothing if email is empty", async () => {
62+
mockGetUser.mockResolvedValue({
63+
data: { user: { id: "user-1" } },
64+
})
65+
66+
const formData = new FormData()
67+
await addContact(formData)
68+
expect(mockFrom).not.toHaveBeenCalled()
69+
})
70+
})
71+
72+
describe("deleteContact action", () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
})
76+
77+
test("redirects unauthenticated users to login", async () => {
78+
mockGetUser.mockResolvedValue({ data: { user: null } })
79+
80+
await expect(deleteContact("contact-1")).rejects.toThrow("NEXT_REDIRECT:/login")
81+
})
82+
83+
test("deletes contact for authenticated users", async () => {
84+
mockGetUser.mockResolvedValue({
85+
data: { user: { id: "user-1" } },
86+
})
87+
88+
const mockEq = vi.fn().mockReturnValue({ error: null })
89+
const mockDelete = vi.fn().mockReturnValue({ eq: mockEq })
90+
mockFrom.mockReturnValue({ delete: mockDelete })
91+
92+
await deleteContact("contact-1")
93+
expect(mockFrom).toHaveBeenCalledWith("contacts")
94+
expect(mockEq).toHaveBeenCalledWith("id", "contact-1")
95+
})
96+
})

‎src/app/settings/actions.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use server"
2+
3+
import { revalidatePath } from "next/cache"
4+
import { redirect } from "next/navigation"
5+
import { createClient } from "@/lib/supabase/server"
6+
7+
export async function addContact(formData: FormData) {
8+
const supabase = await createClient()
9+
const {
10+
data: { user },
11+
} = await supabase.auth.getUser()
12+
13+
if (!user) redirect("/login")
14+
15+
const email = formData.get("email") as string
16+
if (!email) return
17+
18+
await supabase.from("contacts").insert({ email })
19+
revalidatePath("/settings")
20+
}
21+
22+
export async function deleteContact(contactId: string) {
23+
const supabase = await createClient()
24+
const {
25+
data: { user },
26+
} = await supabase.auth.getUser()
27+
28+
if (!user) redirect("/login")
29+
30+
await supabase.from("contacts").delete().eq("id", contactId)
31+
revalidatePath("/settings")
32+
}

‎src/app/settings/page.tsx‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { redirect } from "next/navigation"
2+
import { createClient } from "@/lib/supabase/server"
3+
import type { Contact } from "@/lib/supabase/types"
4+
import { addContact, deleteContact } from "./actions"
5+
import Link from "next/link"
6+
7+
export const revalidate = 0
8+
9+
export default async function SettingsPage() {
10+
const supabase = await createClient()
11+
const {
12+
data: { user },
13+
} = await supabase.auth.getUser()
14+
15+
if (!user) redirect("/login")
16+
17+
const { data: contacts } = await supabase
18+
.from("contacts")
19+
.select("*")
20+
.order("created_at")
21+
22+
const typedContacts = (contacts ?? []) as Contact[]
23+
24+
return (
25+
<main className="max-w-[720px] mx-auto" style={{ padding: "32px 24px 64px" }}>
26+
<div className="mb-7">
27+
<Link
28+
href="/"
29+
className="text-sm no-underline"
30+
style={{ color: "#C4453C" }}
31+
>
32+
&larr; Back
33+
</Link>
34+
</div>
35+
36+
<div
37+
className="rounded mb-6"
38+
style={{
39+
backgroundColor: "#FFFFFF",
40+
border: "1px solid #E8E4DF",
41+
padding: "28px",
42+
}}
43+
>
44+
<h2 className="text-xl font-bold mb-1.5" style={{ color: "#1A1A1A" }}>
45+
Contacts
46+
</h2>
47+
<p className="text-sm mb-5" style={{ color: "#5C5C5C" }}>
48+
All contacts are notified by email when an incident is opened.
49+
</p>
50+
51+
<ul className="list-none mb-5">
52+
{typedContacts.length === 0 ? (
53+
<li className="py-2.5 text-sm" style={{ color: "#5C5C5C" }}>
54+
No contacts added yet.
55+
</li>
56+
) : (
57+
typedContacts.map((contact) => (
58+
<li
59+
key={contact.id}
60+
className="flex items-center justify-between py-2.5"
61+
style={{ borderBottom: "1px solid #F0EDEA" }}
62+
>
63+
<span className="text-[15px]" style={{ color: "#1A1A1A" }}>
64+
{contact.email}
65+
</span>
66+
<form action={deleteContact.bind(null, contact.id)}>
67+
<button
68+
type="submit"
69+
className="text-[13px] px-3 py-1 rounded cursor-pointer transition-colors"
70+
style={{
71+
backgroundColor: "transparent",
72+
color: "#5C5C5C",
73+
border: "1px solid #E8E4DF",
74+
}}
75+
>
76+
Remove
77+
</button>
78+
</form>
79+
</li>
80+
))
81+
)}
82+
</ul>
83+
84+
<form action={addContact} className="flex gap-2">
85+
<input
86+
type="email"
87+
name="email"
88+
placeholder="email@example.com"
89+
required
90+
className="flex-1 text-sm px-3 py-2 rounded outline-none transition-colors"
91+
style={{
92+
border: "1px solid #E8E4DF",
93+
backgroundColor: "#FFFFFF",
94+
color: "#1A1A1A",
95+
}}
96+
/>
97+
<button
98+
type="submit"
99+
className="text-sm font-medium px-4 py-2 rounded cursor-pointer text-white"
100+
style={{ backgroundColor: "#2C2C2C" }}
101+
>
102+
Add
103+
</button>
104+
</form>
105+
</div>
106+
</main>
107+
)
108+
}

0 commit comments

Comments
 (0)