Skip to content

Platform Administration

In plain terms: this is the control room for the whole platform, not any one organization — the six pages below (Organizations, Users, Products, Platform Roles, Staff, Audit Log) let P4P's own team manage every company using the platform, not just their own. It's a separate area from the "Workspace" pages a regular organization member sees.

Everything under /platform/*. Reachable from /dashboard's Platform Administration entry point, visible to anyone holding any PlatformRole (middleware/platform-access.ts), not just SUPER_ADMIN — access to individual sections is then gated per-permission, read vs. manage split consistently across the module (view without a :manage permission is allowed; the mutating action is hidden or blocked).

All controllers here sit behind @UseGuards(PlatformRoleGuard) at the controller level (self-contained, no TenantGuard ordering dependency — unlike the org-scoped PermissionsGuard, see IAM). SUPER_ADMIN bypasses PlatformRoleGuard unconditionally.

How to...

Grouped to mirror the technical sections below — each page's own help icon links straight into the group relevant to it, not this whole list.

Organizations

Create an organization from the platform sideOrganizationsNew workspace → Name/Workspace URL/Type → Create. See field guide below.

Take over a stuck organization — open the organization → Take ownership → give a ReasonContinue → type the workspace's name to confirm. SUPER_ADMIN only. See field guide below.

Grant or revoke an organization's access to a product — open the organization → its Products section → Activate/Deactivate per product. (The Products page itself only edits the catalog — name/description/active status — not who's subscribed.)

Create an organization — field guide
  • Name (required) — 2–100 characters.
  • Workspace URL (required) — 2–50 characters, lowercase letters/numbers/hyphens only. Auto-fills from Name as you type (e.g. "Acme Corp" → acme-corp) — start editing it yourself and that auto-fill stops permanently for the rest of this dialog, even if you go back and change Name again. Must be unique platform-wide; a taken one is only caught on submit, not as you type.
  • Type — Company / Individual / Agency / Service provider / Platform / Personal. Only Personal organizations are created already Active; every other type starts Pending (see the status meanings on the organization's own detail page).
Take ownership — field guide

This button only appears next to whoever currently holds the OWNER role, and only for a SUPER_ADMIN who isn't that owner themself. It doesn't let you hand ownership to a chosen member — clicking it always makes you, the admin doing the clicking, the new owner (joining the organization first if you aren't already a member); the current owner is demoted to a plain MEMBER, not removed.

  • Reason (step 1, optional) — free text, purely a record for whoever reads the audit log later; nothing validates its length or requires it.
  • Confirmation text (step 2, required) — you must type the organization's exact Name (case- and whitespace-sensitive after trimming) before the final Take ownership button stops being disabled. There's no partial-match hint — get one character wrong and it just stays disabled with no error message explaining why.

Users

Delete a user accountUsers → the row → Delete → confirm.

Remove someone from platform staff — open the user → Remove from platform staff. This closes out their staff membership and any platform roles, without deleting their account.

Give someone a platform role, or make them the new platform Owner — open the user → Platform roles → check the roles → Save roles. Transferring the seeded Owner/SUPER_ADMIN role itself is a separate button on the same page, Transfer ownership to this person.

Send a staff member on leave, or bring them back — this isn't on this page at all; it's the "⋯" menu on their Staffing → Employees profile, even though it calls this module's own backend routes.

Platform staff invitations

Invite someone to P4P's internal teamStaffInvite to platform staff → Email, optionally pre-check Platform rolesSend invitation. See field guide below.

Invite platform staff — field guide

Only a SUPER_ADMIN or the P4P Owner sees this action at all — it isn't delegable via any ordinary permission grant, unlike most other invite/manage actions in this module.

  • Email (required) — the only real check is "not empty"; there's no client-side format check at all (an obviously malformed address only gets caught once you actually submit, by the server). An address that's already platform staff, or that already has a pending invitation, is also only caught on submit.
  • Platform roles (optional, checkboxes) — leaving every box unchecked is completely fine: the invitee still lands with the baseline "P4P Member" role rather than ending up with zero access, so this is a way to pre-assign extra roles up front, not a required step. SUPER_ADMIN and Owner themselves never appear in this list — those can't be granted by invitation, only via Take ownership or a direct role transfer on a user's own page.
  • Send invitation is rate-limited to 3 sends per minute — hitting that shows a distinct "too many requests" message and briefly disables the button with a visible countdown, rather than the generic error every other failure shows.

Products & Subscriptions

Edit how a product appears in the catalogProducts → the row's Edit → Name/Description → Save changes. Activating/deactivating a product platform-wide is a separate action from the same menu; granting/revoking a specific organization's access to it is done from that organization's own page — see Organizations above. See field guide below.

Edit a product — field guide
  • Name (required) — 2–100 characters.
  • Description (optional) — up to 500 characters.
  • Nothing else is editable here — the product's Code (its stable internal identifier) and whether it exists at all are set once, in code, when that product's own app is actually built; this dialog only ever touches how it's described in the catalog.
  • Activate/deactivate (platform-wide) isn't part of this dialog — it's the row's own toggle action, reversible, no confirmation needed. Turning a product off here hides it from every organization at once, regardless of their individual subscription status.

Roles

Create a custom platform roleRolesCreate role → name it, pick permissions → save. See field guide below.

Edit, duplicate, or delete a role — the role's Edit, Duplicate, or Delete. Assigning/revoking a role on a user happens from that user's own page — see Users above. Same field guide below for Edit; Duplicate pre-fills the same form from an existing role's permissions, named "Copy of ⟨original⟩" — nothing is saved until you actually submit it.

Create/edit a platform role — field guide

The 5 seeded system roles (e.g. SUPER_ADMIN) show up in this same list but can't be renamed — their Name field is locked; Description and Permissions stay fully editable, and they can never be deleted (only real custom roles can).

  • Name (required) — must be unique across every non-deleted platform role. The dialog itself additionally refuses fewer than 2 characters, though the backend alone would actually accept a single character — you'll never see that gap in practice since the client blocks it first. Up to 100 characters.
  • Description (optional) — up to 500 characters.
  • Permissions (checkboxes, grouped by scope — Organizations/Users/Roles/etc.) — no minimum; a role with zero permissions checked is valid and simply grants nothing.
  • Save/Create only enables once something is actually different from what was loaded — on Create that means at least one field touched from blank; on Edit it means a real change from the role's current name, description, or permission set, not just reopening and resubmitting the same values.

Audit log

Nothing to configure — it's read-only. Filter by actor/action/date range directly on the page; see Audit log below for what gets logged and why it's kept separate from ordinary admin work.

Organizations (/platform/organizations, /platform/organizations/[id])

In plain terms: every company/agency/individual using the platform, searchable and manageable from one list. Includes an emergency "take ownership" action for when an organization's real owner is unreachable.

admin-organizations.controller.ts, mounted at admin/organizations.

  • List / detail / members (GET) — platform:organizations:read.
  • Restore an archived org (POST :orgId/restore, transitions ARCHIVEDACTIVE, logged as org.restored) — confirmed (2026-08-04, grepped apps/portal): no frontend entry point. The only "restore" UI that exists anywhere in the portal is an unrelated Shared Documents feature (restoring a soft-deleted file from trash, POST /organizations/{id}/files/{fileId}/restore) — do not confuse the two. Since archive also has no UI, an organization that becomes ARCHIVED today has no UI path back to ACTIVE at all — API-only both ways.
  • Force owner transfer (POST :orgId/force-owner-transfer, "Take ownership" on the detail page) — SuperAdminGuard, not the general :manage permission. This is explicitly the "SUPER_ADMIN recovery path" (per the code comment) for when an org is stuck without a reachable OWNER — a platform:organizations:manage holder who isn't SUPER_ADMIN cannot use it.

The "New workspace" dialog on the list page creates an organization directly from the platform side (same underlying POST /organizations as self-service creation — see IAM); its trigger is :manage-gated even though the page itself only requires :read.

Fixed, 2026-08-04: this dialog previously crashed with a 500 on every submit — the organization was actually created in the database (the transaction commits before the HTTP response is built), but OrganizationsService.create() returned the raw Prisma row instead of mapping it to OrganizationDto, and Organization.storageQuotaBytes/ storageUsedBytes (BigInt columns, added for docs/storage/ADR-001-file-storage.md) crash JSON.stringify — so the admin saw an error and had no way to know the workspace existed. getById() already avoided this by hand-shaping its return value; create() now does the same. Caught by scripts/e2e/menu/platform-admin.mjs, which exercises this dialog for real on every run.

Users (/platform/users, /platform/users/[id])

In plain terms: every person with a P4P account, across every organization. Today the interface only lets you delete an account outright — pausing/locking one temporarily has to be done directly against the API (no button yet).

admin-users.controller.ts, mounted at admin/users.

  • List / platform-staff list / platform-staff-invitations list / detail (GET) — platform:users:read.
  • Delete a user account (DELETE :id) — platform:users:manage, the only account-status action this page's UI actually exposes (row action on /platform/users).
  • Suspend / activate / lock / unlock (POST :id/suspend|activate|lock|unlock) — confirmed (2026-08-04, grepped apps/portal): no frontend entry point for any of the four, despite all four being fully implemented and permission-gated server-side. If an account needs to be suspended or unlocked today, it's a direct API call — there's no button.
  • Impersonate — no "Impersonate" action exists on this page. Confirmed 2026-08-04: Impersonation is fully backend-only (POST /auth/impersonate, SUPER_ADMIN-only) — this page has no button wired to it, despite being the natural place for one.
  • Remove from platform staff (DELETE :id/platform-staff) — platform:users:manage. Closes out the person's PlatformStaffMembership and any PlatformRole assignments; distinct from suspending their User account entirely.
  • Leave / return from leave (POST :id/leave, POST :id/return-from-leave) — platform:users:manage. Internal-staffing status (on leave / active), not an account-level suspension — affects task/project assignment eligibility in the Staffing module, not login ability.

Platform staff invitations (/platform-staff-invitations/[token])

In plain terms: how someone joins P4P's own internal team (as opposed to being a member of a customer organization). Sent from /platform/staff, a separate flow from inviting someone into a company's workspace.

platform-staff-invitations.controller.ts. Sending an invite (POST /platform-staff-invitations) is gated by SuperAdminOrOwnerGuardnot the same as SuperAdminGuard used for impersonation and force-owner- transfer. It admits SUPER_ADMIN or whoever holds the P4P Owner platform role, deliberately: inviting someone to become platform staff is ordinary staffing work the company Owner should be able to do directly, unlike the security-recovery actions that stay SUPER_ADMIN-only. An invite can never bundle a SUPER_ADMIN role grant (validated server-side even though sending is already gated). Accepting (GET/POST :token[/accept], public) creates the PlatformStaffMembership row — the prerequisite for holding any PlatformRole at all (see Glossary).

Products & Subscriptions (/platform/products)

In plain terms: the catalog of products organizations can be subscribed to, and who's subscribed to what. Products themselves are added by engineers, not from this page — this page only edits how existing ones appear and grants/revokes an organization's access to them.

admin-products.controller.ts, mounted at admin (routes: products, products/:productId, organizations/:orgId/subscriptions, subscriptions/:subscriptionId).

  • Product catalog list — platform:products:read; create/edit/deactivate — platform:products:manage. The page's edit/activate menu is visible at :read but only usable at :manage.
  • An organization's subscriptions — list platform:subscriptions:read, grant/revoke platform:subscriptions:manage. There is no self-service subscription flow today — an organization cannot subscribe itself to a product; a platform admin grants it.

Roles (/platform/roles)

In plain terms: the same idea as organization Roles (IAM), but for P4P's own internal team instead of a customer's — a completely separate set of roles and permissions, never mixed with an organization's.

platform-roles.controller.ts, mounted at admin/platform-roles. Same shape as the org-level Roles page (IAM) but for PlatformRole/PlatformPermission instead of Role/Permission — a structurally separate table pair, never shared with organization RBAC.

  • List roles + list the permission catalog — platform:roles:read.
  • Transfer the seeded SUPER_ADMIN/owner-equivalent role (PATCH owner/transfer) — deliberately no @RequirePlatformPermission decorator (see the code comment at line 49): PlatformRoleGuard's own logic for this route enforces something stricter than the permission system can express. Don't assume "no decorator" means "unguarded."
  • Create / edit / delete a custom platform role, assign/revoke a role to/from a user, add/remove a permission on a role — all platform:roles:manage. As covered in Platform Administration → Users's linked catalog notes: a platform:roles:manage holder can still directly grant SUPER_ADMIN to an already-active platform-staff member via POST :roleId/users/:userId with no extra guard — a known, currently accepted gap (see docs/iam/PERMISSIONS_CATALOG.md, "Still open").

Seeded roles: SUPER_ADMIN (system, full bypass), P4P Owner and P4P Super Admin (granted the full permission catalog explicitly, for display purposes — see Glossary), P4P Admin (curated subset, excludes platform:roles:manage), P4P Member (default grant for new platform staff, includes platform:staffing:manage_own).

Audit log (/platform/audit)

In plain terms: the read-only viewer for the same platform-wide history described in IAM → Audit log. Deliberately kept separate from "P4P Admin," the day-to-day admin role — reading everyone's activity history is treated as more sensitive than ordinary admin work.

admin-audit.controller.ts, mounted at admin/audit-log, platform:audit:read. Read-only view over the append-only AuditLog (written from across the whole platform, not just this module — see IAM → Audit log). Deliberately excluded from the P4P Admin convenience role — "who watches the watchers" is narrower than general admin work, same reasoning that keeps platform:roles:manage out of that role.

Testing this module

scripts/e2e/menu/platform-admin.mjs (10 checks) runs this module's core flows end to end and sets lastVerified above. Not covered by automation, still worth a manual pass — see docs/MANUAL_TESTING.md: read/manage permission-gating on each page's mutating controls, blocking a SUPER_ADMIN role in a staff invitation, product subscription grant/revoke, the platform roles owner-transfer path, and platform:audit:read gating on the Audit Log page itself.