Skip to content
28 changes: 28 additions & 0 deletions slack-app-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"display_information": {
"name": "Site Status",
"description": "Posts alerts when monitored sites go down or recover.",
"background_color": "#C4453C"
},
"features": {
"bot_user": {
"display_name": "Site Status",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"incoming-webhook"
]
}
},
"settings": {
"incoming_webhooks": {
"incoming_webhooks_enabled": true
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
8 changes: 4 additions & 4 deletions src/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe("sendIncidentSlack", () => {
})

test("posts to all Slack webhooks with correct content", async () => {
mockFetch.mockResolvedValue({ ok: true })
mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") })

await sendIncidentSlack({
siteName: "Main Website",
Expand All @@ -115,7 +115,7 @@ describe("sendIncidentSlack", () => {
})

test("sends to multiple webhooks", async () => {
mockFetch.mockResolvedValue({ ok: true })
mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") })

await sendIncidentSlack({
siteName: "Main Website",
Expand Down Expand Up @@ -172,7 +172,7 @@ describe("sendIncidentSlack", () => {

test("logs error when webhook returns non-ok status", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockFetch.mockResolvedValue({ ok: false, status: 403 })
mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("Forbidden") })

await sendIncidentSlack({
siteName: "Main Website",
Expand All @@ -187,7 +187,7 @@ describe("sendIncidentSlack", () => {
})

test("omits error line when error is null", async () => {
mockFetch.mockResolvedValue({ ok: true })
mockFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("") })

await sendIncidentSlack({
siteName: "Main Website",
Expand Down
4 changes: 3 additions & 1 deletion src/app/incidents/[id]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ export async function resolveIncident(incidentId: string) {
redirect("/login")
}

await supabase
const { error } = await supabase
.from("incidents")
.update({
status: "resolved",
resolved_at: new Date().toISOString(),
})
.eq("id", incidentId)

if (error) return { error: `Failed to resolve incident: ${error.message}` }

revalidatePath(`/incidents/${incidentId}`)
revalidatePath("/")
}
5 changes: 3 additions & 2 deletions src/app/incidents/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Incident, Site, Check } from "@/lib/supabase/types"
import CheckLogTable from "@/components/CheckLogTable"
import { LocalDateTime } from "@/components/LocalTime"
import { resolveIncident } from "./actions"
import ActionForm from "@/components/ActionForm"

export const revalidate = 0

Expand Down Expand Up @@ -140,7 +141,7 @@ export default async function IncidentDetailPage({

{isOpen && user && (
<div className="mt-5 pt-5" style={{ borderTop: "1px solid #F0ECE8" }}>
<form action={resolveIncident.bind(null, incident.id)}>
<ActionForm action={resolveIncident.bind(null, incident.id)}>
<button
type="submit"
className="inline-flex items-center gap-1.5 px-5 py-2 text-sm font-semibold text-white rounded cursor-pointer transition-colors"
Expand All @@ -159,7 +160,7 @@ export default async function IncidentDetailPage({
</svg>
Resolve
</button>
</form>
</ActionForm>
</div>
)}
</div>
Expand Down
7 changes: 5 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import "./globals.css"
import Header from "@/components/Header"
import ErrorDialogProvider from "@/components/ErrorDialog"

export const metadata: Metadata = {
title: "Site Status",
Expand All @@ -15,8 +16,10 @@ export default function RootLayout({
return (
<html lang="en">
<body style={{ backgroundColor: "#FAF8F5" }}>
<Header />
{children}
<ErrorDialogProvider>
<Header />
{children}
</ErrorDialogProvider>
</body>
</html>
)
Expand Down
13 changes: 7 additions & 6 deletions src/app/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ export async function addContact(formData: FormData) {
if (type === "slack") {
const webhookUrl = formData.get("contact_webhook_url") as string
const label = formData.get("contact_label") as string
if (!webhookUrl || !label) return
if (!SLACK_WEBHOOK_REGEX.test(webhookUrl)) return
if (!webhookUrl || !label) return { error: "Webhook URL and label are both required." }
if (!SLACK_WEBHOOK_REGEX.test(webhookUrl)) return { error: "Webhook URL must be a valid Slack webhook (https://hooks.slack.com/services/...)." }
const { error } = await supabase.from("contacts").insert({ type: "slack", webhook_url: webhookUrl, label })
if (error) console.error("Failed to insert Slack contact:", error)
if (error) return { error: `Failed to add Slack contact: ${error.message}` }
} else {
const email = formData.get("contact_email") as string
if (!email) return
if (!email) return { error: "Email address is required." }
const { error } = await supabase.from("contacts").insert({ type: "email", email })
if (error) console.error("Failed to insert email contact:", error)
if (error) return { error: `Failed to add email contact: ${error.message}` }
}

revalidatePath("/settings")
Expand All @@ -41,6 +41,7 @@ export async function deleteContact(contactId: string) {

if (!user) redirect("/login")

await supabase.from("contacts").delete().eq("id", contactId)
const { error } = await supabase.from("contacts").delete().eq("id", contactId)
if (error) return { error: `Failed to remove contact: ${error.message}` }
revalidatePath("/settings")
}
12 changes: 7 additions & 5 deletions src/app/settings/invite-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function sendInvitation(formData: FormData) {
if (!user) redirect("/login")

const email = (formData.get("email") as string)?.trim()
if (!email) return
if (!email) return { error: "Email address is required." }

const token = randomBytes(32).toString("hex")

Expand All @@ -31,7 +31,7 @@ export async function sendInvitation(formData: FormData) {
token,
})

if (error) return
if (error) return { error: `Failed to create invitation: ${error.message}` }

const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org"
const inviteLink = `${appUrl}/invite/${token}`
Expand All @@ -48,8 +48,9 @@ export async function sendInvitation(formData: FormData) {
<p><a href="${inviteLink}">Click here to set your password and create your account</a></p>
`.trim(),
})
} catch (err) {
console.error("Failed to send invitation email:", err)
} catch {
revalidatePath("/settings")
return { error: "Invitation created but failed to send email. The invite link is still valid." }
}

revalidatePath("/settings")
Expand All @@ -63,6 +64,7 @@ export async function revokeInvitation(invitationId: string) {

if (!user) redirect("/login")

await supabase.from("invitations").delete().eq("id", invitationId)
const { error } = await supabase.from("invitations").delete().eq("id", invitationId)
if (error) return { error: `Failed to revoke invitation: ${error.message}` }
revalidatePath("/settings")
}
13 changes: 7 additions & 6 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Contact, Invitation } from "@/lib/supabase/types"
import { deleteContact } from "./actions"
import AddContactForm from "@/components/AddContactForm"
import { sendInvitation, revokeInvitation } from "./invite-actions"
import ActionForm from "@/components/ActionForm"
import Link from "next/link"

export const revalidate = 0
Expand Down Expand Up @@ -82,7 +83,7 @@ export default async function SettingsPage() {
</span>
{contact.type === "slack" ? (contact.label || "Slack webhook") : contact.email}
</span>
<form action={deleteContact.bind(null, contact.id)}>
<ActionForm action={deleteContact.bind(null, contact.id)}>
<button
type="submit"
className="text-[13px] px-3 py-1 rounded cursor-pointer transition-colors"
Expand All @@ -94,7 +95,7 @@ export default async function SettingsPage() {
>
Remove
</button>
</form>
</ActionForm>
</li>
))
)}
Expand Down Expand Up @@ -133,7 +134,7 @@ export default async function SettingsPage() {
<span className="text-[15px]" style={{ color: "#1A1A1A" }}>
{invitation.email}
</span>
<form action={revokeInvitation.bind(null, invitation.id)}>
<ActionForm action={revokeInvitation.bind(null, invitation.id)}>
<button
type="submit"
className="text-[13px] px-3 py-1 rounded cursor-pointer transition-colors"
Expand All @@ -145,13 +146,13 @@ export default async function SettingsPage() {
>
Revoke
</button>
</form>
</ActionForm>
</li>
))
)}
</ul>

<form action={sendInvitation} className="flex gap-2">
<ActionForm action={sendInvitation} className="flex gap-2">
<input
type="email"
name="email"
Expand All @@ -171,7 +172,7 @@ export default async function SettingsPage() {
>
Send
</button>
</form>
</ActionForm>
</div>
</main>
)
Expand Down
16 changes: 10 additions & 6 deletions src/app/sites/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function addSite(formData: FormData) {
const name = (formData.get("name") as string)?.trim()
const url = (formData.get("url") as string)?.trim()

if (!name || !url) return
if (!name || !url) return { error: "Name and URL are both required." }

const { data: maxRow } = await supabase
.from("sites")
Expand All @@ -26,7 +26,8 @@ export async function addSite(formData: FormData) {

const position = (maxRow?.position ?? -1) + 1

await supabase.from("sites").insert({ name, url, position })
const { error } = await supabase.from("sites").insert({ name, url, position })
if (error) return { error: `Failed to add site: ${error.message}` }
revalidatePath("/")
}

Expand All @@ -41,9 +42,10 @@ export async function editSite(siteId: string, formData: FormData) {
const name = (formData.get("name") as string)?.trim()
const url = (formData.get("url") as string)?.trim()

if (!name || !url) return
if (!name || !url) return { error: "Name and URL are both required." }

await supabase.from("sites").update({ name, url }).eq("id", siteId)
const { error } = await supabase.from("sites").update({ name, url }).eq("id", siteId)
if (error) return { error: `Failed to update site: ${error.message}` }
revalidatePath("/")
revalidatePath(`/sites/${siteId}`)
}
Expand All @@ -56,7 +58,8 @@ export async function deleteSite(siteId: string) {

if (!user) redirect("/login")

await supabase.from("sites").delete().eq("id", siteId)
const { error } = await supabase.from("sites").delete().eq("id", siteId)
if (error) return { error: `Failed to delete site: ${error.message}` }
revalidatePath("/")
}

Expand All @@ -69,10 +72,11 @@ export async function reorderSites(orderedIds: string[]) {
if (!user) redirect("/login")

for (let i = 0; i < orderedIds.length; i++) {
await supabase
const { error } = await supabase
.from("sites")
.update({ position: i })
.eq("id", orderedIds[i])
if (error) return { error: `Failed to reorder sites: ${error.message}` }
}

revalidatePath("/")
Expand Down
26 changes: 26 additions & 0 deletions src/components/ActionForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client"

import { useErrorDialog } from "@/components/ErrorDialog"

export default function ActionForm({
action,
children,
...props
}: {
action: (formData: FormData) => Promise<{ error: string } | undefined>
children: React.ReactNode
} & Omit<React.FormHTMLAttributes<HTMLFormElement>, "action">) {
const { showError } = useErrorDialog()

return (
<form
{...props}
action={async (formData) => {
const result = await action(formData)
if (result?.error) showError(result.error)
}}
>
{children}
</form>
)
}
10 changes: 8 additions & 2 deletions src/components/AddContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react"
import { addContact } from "@/app/settings/actions"
import { useErrorDialog } from "@/components/ErrorDialog"

const inputStyle = {
border: "1px solid #E8E4DF",
Expand All @@ -11,11 +12,16 @@ const inputStyle = {

export default function AddContactForm() {
const [type, setType] = useState<"email" | "slack">("email")
const { showError } = useErrorDialog()

return (
<form action={async (formData: FormData) => {
await addContact(formData)
setType("email")
const result = await addContact(formData)
if (result?.error) {
showError(result.error)
} else {
setType("email")
}
}} className="flex gap-2">
<input type="hidden" name="contact_type" value={type} />
<select
Expand Down
Loading