Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions e2e/invitations.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
82 changes: 81 additions & 1 deletion src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<main className="max-w-[720px] mx-auto" style={{ padding: "32px 24px 64px" }}>
<div className="mb-7">
Expand Down Expand Up @@ -103,6 +112,77 @@ export default async function SettingsPage() {
</button>
</form>
</div>

<div
className="rounded mb-6"
style={{
backgroundColor: "#FFFFFF",
border: "1px solid #E8E4DF",
padding: "28px",
}}
>
<h2 className="text-xl font-bold mb-1.5" style={{ color: "#1A1A1A" }}>
Invitations
</h2>
<p className="text-sm mb-5" style={{ color: "#5C5C5C" }}>
Invite new administrators by email.
</p>

<ul className="list-none mb-5">
{typedInvitations.length === 0 ? (
<li className="py-2.5 text-sm" style={{ color: "#5C5C5C" }}>
No pending invitations.
</li>
) : (
typedInvitations.map((invitation) => (
<li
key={invitation.id}
className="flex items-center justify-between py-2.5"
style={{ borderBottom: "1px solid #F0EDEA" }}
>
<span className="text-[15px]" style={{ color: "#1A1A1A" }}>
{invitation.email}
</span>
<form action={revokeInvitation.bind(null, invitation.id)}>
<button
type="submit"
className="text-[13px] px-3 py-1 rounded cursor-pointer transition-colors"
style={{
backgroundColor: "transparent",
color: "#5C5C5C",
border: "1px solid #E8E4DF",
}}
>
Revoke
</button>
</form>
</li>
))
)}
</ul>

<form action={sendInvitation} className="flex gap-2">
<input
type="email"
name="email"
placeholder="invite@example.com"
required
className="flex-1 text-sm px-3 py-2 rounded outline-none transition-colors"
style={{
border: "1px solid #E8E4DF",
backgroundColor: "#FFFFFF",
color: "#1A1A1A",
}}
/>
<button
type="submit"
className="text-sm font-medium px-4 py-2 rounded cursor-pointer text-white"
style={{ backgroundColor: "#2C2C2C" }}
>
Send
</button>
</form>
</div>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE POLICY "Authenticated delete invitations" ON invitations FOR DELETE USING (auth.role() = 'authenticated');