mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user