From 5a678fd414fc17f7587883aa62d106e14f54933c Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 19:00:48 -0800 Subject: [PATCH 1/2] fix: restore missing invitation UI on settings page (#49) The invitation backend (server actions, DB, email) was added in PR #25 but the settings page UI to send/manage invitations was never wired up. Adds the Invitations card and E2E tests. Co-Authored-By: Claude Opus 4.6 --- e2e/global-setup.ts | 1 + e2e/invitations.spec.ts | 50 ++++++++++++++++++++++++ playwright.config.ts | 2 +- src/app/settings/page.tsx | 82 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 e2e/invitations.spec.ts diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index d0dc8da..9bebb98 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -12,6 +12,7 @@ export default async function globalSetup() { await admin.from("incidents").delete().neq("id", "00000000-0000-0000-0000-000000000000"); await admin.from("checks").delete().neq("id", "00000000-0000-0000-0000-000000000000"); await admin.from("contacts").delete().neq("id", "00000000-0000-0000-0000-000000000000"); + await admin.from("invitations").delete().neq("id", "00000000-0000-0000-0000-000000000000"); await admin.from("sites").delete().neq("id", "00000000-0000-0000-0000-000000000000"); // Delete existing test user if present diff --git a/e2e/invitations.spec.ts b/e2e/invitations.spec.ts new file mode 100644 index 0000000..486a9ef --- /dev/null +++ b/e2e/invitations.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "./fixtures"; + +test.afterEach(async ({ adminClient }) => { + await adminClient + .from("invitations") + .delete() + .neq("id", "00000000-0000-0000-0000-000000000000"); +}); + +test("shows Invitations heading on settings page", async ({ page }) => { + await page.goto("/settings"); + await expect( + page.getByRole("heading", { name: "Invitations" }) + ).toBeVisible(); +}); + +test("shows empty state", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByText("No pending invitations.")).toBeVisible(); +}); + +test("sends an invitation", async ({ page }) => { + await page.goto("/settings"); + + // The invitations section has its own email input — locate it by the section + const invitationsSection = page.locator("text=Invite new administrators by email.").locator(".."); + await invitationsSection.getByPlaceholder("email@example.com").fill("invite@example.com"); + await invitationsSection.getByRole("button", { name: "Send" }).click(); + + await expect(page.getByText("invite@example.com")).toBeVisible(); +}); + +test("revokes an invitation", async ({ page, adminClient, testData }) => { + // Seed an invitation + await adminClient.from("invitations").insert({ + email: "revoke-me@example.com", + invited_by: testData.userId, + token: "test-token-revoke", + }); + + await page.goto("/settings"); + await expect(page.getByText("revoke-me@example.com")).toBeVisible(); + + // Click the Revoke button next to the specific invitation + const invitationRow = page + .getByRole("listitem") + .filter({ hasText: "revoke-me@example.com" }); + await invitationRow.getByRole("button", { name: "Revoke" }).click(); + await expect(page.getByText("revoke-me@example.com")).not.toBeVisible(); +}); diff --git a/playwright.config.ts b/playwright.config.ts index f2c0c45..f96861d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,7 +39,7 @@ export default defineConfig({ storageState: "e2e/.auth/user.json", }, dependencies: ["public"], - testMatch: [/contacts\.spec\.ts/, /sites-crud\.spec\.ts/, /incidents\.spec\.ts/], + testMatch: [/contacts\.spec\.ts/, /invitations\.spec\.ts/, /sites-crud\.spec\.ts/, /incidents\.spec\.ts/], }, ], webServer: { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 27abcdd..93d5247 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation" import { createClient } from "@/lib/supabase/server" -import type { Contact } from "@/lib/supabase/types" +import type { Contact, Invitation } from "@/lib/supabase/types" import { addContact, deleteContact } from "./actions" +import { sendInvitation, revokeInvitation } from "./invite-actions" import Link from "next/link" export const revalidate = 0 @@ -21,6 +22,14 @@ export default async function SettingsPage() { const typedContacts = (contacts ?? []) as Contact[] + const { data: invitations } = await supabase + .from("invitations") + .select("*") + .is("accepted_at", null) + .order("created_at") + + const typedInvitations = (invitations ?? []) as Invitation[] + return (
@@ -103,6 +112,77 @@ export default async function SettingsPage() {
+ +
+

+ Invitations +

+

+ Invite new administrators by email. +

+ +
    + {typedInvitations.length === 0 ? ( +
  • + No pending invitations. +
  • + ) : ( + typedInvitations.map((invitation) => ( +
  • + + {invitation.email} + +
    + +
    +
  • + )) + )} +
+ +
+ + +
+
) } From 2e5ece6ae62a3b54a36d063f7ef488cbbd6956a7 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 19:23:22 -0800 Subject: [PATCH 2/2] fix: add invitations DELETE RLS policy and unique placeholder - Add missing DELETE policy for invitations table (revoke was silently failing) - Use distinct placeholder for invitations email input to avoid ambiguity with contacts input, fixing the contacts "adds a contact" E2E test Co-Authored-By: Claude Opus 4.6 --- e2e/invitations.spec.ts | 8 +++----- src/app/settings/page.tsx | 2 +- .../20250222000002_add_invitations_delete_policy.sql | 1 + 3 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/20250222000002_add_invitations_delete_policy.sql diff --git a/e2e/invitations.spec.ts b/e2e/invitations.spec.ts index 486a9ef..d44b717 100644 --- a/e2e/invitations.spec.ts +++ b/e2e/invitations.spec.ts @@ -22,12 +22,10 @@ test("shows empty state", async ({ page }) => { test("sends an invitation", async ({ page }) => { await page.goto("/settings"); - // The invitations section has its own email input — locate it by the section - const invitationsSection = page.locator("text=Invite new administrators by email.").locator(".."); - await invitationsSection.getByPlaceholder("email@example.com").fill("invite@example.com"); - await invitationsSection.getByRole("button", { name: "Send" }).click(); + await page.getByPlaceholder("invite@example.com").fill("newuser@example.com"); + await page.getByRole("button", { name: "Send" }).click(); - await expect(page.getByText("invite@example.com")).toBeVisible(); + await expect(page.getByText("newuser@example.com")).toBeVisible(); }); test("revokes an invitation", async ({ page, adminClient, testData }) => { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 93d5247..332138e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -165,7 +165,7 @@ export default async function SettingsPage() {