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..d44b717 --- /dev/null +++ b/e2e/invitations.spec.ts @@ -0,0 +1,48 @@ +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"); + + await page.getByPlaceholder("invite@example.com").fill("newuser@example.com"); + await page.getByRole("button", { name: "Send" }).click(); + + await expect(page.getByText("newuser@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..332138e 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. +

+ + + +
+ + +
+
) } diff --git a/supabase/migrations/20250222000002_add_invitations_delete_policy.sql b/supabase/migrations/20250222000002_add_invitations_delete_policy.sql new file mode 100644 index 0000000..3109dba --- /dev/null +++ b/supabase/migrations/20250222000002_add_invitations_delete_policy.sql @@ -0,0 +1 @@ +CREATE POLICY "Authenticated delete invitations" ON invitations FOR DELETE USING (auth.role() = 'authenticated');