Route portal Users page to SaasTeamController on SaaS via usersBackend seam (#6940)

## Why

The portal Users page worked on self-hosted but **403'd on SaaS**. It
called the proprietary admin endpoints (`/api/v1/user/admin/*`,
`/api/v1/team/*`, `ui-data/admin-settings`), all `hasRole('ADMIN')`.
SaaS users are always `ROLE_USER` (never `ROLE_ADMIN`), so those
endpoints reject them. This is the last SaaS-release blocker for the
portal.

## What

Route the SaaS build's Users page to the **existing**
`SaasTeamController` (invitation-based team management) - no new
backend. Done via a build-time flavor seam, mirroring the existing
`usersCapabilities` pattern.

- **New seam `@app/portal/usersBackend`** (interface in
`portal/api/usersBackend.ts`) with two impls resolved by the `@app/*`
alias:
- `proprietary/portal/usersBackend.ts` re-exports the existing
admin-endpoint functions - **self-hosted behaves exactly as before**.
- `saas/portal/usersBackend.ts` calls `SaasTeamController`
(`/api/v1/team/*`) via `apiClient.local` (already flavor-aware: SaaS
backend + Supabase JWT). Resolves the leader's team from `GET
/api/v1/team/my`, maps `TeamMemberDTO`/`InvitationDTO` onto the portal
`Member`/`PendingInvitation` types.
- **`manageInvitations` capability** (SaaS `true` / self-hosted `false`)
gates a new **Pending invitations** panel (list from `GET
/{teamId}/invitations`, Cancel via `DELETE
/api/v1/team/invitations/{id}`).
- **Remove re-enabled on SaaS** (was gated off): the roster remove
action now works at team scope against `DELETE
/{teamId}/members/{memberId}`, with a flavor-aware label ("Remove from
team" vs "Remove from org") and confirm copy.
- Invite (email) and rename routed through the seam (`POST /invite`,
`POST /{teamId}/rename`); `fetchAuthConfig` on SaaS is static (no
spurious admin-endpoint 403).
- **MSW handlers** (`mocks/handlers/teamSaas.ts`) mirror the controller
so the SaaS Users page is exercisable in mock mode. Registered in
`handlers` but deliberately **not** `embeddedDataHandlers` (would clash
with the editor's own `/api/v1/team/*` routes when portal shares its
origin).

## Constraints honoured

- No new backend endpoints - reuses `SaasTeamController`.
- Self-hosted path unchanged (proprietary impl re-exports the same
functions).
- No SaaS user is ever `ROLE_ADMIN` - `adminRole`/admin-only UI stay
hidden.

## Notes from an adversarial self-review (both fixed in this PR)

- Solo SaaS users' auto-created **personal team** now hides the Rename
control (the backend rejects renaming personal teams with 400) -
`isPersonal` threaded through `Team`/`TeamGroup`.
- Expired-but-still-`PENDING` invitations are filtered in the adapter,
and the expiry label no longer mislabels a just-expired invite as
"Expires today".

## Testing

- `task frontend:typecheck:all` - all 8 flavors pass.
- Portal vitest project: **122 passing** (added SaaS adapter +
shape-mapping tests via MSW, PendingInvitations panel, and
remove/manageInvitations/personal-team gating).
- `task frontend:lint` (ESLint `--max-warnings=0` + dpdm no circular
deps) and prettier clean.

## Open questions

- **Team resolution on SaaS**: I resolve the leader's single manageable
team (prefer a real non-personal team they lead). If a leader owns
multiple real teams, only the primary is shown - matches the "single
team" framing in the spec; flag if multi-team management is wanted.
- **`isSelf` on SaaS** uses the LEADER role (the portal Users page is
leader-only on SaaS, so the leader row is the viewer). Verified there's
no multi-leader creation path today; revisit if that changes.

Draft - not marking ready until reviewed.
This commit is contained in:
Anthony Stirling
2026-07-10 09:22:05 +00:00
committed by GitHub
parent 68ec176719
commit b9f9f84907
24 changed files with 1230 additions and 39 deletions
@@ -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"
+2
View File
@@ -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 {
+18
View File
@@ -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
@@ -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();
});
});
@@ -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<UsersResponse>;
/** Teams shown in the roster / invite team picker. */
fetchTeams(): Promise<Team[]>;
/** Login/auth config that shapes the invite modal (direct-create, OAuth/SAML). */
fetchAuthConfig(): Promise<AdminAuthConfig>;
/** Invite a member by email (self-hosted: admin invite; SaaS: team invite). */
inviteMember(
email: string,
role: Extract<RoleId, "admin" | "member">,
teamId?: number,
): Promise<InviteResult>;
/** Rename a team. */
renameTeam(teamId: number, newName: string): Promise<void>;
/** Remove a member (self-hosted: delete account; SaaS: remove from team). */
removeMember(member: Member): Promise<void>;
/**
* 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<void>;
}
@@ -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. */
@@ -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";
@@ -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;
@@ -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<string, unknown>) => {
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(
<MantineProvider>
<PendingInvitations invitations={INVITES} onCancel={onCancel} />
</MantineProvider>,
);
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]);
});
});
@@ -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 (
<section className="portal-users__group">
<header className="portal-users__group-head">
<div className="portal-users__group-title">
<strong>{t("users.invites.title", "Pending invitations")}</strong>
<span className="portal-users__group-desc">
{t(
"users.invites.desc",
"Invited people who haven't joined yet. They hold a seat until they accept.",
)}
</span>
</div>
<span className="portal-users__group-count">
{t("users.invites.count", "{{count}} pending", {
count: invitations.length,
})}
</span>
</header>
{invitations.map((inv) => {
const expires = expiryLabel(inv.expiresAt, expiresWord);
return (
<div className="portal-users__row" key={inv.id}>
<div className="portal-users__row-main">
<Avatar name={inv.email} size="sm" tone="neutral" />
<div className="portal-users__row-id">
<span className="portal-users__row-name">{inv.email}</span>
{inv.invitedBy && (
<span className="portal-users__row-email">
{t("users.invites.by", "Invited by {{who}}", {
who: inv.invitedBy,
})}
</span>
)}
</div>
</div>
<span className="portal-users__inv-spacer" />
{expires && (
<span className="portal-users__row-active">{expires}</span>
)}
<Button variant="secondary" size="sm" onClick={() => onCancel(inv)}>
{t("users.invites.cancel", "Cancel")}
</Button>
</div>
);
})}
</section>
);
}
@@ -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) {
@@ -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,
@@ -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<string, unknown>) => {
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(
<MantineProvider>
<UsersDirectory
members={[MEMBER]}
teams={teams}
capabilities={caps}
processorTeamIds={new Set()}
onChangeRole={vi.fn()}
onGrantProcessor={vi.fn()}
onRevokeProcessor={vi.fn()}
onGrantTeamProcessor={vi.fn()}
onRevokeTeamProcessor={vi.fn()}
onAddToTeam={vi.fn()}
onResetPassword={vi.fn()}
onMoveToTeam={vi.fn()}
onToggleEnabled={vi.fn()}
onUnlock={vi.fn()}
onDisableMfa={vi.fn()}
onRemove={onRemove}
onRenameTeam={vi.fn()}
onDeleteTeam={vi.fn()}
/>
</MantineProvider>,
);
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");
});
});
@@ -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 (
<Menu position="bottom-end" withinPortal shadow="md" width={210}>
<Menu.Target>
@@ -190,16 +199,14 @@ export function UsersDirectory({
{t("users.action.disableMfa", "Reset MFA")}
</Menu.Item>
)}
{canRemove && rowKebabHasUpperActions(m) && <Menu.Divider />}
{canRemove && (
<Menu.Item
color="red"
disabled={m.isSelf}
onClick={() => onRemove(m)}
>
{t("users.action.remove", "Remove from org")}
</Menu.Item>
)}
{rowKebabHasUpperActions(m) && <Menu.Divider />}
<Menu.Item
color="red"
disabled={m.isSelf}
onClick={() => onRemove(m)}
>
{removeLabel}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -423,7 +430,7 @@ export function UsersDirectory({
)}
</Menu.Item>
))}
{!SYSTEM_TEAMS.has(team.name) &&
{!isManagedTeam(team) &&
(capabilities.renameTeam || capabilities.deleteTeam) && (
<>
{capabilities.manageGrants && <Menu.Divider />}
@@ -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));
@@ -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";
@@ -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,
});
}),
];
@@ -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;
@@ -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 <Users> 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<string, unknown>) => {
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(
<MantineProvider>
<MemoryRouter>
<Users />
</MemoryRouter>
</MantineProvider>,
);
}
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(),
);
});
});
+49 -18
View File
@@ -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<UsersResponse>(
() => 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<Team[]>(() => fetchTeams(), [tier, refreshKey]);
const authState = useAsync<AdminAuthConfig>(() => fetchAuthConfig(), []);
const teamsState = useAsync<Team[]>(
() => usersBackend.fetchTeams(),
[tier, refreshKey],
);
const authState = useAsync<AdminAuthConfig>(
() => usersBackend.fetchAuthConfig(),
[],
);
const [actionError, setActionError] = useState<string | null>(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 && (
<PendingInvitations invitations={invitations} onCancel={cancelInvite} />
)}
{!loading && members.length > 0 && (
<UsersDirectory
members={members}
@@ -0,0 +1,29 @@
import type { UsersBackend } from "@portal/api/usersBackend";
import {
fetchAuthConfig,
fetchUsers,
inviteMember,
removeMember,
} from "@portal/api/users";
import { fetchTeams, renameTeam } from "@portal/api/teams";
/**
* Self-hosted (proprietary) build: the existing admin-endpoint calls, unchanged.
* This is exactly the behaviour the Users page had before the seam existed - it
* just re-exports the `@portal/api/{users,teams}` functions behind the contract.
*/
export const usersBackend: UsersBackend = {
fetchUsers,
fetchTeams,
fetchAuthConfig,
inviteMember,
renameTeam,
removeMember,
// Self-hosted has no pending-invite concept; the control is gated off
// (manageInvitations=false) so this is never reached.
cancelInvitation() {
return Promise.reject(
new Error("Cancelling invitations is not supported on self-hosted"),
);
},
};
@@ -12,6 +12,7 @@ export const usersCapabilities: UsersCapabilities = {
deleteTeam: true,
renameTeam: true,
emailInvite: true,
manageInvitations: false,
directCreate: true,
resetPassword: true,
unlock: true,
@@ -0,0 +1,245 @@
import type { UsersBackend } from "@portal/api/usersBackend";
import { apiClient } from "@portal/api/http";
import {
ROLES,
type AdminAuthConfig,
type InviteResult,
type Member,
type PendingInvitation,
type RoleId,
type UsersResponse,
} from "@portal/api/users";
import type { Team } from "@portal/api/teams";
import type { Tier } from "@portal/contexts/TierContext";
/**
* SaaS build: the Users page runs as a team leader against SaasTeamController
* (`/api/v1/team/*`), not the ROLE_ADMIN admin endpoints (which 403 for SaaS's
* ROLE_USER accounts). Shapes here mirror SaasTeamController's DTOs and are
* mapped onto the shared portal `Member` / `UsersResponse` types. Same paths the
* editor's SaaSTeamContext uses.
*/
/* ── SaasTeamController DTOs ─────────────────────────────────────────────── */
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;
/** "LEADER" | "MEMBER". */
role: string;
joinedAt?: string;
}
interface InvitationDTO {
invitationId: number;
teamName: string;
inviterEmail: string;
inviteeEmail: string;
invitationToken: string;
/** "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED". */
status: string;
expiresAt?: string;
}
/** No last-activity signal on the team endpoints, so the column reads a dash. */
const NO_ACTIVITY = "-";
/** 0 / huge sentinel seat values mean "no limit". */
function normalizeSeatLimit(max: number | undefined): number | null {
if (!max || max <= 0 || max >= 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<TeamDetailsDTO | null> {
const teams = await apiClient.local.json<TeamDetailsDTO[]>("/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<UsersResponse> {
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<TeamMemberDTO[]>(
`/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<InvitationDTO[]>(
`/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<Team[]> {
const team = await resolveTeam();
if (!team) return [];
return [
{
id: team.teamId,
name: team.name,
userCount: team.memberCount,
owners: [],
isPersonal: team.isPersonal,
},
];
},
fetchAuthConfig(): Promise<AdminAuthConfig> {
// 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<RoleId, "admin" | "member">,
teamId?: number,
): Promise<InviteResult> {
// 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<void> {
await apiClient.local.json(`/api/v1/team/${teamId}/rename`, {
method: "POST",
body: { newName },
});
},
async removeMember(member: Member): Promise<void> {
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<void> {
await apiClient.local.json(`/api/v1/team/invitations/${invitationId}`, {
method: "DELETE",
});
},
};
@@ -13,6 +13,7 @@ export const usersCapabilities: UsersCapabilities = {
deleteTeam: false,
renameTeam: true,
emailInvite: true,
manageInvitations: true,
directCreate: false,
resetPassword: false,
unlock: false,