diff --git a/slack-app-manifest.json b/slack-app-manifest.json new file mode 100644 index 0000000..c29abae --- /dev/null +++ b/slack-app-manifest.json @@ -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 + } +} diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 4a174b8..aa88187 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/src/app/incidents/[id]/actions.ts b/src/app/incidents/[id]/actions.ts index c30678b..814723a 100644 --- a/src/app/incidents/[id]/actions.ts +++ b/src/app/incidents/[id]/actions.ts @@ -15,7 +15,7 @@ export async function resolveIncident(incidentId: string) { redirect("/login") } - await supabase + const { error } = await supabase .from("incidents") .update({ status: "resolved", @@ -23,6 +23,8 @@ export async function resolveIncident(incidentId: string) { }) .eq("id", incidentId) + if (error) return { error: `Failed to resolve incident: ${error.message}` } + revalidatePath(`/incidents/${incidentId}`) revalidatePath("/") } diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx index d708242..9fc4900 100644 --- a/src/app/incidents/[id]/page.tsx +++ b/src/app/incidents/[id]/page.tsx @@ -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 @@ -140,7 +141,7 @@ export default async function IncidentDetailPage({ {isOpen && user && (
-
+ - +
)} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0e91410..9a51953 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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", @@ -15,8 +16,10 @@ export default function RootLayout({ return ( -
- {children} + +
+ {children} + ) diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 8953a38..754458c 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -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") @@ -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") } diff --git a/src/app/settings/invite-actions.ts b/src/app/settings/invite-actions.ts index 74ac4d0..246cd80 100644 --- a/src/app/settings/invite-actions.ts +++ b/src/app/settings/invite-actions.ts @@ -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") @@ -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}` @@ -48,8 +48,9 @@ export async function sendInvitation(formData: FormData) {

Click here to set your password and create your account

`.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") @@ -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") } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e87ab8c..82fd070 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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 @@ -82,7 +83,7 @@ export default async function SettingsPage() { {contact.type === "slack" ? (contact.label || "Slack webhook") : contact.email} -
+ - +
)) )} @@ -133,7 +134,7 @@ export default async function SettingsPage() { {invitation.email} -
+ - +
)) )} -
+ Send - +
) diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts index 8e4bb95..443357a 100644 --- a/src/app/sites/actions.ts +++ b/src/app/sites/actions.ts @@ -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") @@ -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("/") } @@ -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}`) } @@ -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("/") } @@ -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("/") diff --git a/src/components/ActionForm.tsx b/src/components/ActionForm.tsx new file mode 100644 index 0000000..8659c6f --- /dev/null +++ b/src/components/ActionForm.tsx @@ -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, "action">) { + const { showError } = useErrorDialog() + + return ( +
{ + const result = await action(formData) + if (result?.error) showError(result.error) + }} + > + {children} +
+ ) +} diff --git a/src/components/AddContactForm.tsx b/src/components/AddContactForm.tsx index 8090d14..2110cf2 100644 --- a/src/components/AddContactForm.tsx +++ b/src/components/AddContactForm.tsx @@ -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", @@ -11,11 +12,16 @@ const inputStyle = { export default function AddContactForm() { const [type, setType] = useState<"email" | "slack">("email") + const { showError } = useErrorDialog() return (
{ - await addContact(formData) - setType("email") + const result = await addContact(formData) + if (result?.error) { + showError(result.error) + } else { + setType("email") + } }} className="flex gap-2">