diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 520e042ade..f3fe7bfefd 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -10160,11 +10160,13 @@ title = "Users" you = "(you)" [users.action] +cancelInvite = "Cancel invitation" deleteTeam = "Delete team" disableMfa = "Reset MFA" move = "Move to team" reinstate = "Reinstate" remove = "Remove from org" +removeTeam = "Remove from team" rename = "Rename team" resetPw = "Reset password" suspend = "Suspend" @@ -10177,11 +10179,14 @@ editor = "Editor" processor = "Processor" [users.confirm] +cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link." +cancelInviteTitle = "Cancel invitation" deleteTeamBody = "Delete the {{name}} team? The team must be empty first - move its members to another team, and it can't still own any integration configs." deleteTeamTitle = "Delete team" disableMfaBody = "Remove {{name}}'s MFA enrolment? They'll set it up again on next login if required." disableMfaTitle = "Reset MFA" removeBody = "Permanently remove {{name}} from the organization? This cannot be undone." +removeTeamBody = "Remove {{name}} from the team? They keep their account but lose access to this team's resources." removeTitle = "Remove member" [users.empty] @@ -10232,6 +10237,14 @@ username = "Username" usernameError = "Username must be at least 3 characters" usernamePlaceholder = "jsmith" +[users.invites] +by = "Invited by {{who}}" +cancel = "Cancel" +count = "{{count}} pending" +desc = "Invited people who haven't joined yet. They hold a seat until they accept." +expires = "Expires" +title = "Pending invitations" + [users.loadError] description = "Something went wrong reaching the backend, or you don't have access. Try again." title = "Couldn't load members" diff --git a/frontend/editor/src/portal/api/teams.ts b/frontend/editor/src/portal/api/teams.ts index 8ce2e36d30..e57663709b 100644 --- a/frontend/editor/src/portal/api/teams.ts +++ b/frontend/editor/src/portal/api/teams.ts @@ -12,6 +12,8 @@ export interface Team { userCount: number; /** Usernames of the team's owners (LEADER memberships). */ owners: string[]; + /** SaaS: an auto-created personal team (can't be renamed/deleted). Undefined self-hosted. */ + isPersonal?: boolean; } interface TeamsDto { diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 6100999956..3505238927 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -72,6 +72,22 @@ export interface Role { tone: "purple" | "blue" | "green" | "amber" | "neutral"; } +/** + * A pending team invitation (SaaS only). Mapped from SaasTeamController's + * InvitationDTO; self-hosted has no pending-invite concept (invites create the + * account immediately) so the roster's `invitations` list stays empty there. + */ +export interface PendingInvitation { + /** Backend invitationId, used for cancel. */ + id: number; + /** Invitee email. */ + email: string; + /** Who sent it (inviter email), for context. */ + invitedBy?: string; + /** ISO expiry, if the backend surfaces one. */ + expiresAt?: string; +} + /* ──────────────────────────────────────────────────────────────────────── */ /* Access controls (tier-scoped) */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -125,6 +141,8 @@ export interface UsersResponse { members: Member[]; roles: Role[]; access: AccessControls; + /** Pending team invitations (SaaS); undefined/empty on self-hosted. */ + invitations?: PendingInvitation[]; /** Whether SMTP is configured (gates emailing passwords/invites). */ mailEnabled: boolean; /** Whether email invites will work: SMTP on AND mail.enableInvites=true. Gates the diff --git a/frontend/editor/src/portal/api/usersBackend.saas.test.ts b/frontend/editor/src/portal/api/usersBackend.saas.test.ts new file mode 100644 index 0000000000..d8facade5b --- /dev/null +++ b/frontend/editor/src/portal/api/usersBackend.saas.test.ts @@ -0,0 +1,202 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { + teamSaasHandlers, + resetTeamSaasStore, +} from "@portal/mocks/handlers/teamSaas"; + +// apiClient.local attaches a stored bearer + (transitively) touches the Supabase +// client at import; stub both so the local transport stays hermetic. MSW +// intercepts the relative /api/v1/team/* URLs regardless of the (absent) token. +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +// The SaaS usersBackend lives under src/saas; the portal vitest project resolves +// @app to proprietary (there's no @saas alias here), so the SaaS impl can only be +// exercised by importing it directly by path. +// eslint-disable-next-line no-restricted-imports +import { usersBackend } from "../../saas/portal/usersBackend"; + +const server = setupServer(...teamSaasHandlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +beforeEach(() => resetTeamSaasStore()); + +describe("saas usersBackend — fetchUsers mapping", () => { + it("maps SaasTeamController members + invitations onto UsersResponse", async () => { + const res = await usersBackend.fetchUsers("pro"); + + // 3 members from the store; leader → team_owner, the rest → member. + expect(res.members).toHaveLength(3); + const leader = res.members.find((m) => m.email === "leader@acme.com")!; + expect(leader.role).toBe("team_owner"); + expect(leader.teamLead).toBe(true); + // Leader is the viewer on SaaS → self (self-remove disabled) + portal access. + expect(leader.isSelf).toBe(true); + expect(leader.canAccessPortal).toBe(true); + expect(leader.teamId).toBe(1); + + const member = res.members.find((m) => m.email === "priya@acme.com")!; + expect(member.role).toBe("member"); + expect(member.isSelf).toBeFalsy(); + expect(member.canAccessPortal).toBe(false); + // Backend numeric id is preserved for the remove call. + expect(member.id).toBe("2"); + + // One pending invitation mapped onto PendingInvitation. + expect(res.invitations).toHaveLength(1); + expect(res.invitations![0]).toMatchObject({ + id: 101, + email: "sam.lee@acme.com", + invitedBy: "leader@acme.com", + }); + }); + + it("derives summary + seats from the resolved team", async () => { + const res = await usersBackend.fetchUsers("pro"); + expect(res.summary.totalMembers).toBe(3); + expect(res.summary.pendingInvites).toBe(1); + // Store seatsUsed = members (3) + pending (1). + expect(res.summary.seatsUsed).toBe(4); + expect(res.summary.seatLimit).toBe(10); + expect(res.access).toEqual({ tier: "pro", seatsUsed: 4, seatLimit: 10 }); + // SaaS always has email; not gated on a self-hosted SMTP config. + expect(res.mailEnabled).toBe(true); + expect(res.emailInvitesEnabled).toBe(true); + }); + + it("excludes non-pending invitations", async () => { + await usersBackend.cancelInvitation(101); + const res = await usersBackend.fetchUsers("pro"); + expect(res.invitations).toHaveLength(0); + expect(res.summary.pendingInvites).toBe(0); + }); + + it("drops PENDING invitations whose expiry has already passed", async () => { + server.use( + http.get("/api/v1/team/:teamId/invitations", () => + HttpResponse.json([ + { + invitationId: 201, + teamName: "Acme", + inviterEmail: "leader@acme.com", + inviteeEmail: "expired@acme.com", + invitationToken: "t1", + status: "PENDING", + expiresAt: "2000-01-01T00:00:00Z", + }, + { + invitationId: 202, + teamName: "Acme", + inviterEmail: "leader@acme.com", + inviteeEmail: "live@acme.com", + invitationToken: "t2", + status: "PENDING", + expiresAt: "2999-01-01T00:00:00Z", + }, + ]), + ), + ); + const res = await usersBackend.fetchUsers("pro"); + expect(res.invitations!.map((i) => i.email)).toEqual(["live@acme.com"]); + expect(res.summary.pendingInvites).toBe(1); + }); +}); + +describe("saas usersBackend — teams + auth config", () => { + it("fetchTeams returns the single resolved team", async () => { + const teams = await usersBackend.fetchTeams(); + expect(teams).toEqual([ + { id: 1, name: "Acme", userCount: 3, owners: [], isPersonal: false }, + ]); + }); + + it("fetchAuthConfig is static (no direct-create, no OAuth/SAML)", async () => { + const cfg = await usersBackend.fetchAuthConfig(); + expect(cfg).toEqual({ + canDirectCreate: false, + hasOauth: false, + hasSaml: false, + }); + }); +}); + +describe("saas usersBackend — mutations hit SaasTeamController", () => { + it("inviteMember POSTs /invite with teamId+email and shows as pending", async () => { + let seenBody: unknown = null; + server.events.on("request:start", async ({ request }) => { + if (request.method === "POST" && request.url.endsWith("/team/invite")) { + seenBody = await request.clone().json(); + } + }); + const result = await usersBackend.inviteMember("new@acme.com", "member"); + expect(result.successCount).toBe(1); + expect(seenBody).toMatchObject({ teamId: 1, email: "new@acme.com" }); + server.events.removeAllListeners(); + + const res = await usersBackend.fetchUsers("pro"); + expect(res.invitations!.map((i) => i.email)).toContain("new@acme.com"); + }); + + it("renameTeam POSTs /{teamId}/rename and the new name is read back", async () => { + await usersBackend.renameTeam(1, "Beta"); + const teams = await usersBackend.fetchTeams(); + expect(teams[0].name).toBe("Beta"); + }); + + it("removeMember DELETEs the team member and drops them from the roster", async () => { + const before = await usersBackend.fetchUsers("pro"); + const target = before.members.find((m) => m.email === "priya@acme.com")!; + await usersBackend.removeMember(target); + const after = await usersBackend.fetchUsers("pro"); + expect(after.members.map((m) => m.email)).not.toContain("priya@acme.com"); + expect(after.members).toHaveLength(2); + }); + + it("removeMember throws when the member has no team", async () => { + await expect( + usersBackend.removeMember({ + id: "9", + name: "x", + email: "x@acme.com", + role: "member", + status: "active", + lastActive: "-", + }), + ).rejects.toThrow(/no team/i); + }); + + it("cancelInvitation DELETEs the invitation", async () => { + let seenUrl: string | null = null; + server.events.on("request:start", ({ request }) => { + if ( + request.method === "DELETE" && + request.url.includes("/invitations/") + ) { + seenUrl = request.url; + } + }); + await usersBackend.cancelInvitation(101); + expect(seenUrl).toContain("/api/v1/team/invitations/101"); + server.events.removeAllListeners(); + }); +}); diff --git a/frontend/editor/src/portal/api/usersBackend.ts b/frontend/editor/src/portal/api/usersBackend.ts new file mode 100644 index 0000000000..eb294e8607 --- /dev/null +++ b/frontend/editor/src/portal/api/usersBackend.ts @@ -0,0 +1,52 @@ +/** + * Per-flavor backend for the Users page. + * + * The Users page UI is shared, but its data + mutation endpoints differ by + * flavor. Self-hosted (org-admin) talks to the proprietary admin endpoints + * (`/api/v1/user/admin/*`, `/api/v1/team/*`, `ui-data/admin-settings`), which + * require ROLE_ADMIN. SaaS users are always ROLE_USER, so those 403 there; + * instead the SaaS build talks to the invitation-based `SaasTeamController` + * (`/api/v1/team/{my,invite,{id}/members,{id}/invitations,...}`) as a team + * leader. Both go through `apiClient.local` (flavor-aware transport). + * + * Resolved at build time via the `@app/*` alias, same as `usersCapabilities`: + * `src/proprietary/portal/usersBackend.ts` (self-hosted) and + * `src/saas/portal/usersBackend.ts` (SaaS). This module is just the shared + * contract; only the flavor-divergent operations live here. Self-hosted-only + * actions (role changes, suspend, password reset, MFA, grants, create/delete + * team) stay in `@portal/api/{users,teams}` and are gated off on SaaS via + * `usersCapabilities`. + */ +import type { Tier } from "@portal/contexts/TierContext"; +import type { Team } from "@portal/api/teams"; +import type { + AdminAuthConfig, + InviteResult, + Member, + RoleId, + UsersResponse, +} from "@portal/api/users"; + +export interface UsersBackend { + /** Roster + summary + (SaaS) pending invitations, adapted onto UsersResponse. */ + fetchUsers(tier: Tier): Promise; + /** Teams shown in the roster / invite team picker. */ + fetchTeams(): Promise; + /** Login/auth config that shapes the invite modal (direct-create, OAuth/SAML). */ + fetchAuthConfig(): Promise; + /** Invite a member by email (self-hosted: admin invite; SaaS: team invite). */ + inviteMember( + email: string, + role: Extract, + teamId?: number, + ): Promise; + /** Rename a team. */ + renameTeam(teamId: number, newName: string): Promise; + /** Remove a member (self-hosted: delete account; SaaS: remove from team). */ + removeMember(member: Member): Promise; + /** + * Cancel a pending invitation by id (SaaS). Never called on self-hosted + * (gated off by `manageInvitations`); the proprietary impl rejects it. + */ + cancelInvitation(invitationId: number): Promise; +} diff --git a/frontend/editor/src/portal/api/usersCapabilities.ts b/frontend/editor/src/portal/api/usersCapabilities.ts index 151c4c1df4..dcea5487ec 100644 --- a/frontend/editor/src/portal/api/usersCapabilities.ts +++ b/frontend/editor/src/portal/api/usersCapabilities.ts @@ -28,6 +28,12 @@ export interface UsersCapabilities { renameTeam: boolean; /** Invite by email. */ emailInvite: boolean; + /** + * Manage pending invitations (list + cancel). On SaaS an invite is a pending + * TeamInvitation until accepted; self-hosted invites create the account at once, + * so there's nothing pending to manage - off there. + */ + manageInvitations: boolean; /** Create an account directly with a password (self-hosted password login). */ directCreate: boolean; /** Admin password reset. */ diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx index cf43743817..52cb597aca 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx @@ -19,9 +19,12 @@ vi.mock("@portal/contexts/TierContext", () => ({ vi.mock("@portal/api/users", () => ({ createMember: vi.fn(), fetchUsers: vi.fn().mockResolvedValue({ members: [] }), - inviteMember: vi.fn(), ROLE_LABEL: { member: "Member", admin: "Admin" }, })); +// The email invite now routes through the usersBackend seam. +vi.mock("@app/portal/usersBackend", () => ({ + usersBackend: { inviteMember: vi.fn() }, +})); vi.mock("@portal/api/access", () => ({ createGrant: vi.fn() })); import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index 515c95923e..23f8acd77d 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -4,10 +4,10 @@ import { Button, Checkbox, FormField, Input, Modal, Select } from "@app/ui"; import { createMember, fetchUsers, - inviteMember, ROLE_LABEL, type AuthType, } from "@portal/api/users"; +import { usersBackend } from "@app/portal/usersBackend"; import { createGrant } from "@portal/api/access"; import { errorMessage } from "@portal/api/http"; import type { Team } from "@portal/api/teams"; @@ -212,7 +212,11 @@ export function InviteMemberModal({ let processorApplied = true; if (mode === "email") { if (!emailValid) return; - const result = await inviteMember(email.trim(), role, teamNum); + const result = await usersBackend.inviteMember( + email.trim(), + role, + teamNum, + ); if (result?.error || result?.errors) { setSubmitError(result.error ?? result.errors ?? null); return; diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.test.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.test.tsx new file mode 100644 index 0000000000..ffecdc3f83 --- /dev/null +++ b/frontend/editor/src/portal/components/users/PendingInvitations.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, opts?: Record) => { + const base = fallback ?? key; + // Minimal interpolation so {{email}} / {{who}} assertions read naturally. + return opts + ? base.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")) + : base; + }, + }), +})); + +import { PendingInvitations } from "@portal/components/users/PendingInvitations"; +import type { PendingInvitation } from "@portal/api/users"; + +const INVITES: PendingInvitation[] = [ + { id: 101, email: "sam@acme.com", invitedBy: "leader@acme.com" }, + { id: 102, email: "dana@acme.com" }, +]; + +function renderPanel(onCancel = vi.fn()) { + render( + + + , + ); + return onCancel; +} + +describe("PendingInvitations", () => { + it("lists each pending invite with its email and inviter", () => { + renderPanel(); + expect(screen.getByText("sam@acme.com")).toBeInTheDocument(); + expect(screen.getByText("dana@acme.com")).toBeInTheDocument(); + expect(screen.getByText("Invited by leader@acme.com")).toBeInTheDocument(); + expect(screen.getByText("2 pending")).toBeInTheDocument(); + }); + + it("cancelling an invite calls back with that invitation", () => { + const onCancel = renderPanel(); + const buttons = screen.getAllByText("Cancel"); + fireEvent.click(buttons[0]); + expect(onCancel).toHaveBeenCalledWith(INVITES[0]); + }); +}); diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.tsx new file mode 100644 index 0000000000..acf9f96386 --- /dev/null +++ b/frontend/editor/src/portal/components/users/PendingInvitations.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next"; +import { Avatar, Button } from "@app/ui"; +import type { PendingInvitation } from "@portal/api/users"; +import "@portal/views/Users.css"; + +interface PendingInvitationsProps { + invitations: PendingInvitation[]; + /** Cancel a pending invite by its backend id. */ + onCancel: (invitation: PendingInvitation) => void; +} + +/** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable, + * or already past (the adapter filters expired invites, so no "expired" state). */ +function expiryLabel(iso: string | undefined, expiresWord: string): string { + if (!iso) return ""; + const ts = Date.parse(iso); + if (!Number.isFinite(ts) || ts <= Date.now()) return ""; + const days = Math.round((ts - Date.now()) / 86400000); + if (days === 0) return `${expiresWord} today`; + return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`; +} + +/** + * Pending team invitations (SaaS): the parity gap vs the editor. Each row shows + * the invitee and lets a team leader cancel the invite. Rendered only when the + * flavor supports it (manageInvitations) and there are pending invites. + */ +export function PendingInvitations({ + invitations, + onCancel, +}: PendingInvitationsProps) { + const { t } = useTranslation(); + const expiresWord = t("users.invites.expires", "Expires"); + return ( +
+
+
+ {t("users.invites.title", "Pending invitations")} + + {t( + "users.invites.desc", + "Invited people who haven't joined yet. They hold a seat until they accept.", + )} + +
+ + {t("users.invites.count", "{{count}} pending", { + count: invitations.length, + })} + +
+ {invitations.map((inv) => { + const expires = expiryLabel(inv.expiresAt, expiresWord); + return ( +
+
+ +
+ {inv.email} + {inv.invitedBy && ( + + {t("users.invites.by", "Invited by {{who}}", { + who: inv.invitedBy, + })} + + )} +
+
+ + {expires && ( + {expires} + )} + +
+ ); + })} +
+ ); +} diff --git a/frontend/editor/src/portal/components/users/RenameTeamModal.tsx b/frontend/editor/src/portal/components/users/RenameTeamModal.tsx index d9f1f4b66c..ea3de007cb 100644 --- a/frontend/editor/src/portal/components/users/RenameTeamModal.tsx +++ b/frontend/editor/src/portal/components/users/RenameTeamModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, FormField, Input, Modal } from "@app/ui"; -import { renameTeam } from "@portal/api/teams"; +import { usersBackend } from "@app/portal/usersBackend"; import { errorMessage } from "@portal/api/http"; import "@portal/views/Users.css"; @@ -37,7 +37,7 @@ export function RenameTeamModal({ setSaving(true); setError(null); try { - await renameTeam(teamId, name.trim()); + await usersBackend.renameTeam(teamId, name.trim()); onDone(); onClose(); } catch (e) { diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index b7fb277854..ff4fb4b6f1 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -13,6 +13,7 @@ const FULL_CAPS: UsersCapabilities = { deleteTeam: true, renameTeam: true, emailInvite: true, + manageInvitations: false, directCreate: true, resetPassword: true, unlock: true, @@ -33,6 +34,7 @@ const SAAS_CAPS: UsersCapabilities = { deleteTeam: false, renameTeam: true, emailInvite: true, + manageInvitations: true, directCreate: false, resetPassword: false, unlock: false, diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx new file mode 100644 index 0000000000..2c047c74e2 --- /dev/null +++ b/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, opts?: Record) => { + const base = fallback ?? key; + return opts + ? base.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")) + : base; + }, + }), +})); + +import { UsersDirectory } from "@portal/components/users/UsersDirectory"; +import type { Member } from "@portal/api/users"; +import type { Team } from "@portal/api/teams"; +// Prove the gating against the real flavor capability files. The portal vitest +// project resolves @app to proprietary and has no @saas alias, so the SaaS set is +// reached by path; the self-hosted set uses the @proprietary alias. +// eslint-disable-next-line no-restricted-imports +import { usersCapabilities as saasCaps } from "../../../saas/portal/usersCapabilities"; +import { usersCapabilities as selfHostedCaps } from "@proprietary/portal/usersCapabilities"; + +const MEMBER: Member = { + id: "2", + name: "Priya", + email: "priya@acme.com", + username: "priya@acme.com", + role: "member", + status: "active", + lastActive: "-", + teamId: 1, + teamName: "Acme", +}; +const TEAMS: Team[] = [{ id: 1, name: "Acme", userCount: 1, owners: [] }]; + +function renderDirectory(caps: typeof saasCaps, teams: Team[] = TEAMS) { + const onRemove = vi.fn(); + render( + + + , + ); + return onRemove; +} + +describe("UsersDirectory — remove action gating", () => { + it("SaaS (team scope) offers 'Remove from team'", async () => { + const onRemove = renderDirectory(saasCaps); + fireEvent.click(screen.getByRole("button", { name: "Actions for Priya" })); + const item = await screen.findByText("Remove from team"); + fireEvent.click(item); + expect(onRemove).toHaveBeenCalledWith(MEMBER); + expect(screen.queryByText("Remove from org")).not.toBeInTheDocument(); + }); + + it("self-hosted (org scope) offers 'Remove from org'", async () => { + renderDirectory(selfHostedCaps); + fireEvent.click(screen.getByRole("button", { name: "Actions for Priya" })); + expect(await screen.findByText("Remove from org")).toBeInTheDocument(); + expect(screen.queryByText("Remove from team")).not.toBeInTheDocument(); + }); + + it("hides the Rename control for a SaaS personal team (backend rejects it)", () => { + const personalTeam: Team[] = [ + { id: 1, name: "My Team", userCount: 1, owners: [], isPersonal: true }, + ]; + renderDirectory(saasCaps, personalTeam); + // No team-header kebab at all (rename is the only would-be item on SaaS). + expect( + screen.queryByRole("button", { name: "Team actions" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Rename team")).not.toBeInTheDocument(); + }); +}); + +describe("flavor capabilities — invitations + remove scope", () => { + it("SaaS manages invitations and removes at team scope", () => { + expect(saasCaps.manageInvitations).toBe(true); + expect(saasCaps.removeScope).toBe("team"); + // No SaaS user is ever ROLE_ADMIN. + expect(saasCaps.adminRole).toBe(false); + }); + + it("self-hosted has no pending-invite management and removes at org scope", () => { + expect(selfHostedCaps.manageInvitations).toBe(false); + expect(selfHostedCaps.removeScope).toBe("org"); + }); +}); diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.tsx index 1419026453..2e78a6d4be 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.tsx @@ -124,11 +124,17 @@ export function UsersDirectory({ return owners.map((u) => nameByUsername.get(u) ?? u).join(", "); } + // A team whose name/membership is managed by the system - no rename/delete. + // SaaS personal teams (isPersonal) reject rename/delete at the backend too. + function isManagedTeam(team: TeamGroup): boolean { + return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true; + } + // Whether the team-header kebab has any actions (else it isn't rendered). function teamKebabHasItems(team: TeamGroup): boolean { return ( capabilities.manageGrants || - (!SYSTEM_TEAMS.has(team.name) && + (!isManagedTeam(team) && (capabilities.renameTeam || capabilities.deleteTeam)) ); } @@ -145,10 +151,13 @@ export function UsersDirectory({ } function rowKebab(m: Member) { - // Removal is org-delete (admin-only) - the only backed path. SaaS "remove from - // team" has no endpoint, so don't offer a control that would 403 or org-delete. - const canRemove = capabilities.removeScope === "org"; - if (!rowKebabHasUpperActions(m) && !canRemove) return null; + // Both flavors have a backed remove now: org-delete (self-hosted) or + // team-remove (SaaS, via SaasTeamController's remove-member endpoint), so the + // kebab always carries at least the remove action. + const removeLabel = + capabilities.removeScope === "team" + ? t("users.action.removeTeam", "Remove from team") + : t("users.action.remove", "Remove from org"); return ( @@ -190,16 +199,14 @@ export function UsersDirectory({ {t("users.action.disableMfa", "Reset MFA")} )} - {canRemove && rowKebabHasUpperActions(m) && } - {canRemove && ( - onRemove(m)} - > - {t("users.action.remove", "Remove from org")} - - )} + {rowKebabHasUpperActions(m) && } + onRemove(m)} + > + {removeLabel} + ); @@ -423,7 +430,7 @@ export function UsersDirectory({ )} ))} - {!SYSTEM_TEAMS.has(team.name) && + {!isManagedTeam(team) && (capabilities.renameTeam || capabilities.deleteTeam) && ( <> {capabilities.manageGrants && } diff --git a/frontend/editor/src/portal/components/users/directory.ts b/frontend/editor/src/portal/components/users/directory.ts index a2663a681a..a779c57954 100644 --- a/frontend/editor/src/portal/components/users/directory.ts +++ b/frontend/editor/src/portal/components/users/directory.ts @@ -8,6 +8,8 @@ export interface TeamGroup { /** Usernames of the team owners (resolved to display names by the UI). */ owners: string[]; members: Member[]; + /** SaaS personal team - not renameable/deletable; the UI hides those controls. */ + isPersonal?: boolean; } /** The roster split into Organization owners, teams, and guests. */ @@ -42,6 +44,7 @@ export function buildDirectory(members: Member[], teams: Team[]): Directory { name: t.name, owners: t.owners, members: byTeam.get(t.id) ?? [], + isPersonal: t.isPersonal, })) .filter((g) => g.members.length > 0) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 64e885d15f..52e8ab75b9 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -9,6 +9,7 @@ import { procurementHandlers } from "@portal/mocks/handlers/procurement"; import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"; import { docsHandlers } from "@portal/mocks/handlers/docs"; import { usersHandlers } from "@portal/mocks/handlers/users"; +import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas"; import { agentsHandlers } from "@portal/mocks/handlers/agents"; import { policiesHandlers } from "@portal/mocks/handlers/policies"; import { documentsHandlers } from "@portal/mocks/handlers/documents"; @@ -28,6 +29,7 @@ export const handlers = [ ...procurementHandlers, ...procurementSaasHandlers, ...usersHandlers, + ...teamSaasHandlers, ...agentsHandlers, ...policiesHandlers, ...documentsHandlers, @@ -38,3 +40,4 @@ export const handlers = [ export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; export { resetProcurementStore } from "@portal/mocks/handlers/procurement"; +export { resetTeamSaasStore } from "@portal/mocks/handlers/teamSaas"; diff --git a/frontend/editor/src/portal/mocks/handlers/teamSaas.ts b/frontend/editor/src/portal/mocks/handlers/teamSaas.ts new file mode 100644 index 0000000000..460d0781f4 --- /dev/null +++ b/frontend/editor/src/portal/mocks/handlers/teamSaas.ts @@ -0,0 +1,175 @@ +import { http, HttpResponse, delay } from "msw"; + +/** + * Mock mode for the SaaS Users page: serves the SaasTeamController routes the + * SaaS `usersBackend` adapter calls (`/api/v1/team/*`). A tiny in-memory store + * makes invite / cancel / remove / rename reflect, so the page is exercisable + * without a live SaaS backend. NOT registered in embeddedDataHandlers - these + * paths overlap the editor's own team feature when portal shares its origin. + */ + +interface TeamDetailsDTO { + teamId: number; + name: string; + teamType: string; + isPersonal: boolean; + memberCount: number; + seatCount: number; + seatsUsed: number; + maxSeats: number; + isLeader: boolean; +} + +interface TeamMemberDTO { + id: number; + username: string; + email: string; + role: string; + joinedAt: string; +} + +interface InvitationDTO { + invitationId: number; + teamName: string; + inviterEmail: string; + inviteeEmail: string; + invitationToken: string; + status: string; + expiresAt: string; +} + +const TEAM_ID = 1; +const TEAM_NAME = "Acme"; + +interface Store { + teamName: string; + maxSeats: number; + members: TeamMemberDTO[]; + invitations: InvitationDTO[]; + nextInvitationId: number; + nextMemberId: number; +} + +function seed(): Store { + return { + teamName: TEAM_NAME, + maxSeats: 10, + members: [ + { + id: 1, + username: "leader@acme.com", + email: "leader@acme.com", + role: "LEADER", + joinedAt: "2026-01-05T09:00:00Z", + }, + { + id: 2, + username: "priya@acme.com", + email: "priya@acme.com", + role: "MEMBER", + joinedAt: "2026-02-11T09:00:00Z", + }, + { + id: 3, + username: "marcus@acme.com", + email: "marcus@acme.com", + role: "MEMBER", + joinedAt: "2026-03-02T09:00:00Z", + }, + ], + invitations: [ + { + invitationId: 101, + teamName: TEAM_NAME, + inviterEmail: "leader@acme.com", + inviteeEmail: "sam.lee@acme.com", + invitationToken: "tok-sam", + status: "PENDING", + expiresAt: "2026-12-31T00:00:00Z", + }, + ], + nextInvitationId: 102, + nextMemberId: 4, + }; +} + +let store: Store = seed(); + +/** Reset the SaaS team store between tests. */ +export function resetTeamSaasStore(): void { + store = seed(); +} + +function pendingCount(): number { + return store.invitations.filter((i) => i.status === "PENDING").length; +} + +function teamDetails(): TeamDetailsDTO { + return { + teamId: TEAM_ID, + name: store.teamName, + teamType: "PRO", + isPersonal: false, + memberCount: store.members.length, + seatCount: store.maxSeats, + seatsUsed: store.members.length + pendingCount(), + maxSeats: store.maxSeats, + isLeader: true, + }; +} + +export const teamSaasHandlers = [ + http.get("/api/v1/team/my", async () => { + await delay(80); + return HttpResponse.json([teamDetails()]); + }), + http.get("/api/v1/team/:teamId/members", () => + HttpResponse.json(store.members), + ), + http.get("/api/v1/team/:teamId/invitations", () => + HttpResponse.json(store.invitations), + ), + http.post("/api/v1/team/invite", async ({ request }) => { + const body = (await request.json()) as { teamId: number; email: string }; + const invitation: InvitationDTO = { + invitationId: store.nextInvitationId++, + teamName: store.teamName, + inviterEmail: "leader@acme.com", + inviteeEmail: body.email, + invitationToken: `tok-${body.email}`, + status: "PENDING", + expiresAt: "2026-12-31T00:00:00Z", + }; + store.invitations.push(invitation); + return HttpResponse.json(invitation); + }), + http.delete("/api/v1/team/invitations/:invitationId", ({ params }) => { + const id = Number(params.invitationId); + const inv = store.invitations.find((i) => i.invitationId === id); + if (!inv) { + return HttpResponse.json( + { error: "Invitation not found" }, + { status: 404 }, + ); + } + inv.status = "CANCELLED"; + return HttpResponse.json({ message: "Invitation cancelled" }); + }), + http.delete("/api/v1/team/:teamId/members/:memberId", ({ params }) => { + const id = Number(params.memberId); + const before = store.members.length; + store.members = store.members.filter((m) => m.id !== id); + if (store.members.length === before) { + return HttpResponse.json({ error: "Member not found" }, { status: 400 }); + } + return HttpResponse.json({ message: "Member removed successfully" }); + }), + http.post("/api/v1/team/:teamId/rename", async ({ request }) => { + const body = (await request.json()) as { newName: string }; + store.teamName = body.newName; + return HttpResponse.json({ + message: "Team renamed successfully", + newName: body.newName, + }); + }), +]; diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index a29a8aafab..f351ebeaa3 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -400,6 +400,11 @@ flex-shrink: 0; } +/* Pushes the pending-invitation row's expiry + Cancel to the right edge. */ +.portal-users__inv-spacer { + flex: 1; +} + /* Invite modal extras */ .portal-users__invite-grid { display: grid; diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx new file mode 100644 index 0000000000..7cc49c91dd --- /dev/null +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -0,0 +1,149 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter } from "react-router-dom"; +import { setupServer } from "msw/node"; +import { + teamSaasHandlers, + resetTeamSaasStore, +} from "@portal/mocks/handlers/teamSaas"; + +/** + * End-to-end SaaS Users page: renders the real view wired for the SaaS + * flavor (saas capabilities + saas usersBackend) against MSW handlers that mirror + * SaasTeamController. Exercises the whole page - roster mapping, the pending- + * invitations panel, team-scope remove, and the cancel/remove mutation flows + * through the confirm dialog - the way a team leader would use it. + */ + +// Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +// Force the SaaS flavor: the portal vitest project resolves @app to proprietary, +// so redirect the two flavor seams to their real SaaS implementations. +vi.mock("@app/portal/usersCapabilities", async () => ({ + usersCapabilities: (await import("../../saas/portal/usersCapabilities")) + .usersCapabilities, +})); +vi.mock("@app/portal/usersBackend", async () => ({ + usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend, +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, opts?: Record) => { + const base = fallback ?? key; + return opts + ? base.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")) + : base; + }, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +import { Users } from "@portal/views/Users"; + +const server = setupServer(...teamSaasHandlers); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +beforeEach(() => resetTeamSaasStore()); + +function renderUsers() { + return render( + + + + + , + ); +} + +describe("Users page (SaaS flavor, end-to-end via SaasTeamController mocks)", () => { + it("renders the team roster and the pending-invitations panel", async () => { + renderUsers(); + // Roster from GET /{teamId}/members. + expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); + expect(screen.getByText("priya@acme.com")).toBeInTheDocument(); + expect(screen.getByText("marcus@acme.com")).toBeInTheDocument(); + // Pending-invitations panel (manageInvitations capability) from /{teamId}/invitations. + expect(screen.getByText("Pending invitations")).toBeInTheDocument(); + expect(screen.getByText("sam.lee@acme.com")).toBeInTheDocument(); + }); + + it("offers team-scope removal, not org deletion", async () => { + renderUsers(); + await screen.findByText("priya@acme.com"); + fireEvent.click( + screen.getByRole("button", { name: "Actions for priya@acme.com" }), + ); + expect( + await screen.findByRole("menuitem", { name: "Remove from team" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: "Remove from org" }), + ).not.toBeInTheDocument(); + }); + + it("removes a member from the team through the confirm dialog", async () => { + renderUsers(); + await screen.findByText("priya@acme.com"); + fireEvent.click( + screen.getByRole("button", { name: "Actions for priya@acme.com" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Remove from team" }), + ); + // Confirm dialog -> DELETE /{teamId}/members/{id} -> roster refetch. + fireEvent.click( + await screen.findByRole("button", { name: "Remove from team" }), + ); + await waitFor(() => + expect(screen.queryByText("priya@acme.com")).not.toBeInTheDocument(), + ); + expect(screen.getByText("marcus@acme.com")).toBeInTheDocument(); + }); + + it("cancels a pending invitation through the confirm dialog", async () => { + renderUsers(); + await screen.findByText("sam.lee@acme.com"); + const panel = screen + .getByText("Pending invitations") + .closest("section") as HTMLElement; + fireEvent.click(within(panel).getByRole("button", { name: "Cancel" })); + // Confirm dialog -> DELETE /invitations/{id} -> refetch drops the invite. + fireEvent.click( + await screen.findByRole("button", { name: "Cancel invitation" }), + ); + await waitFor(() => + expect(screen.queryByText("sam.lee@acme.com")).not.toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 26670198d5..e8a287e6f7 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -7,31 +7,27 @@ import { useAsync } from "@portal/hooks/useAsync"; import { changeMemberRole, disableMemberMfa, - fetchAuthConfig, - fetchUsers, - removeMember, setMemberSuspended, unlockMember, type AdminAuthConfig, type Member, + type PendingInvitation, type PortalAccessState, type RoleId, type UsersResponse, } from "@portal/api/users"; +import { usersBackend } from "@app/portal/usersBackend"; import { createGrant, fetchGrants, revokeGrant, type ResourceGrant, } from "@portal/api/access"; -import { - deleteTeam as apiDeleteTeam, - fetchTeams, - type Team, -} from "@portal/api/teams"; +import { deleteTeam as apiDeleteTeam, type Team } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; +import { PendingInvitations } from "@portal/components/users/PendingInvitations"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; import { NewTeamModal } from "@portal/components/users/NewTeamModal"; import { ResetPasswordModal } from "@portal/components/users/ResetPasswordModal"; @@ -54,7 +50,7 @@ export function Users() { const [refreshKey, setRefreshKey] = useState(0); const usersState = useAsync( - () => fetchUsers(tier), + () => usersBackend.fetchUsers(tier), [tier, refreshKey], ); // Grants are ADMIN-only; skip the fetch entirely on flavors that can't manage them. @@ -62,8 +58,14 @@ export function Users() { () => (caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([])), [tier, refreshKey], ); - const teamsState = useAsync(() => fetchTeams(), [tier, refreshKey]); - const authState = useAsync(() => fetchAuthConfig(), []); + const teamsState = useAsync( + () => usersBackend.fetchTeams(), + [tier, refreshKey], + ); + const authState = useAsync( + () => usersBackend.fetchAuthConfig(), + [], + ); const [actionError, setActionError] = useState(null); const [inviteOpen, setInviteOpen] = useState(false); @@ -129,6 +131,8 @@ export function Users() { ); const teams = teamsState.data ?? []; + // Pending invites ride along with the roster fetch (SaaS); empty on self-hosted. + const invitations = usersState.data?.invitations ?? []; const mailEnabled = usersState.data?.mailEnabled ?? false; // Email invites need SMTP + mail.enableInvites on self-hosted; SaaS (no directCreate path) // always has email via Supabase, so it isn't gated on a self-hosted mail config. @@ -211,16 +215,39 @@ export function Users() { }); } function removeUser(member: Member) { + // SaaS removes from the team (the account survives); self-hosted deletes the account. + const teamScope = caps.removeScope === "team"; setConfirm({ title: t("users.confirm.removeTitle", "Remove member"), - body: t( - "users.confirm.removeBody", - "Permanently remove {{name}} from the organization? This cannot be undone.", - { name: member.name }, - ), - confirmLabel: t("users.action.remove", "Remove from org"), + body: teamScope + ? t( + "users.confirm.removeTeamBody", + "Remove {{name}} from the team? They keep their account but lose access to this team's resources.", + { name: member.name }, + ) + : t( + "users.confirm.removeBody", + "Permanently remove {{name}} from the organization? This cannot be undone.", + { name: member.name }, + ), + confirmLabel: teamScope + ? t("users.action.removeTeam", "Remove from team") + : t("users.action.remove", "Remove from org"), danger: true, - action: () => removeMember(member), + action: () => usersBackend.removeMember(member), + }); + } + function cancelInvite(invitation: PendingInvitation) { + setConfirm({ + title: t("users.confirm.cancelInviteTitle", "Cancel invitation"), + body: t( + "users.confirm.cancelInviteBody", + "Cancel the invitation to {{email}}? They won't be able to join with the current link.", + { email: invitation.email }, + ), + confirmLabel: t("users.action.cancelInvite", "Cancel invitation"), + danger: true, + action: () => usersBackend.cancelInvitation(invitation.id), }); } function deleteTeamAction(team: TeamGroup) { @@ -305,6 +332,10 @@ export function Users() { /> )} + {caps.manageInvitations && !loading && invitations.length > 0 && ( + + )} + {!loading && members.length > 0 && ( = 100000) return null; + return max; +} + +/** True only when the ISO expiry is present, parseable, and in the past. */ +function isExpired(iso: string | undefined): boolean { + if (!iso) return false; + const ts = Date.parse(iso); + return Number.isFinite(ts) && ts <= Date.now(); +} + +/** + * The leader's manageable team. Prefer a real (non-personal) team they lead, + * then any team they lead, then their first team. Returns null when the user has + * no teams at all. + */ +async function resolveTeam(): Promise { + const teams = await apiClient.local.json("/api/v1/team/my"); + if (!teams || teams.length === 0) return null; + return ( + teams.find((t) => t.isLeader && !t.isPersonal) ?? + teams.find((t) => t.isLeader) ?? + teams[0] + ); +} + +/** Map a SaasTeamController member onto the portal Member. */ +function toMember(dto: TeamMemberDTO, team: TeamDetailsDTO): Member { + const isLeader = dto.role === "LEADER"; + const role: RoleId = isLeader ? "team_owner" : "member"; + return { + id: String(dto.id), + name: dto.username, + email: dto.email ?? dto.username, + username: dto.username, + role, + teamLead: isLeader, + teamId: team.teamId, + teamName: team.name, + // The portal Users page is leader-only on SaaS, so when the viewer leads this + // team the LEADER row is them; mark it self so self-remove is disabled (leaving + // is a separate flow). Guarded on team.isLeader so a non-leader fallback view + // doesn't mislabel someone else's row as self. + isSelf: isLeader && team.isLeader, + // Leaders hold portal (processor) access via the role-based default policy; + // members don't by default. Drives the roster's access chip. + canAccessPortal: isLeader, + status: "active", + lastActive: NO_ACTIVITY, + authority: "ROLE_USER", + }; +} + +/** Map a SaasTeamController invitation onto the portal PendingInvitation. */ +function toInvitation(dto: InvitationDTO): PendingInvitation { + return { + id: dto.invitationId, + email: dto.inviteeEmail, + invitedBy: dto.inviterEmail, + expiresAt: dto.expiresAt, + }; +} + +export const usersBackend: UsersBackend = { + async fetchUsers(tier: Tier): Promise { + const team = await resolveTeam(); + if (!team) { + return { + summary: { + totalMembers: 0, + pendingInvites: 0, + seatsUsed: 0, + seatLimit: null, + }, + members: [], + roles: ROLES, + access: { tier, seatsUsed: 0, seatLimit: null }, + mailEnabled: true, + emailInvitesEnabled: true, + invitations: [], + }; + } + + const memberDtos = await apiClient.local.json( + `/api/v1/team/${team.teamId}/members`, + ); + // Invitations are leader-only; skip the call (would 403) if we resolved a + // team the user only belongs to. + const invitationDtos = team.isLeader + ? await apiClient.local.json( + `/api/v1/team/${team.teamId}/invitations`, + ) + : []; + + const members = (memberDtos ?? []).map((m) => toMember(m, team)); + // Only genuinely-live invites: PENDING, and not past expiry. The backend + // returns every status and flips PENDING->EXPIRED on a daily sweep, so a + // past-expiry PENDING row can linger for up to a day - drop it here. + const invitations = (invitationDtos ?? []) + .filter((i) => i.status === "PENDING" && !isExpired(i.expiresAt)) + .map(toInvitation); + const seatLimit = normalizeSeatLimit(team.maxSeats); + const seatsUsed = team.seatsUsed ?? members.length; + + return { + summary: { + totalMembers: members.length, + pendingInvites: invitations.length, + seatsUsed, + seatLimit, + }, + members, + roles: ROLES, + access: { tier, seatsUsed, seatLimit }, + // SaaS always has email (Supabase); no self-hosted SMTP gate. + mailEnabled: true, + emailInvitesEnabled: true, + invitations, + }; + }, + + async fetchTeams(): Promise { + const team = await resolveTeam(); + if (!team) return []; + return [ + { + id: team.teamId, + name: team.name, + userCount: team.memberCount, + owners: [], + isPersonal: team.isPersonal, + }, + ]; + }, + + fetchAuthConfig(): Promise { + // SaaS is Supabase-authed: no direct password create, no self-hosted + // OAuth/SAML provider list. Static, no network call (the login probe is an + // admin/self-hosted endpoint). + return Promise.resolve({ + canDirectCreate: false, + hasOauth: false, + hasSaml: false, + }); + }, + + async inviteMember( + email: string, + _role: Extract, + teamId?: number, + ): Promise { + // SaaS invitations are always plain members; role is ignored. + const tid = teamId ?? (await resolveTeam())?.teamId; + if (tid == null) throw new Error("No team to invite to"); + await apiClient.local.json(`/api/v1/team/invite`, { + method: "POST", + body: { teamId: tid, email }, + }); + return { successCount: 1, failureCount: 0 }; + }, + + async renameTeam(teamId: number, newName: string): Promise { + await apiClient.local.json(`/api/v1/team/${teamId}/rename`, { + method: "POST", + body: { newName }, + }); + }, + + async removeMember(member: Member): Promise { + if (member.teamId == null) { + throw new Error("Member has no team to be removed from"); + } + await apiClient.local.json( + `/api/v1/team/${member.teamId}/members/${member.id}`, + { method: "DELETE" }, + ); + }, + + async cancelInvitation(invitationId: number): Promise { + await apiClient.local.json(`/api/v1/team/invitations/${invitationId}`, { + method: "DELETE", + }); + }, +}; diff --git a/frontend/editor/src/saas/portal/usersCapabilities.ts b/frontend/editor/src/saas/portal/usersCapabilities.ts index e24ee29745..b9408cbcd6 100644 --- a/frontend/editor/src/saas/portal/usersCapabilities.ts +++ b/frontend/editor/src/saas/portal/usersCapabilities.ts @@ -13,6 +13,7 @@ export const usersCapabilities: UsersCapabilities = { deleteTeam: false, renameTeam: true, emailInvite: true, + manageInvitations: true, directCreate: false, resetPassword: false, unlock: false,