IAM — Authentication, Organizations, Roles & Permissions
This is the module every session starts in: proving who you are, then choosing (or being placed into) an Organization. Everything below is backed by apps/core-api/src/modules/auth and .../organizations, .../roles.
How to...
Grouped to match the technical sections below — each page's own help icon opens straight into its own group here, not this whole list.
Authentication
Create an account — /auth/sign-up → enter your email and a password → Create account → check your inbox for a verification link. See field guide.
Sign in without a password — on /auth/sign-in, click Sign in with a magic link or Sign in with a code instead, enter your email, and check your inbox. See field guide.
Sign in with Google — Continue with Google on /auth/sign-in.
Recover a forgotten password — Forgot password? on /auth/sign-in → enter your email → follow the emailed link. See field guide.
Create an account — field guide
- Email — if it's already registered, you're told right away when you submit.
- Password — 8 to 128 characters. No other requirements (no forced uppercase letters, digits, or symbols).
- After you submit, a verification email is sent, and a Resend verification email link appears — you can use it up to 3 times a minute. Signing up also quietly creates a personal workspace for you (see What is P4P).
Sign in without a password — field guide
Both options — magic link and one-time code — replace the password form entirely. Pick one for that visit, not both.
- Magic link — enter your email → Send link. You'll always see "If an account exists, a sign-in link has been sent," whether or not the address is registered, so this stays private either way. The link expires after 15 minutes.
- One-time code — enter your email → Send code → a 6-digit code field appears. The code expires after 10 minutes, and requesting a new one cancels the old one.
- Both are limited to 3 sends per minute, shown as a countdown on the resend button.
Recover a forgotten password — field guide
- Request a reset (
/auth/forgot-password) — enter your email → Send reset link. Same private confirmation message as above, whether or not the address has an account. - Set a new password (from the emailed link) — enter and confirm your new password (8–128 characters, and the two must match). If the link is missing, broken, or already used, you'll see an error and can request a new one.
Home
Open one of your workspaces — /dashboard → the workspace's card under My Workspaces. This is the page you land on after signing in, and the avatar menu's own home link brings you back to it.
Find a workspace in a long list — the Search workspaces… box above the list. It filters by name as you type; the count above it always shows how many you actually belong to, not how many the search left.
Go to Platform Administration or Team Management — the cards at the top. You only see the ones you have access to: Platform Administration needs platform:admin:access, Team Management needs workspace:staffing:read, and contractors with a linked HR pool record get an HR Pool Profile card instead.
Get into a workspace you can't see — you can't add yourself. Ask that workspace's owner to invite you; the tip at the bottom of the list says the same thing.
Account
Edit your profile — /account/profile → update your photo, name, phone, timezone, bio, location, birth date, skills, and interest areas.
Set your language — /account/profile → Language → pick one. This saves immediately — there's no separate Save button for it. It's different from the quick language switcher in the avatar menu, which only changes what you see for the current session; this field is your permanent default, including which language your notification emails arrive in.
Accepting an invitation
Open the link from the invitation email (/invitations/[token]). What happens next depends on whether you're signed in, and as whom — the app figures this out from the invited email address, so you're never asked to pick.
Already signed in as the invited address — one click on Accept invitation and you're done.
Signed in as a different account — the page won't let you accept. It tells you whose invitation this is and offers Sign out. After signing out, open the invitation link again to accept it.
Not signed in, and that email already has a P4P account — click Accept invitation, then sign in right there — with your password, a magic link, or a one-time code, whichever you prefer — no separate trip to the sign-in page needed. If that account only ever used Google (no password set), you'll be offered a password-reset link instead; once you've set a password, open the invitation link again to finish.
Not signed in, and that email has no account yet — a short form (name + password) creates your account and joins the organization in one step. If that's wrong — the address does have an account — a link switches you to sign in instead.
You can also sign in with Google from either screen; you'll land back on this same invitation, already signed in.
Creating an organization
There's no button for this yet. It happens automatically when you register — your own personal workspace — see Creating an organization below for the details.
Workspace home
See where you stand in a workspace — /org/[slug]/dashboard, the page a workspace card opens into. Under the workspace name it prints Your role: … — the roles you hold there, straight from your own membership.
Jump to Members, Roles or Products — the three cards, each with its own live counts (total members and how many are Without a role; total and custom roles; total and active products). A card you lack the permission for stays visible but greyed out and unclickable — the point being that you can see the section exists rather than wondering whether it does.
Read a count of "—" — that metric failed to load or you can't read that section; the section page itself will show the real error. … means it is still loading.
Organization Settings
Edit your organization's name, description, or website — Settings → edit the fields → Save changes. See field guide.
Set your organization's default language — Settings → Default language → pick one. Saves immediately. Only the OWNER can change this — it's the language every member without their own personal pick gets, including for notification emails.
Edit organization settings — field guide
Without permission to manage the organization, this whole page shows the same three values as read-only text — no editable fields at all.
- Workspace URL — shown for reference; it's set once when the workspace is created and can't be changed afterward.
- Name — required, 2–100 characters.
- Description — optional, up to 2,000 characters.
- Website — optional; if you enter one, it must be a complete URL (like
https://example.com). - Two things you might expect here but won't find: changing the owner (that's Transfer ownership, on the Members list below, OWNER-only) and archiving the organization (not available in the interface yet).
Members
Invite someone to your organization — Members → Invite member → enter their email, pick a Role → Send invitation. See field guide.
Remove a member, or hand over ownership — on the Members list, open the row's actions menu → Transfer ownership (only the current OWNER can do this) or remove them.
Open a member's page, or their account — on the Members list, click the name to open the member's page in this workspace (roles, job title). Click the email under it to open the person's account page (Platform Administration → Users); that link exists only if you hold platform:users:read, and on your own row it opens your own profile instead. Everywhere else a person is shown, the name and the email work the same way.
Assign a role to a member, or edit their job title — open that member's own page → Roles or Job title in this workspace → Save. See field guide.
Invite a member — field guide
- Email — a malformed address, or one that's already a member or already invited, is only caught when you submit.
- Role — required; defaults to MEMBER so it's never left blank. OWNER is never offered here — the only way to make someone an Owner is Transfer ownership.
- You can send up to 3 invitations a minute.
Member roles & job title — field guide
- Job title in this workspace — optional, up to 100 characters. You can always edit your own; editing someone else's needs the right permission. It's specific to this workspace — the same person can have a different title in each one they belong to.
- Roles — check any number, including none. OWNER never appears here either — it only ever moves through Transfer ownership.
- The Save button for each section only lights up once you've actually changed something in that section.
Roles
Create a custom role for your organization — Roles → Create role → name it, pick permissions. See field guide.
Edit, duplicate, or delete a role — the role's ⋯ menu → Edit, Duplicate, or Delete. The three built-in roles (Owner/Admin/Member) can't be renamed or deleted, so Edit isn't offered for them — but Duplicate and View permissions still work.
Create/edit an organization role — field guide
A dedicated page, not a dialog, so the permission list has room to breathe.
- Name — required, up to 100 characters, at least 2. Only needs to be unique within your own organization — another organization can use the exact same role name.
- Description — optional, up to 2,000 characters.
- Permissions — use the search box, or open one section at a time (Members, Roles, Organization, AI, Files, and so on). A role can have zero permissions checked. Every role, including the three built-in ones, has a View permissions option for a quick read-only look without opening the full editor.
- Save/Create only becomes active once something has actually changed.
- Delete — a role that's still assigned to anyone (an active member or a pending invitation) can't be deleted; reassign them first.
Products
See what this workspace has — /org/[slug]/products. Every product P4P currently offers gets a card; the badge on it is this workspace's own status for that product — Active, Trial, Suspended, Expired, Cancelled, or Not subscribed.
Subscribe to something — not from here. This page is read-only for everyone, including owners; subscriptions are granted from Platform Administration → Products & Subscriptions. Ask a platform administrator.
Shared Documents
Upload a shared document — Shared Documents → Files tab → Upload document.
Preview a shared document — the row's ⋯ menu → Preview, which opens it in a viewer dialog with a Download button, not a raw browser tab. Images, PDF, audio and video render inline; .md is shown formatted, .csv as a table, .txt/.log as plain text. The Office formats (.docx / .xlsx / .pptx and the older .doc / .xls / .ppt) have no preview — the menu offers Download only. A text/CSV/Markdown file over 2 MB, or a video the browser can't decode (some .mov), also falls back to Download.
Delete or restore a shared document — the row's ⋯ menu → Delete (moves it to Trash for 30 days); from Trash, Restore or Delete permanently.
Connected apps
Connect an AI client to your P4P account — you don't start this here, you start in the client, and it asks for one thing P4P has to give you: the server address, shown with a copy button on Connected Apps (https://api.<your P4P domain>/mcp/external — the page shows the right one for the environment you are in).
In ChatGPT: turn on Settings → Security and login → Developer mode first — custom connectors are hidden until it is on, and it is labelled "increased risk", which is its blanket warning about any third-party connector. Then Settings → Plugins → Browse plugins → the + beside the search box → paste the address, leave authentication on OAuth, and create. Plus or Pro only; free accounts cannot add their own connectors. In Claude Desktop: Settings → Connectors, add a custom one with the same address; nothing to turn on first.
Either way the client then sends you to a P4P Authorize screen listing exactly what it is asking for. Approve sends you back to the client, connected; Deny sends you back with nothing granted.
See what you have connected — /account/api-tokens (Connected Apps in the account menu): one row per app, with the permissions it holds, when you connected it and when it was last used. Never used means exactly that.
See what an app has been doing — the chevron on its row. Every tool an AI client calls is recorded against its own token, so this is when, which tool, and why a call failed — the answer to "what has ChatGPT been doing with my account", which "last used" alone never gave.
Cut an app off — the row's bin icon → Revoke. It stops working immediately and there is no undo; reconnecting means going through the authorization screen again.
Understand what you are approving — an app can never be granted more than you already have. The authorization screen lists each requested permission with a plain-language line under it.
Notifications
Account security is the one group nobody can switch off, and the only one where each event stays its own entry rather than folding into the previous one — two of these happening in a row is exactly when you most need to see both. Full explanation in Notifications.
Account
Someone signs in from a new device — you are told which kind of device it was. A browser simply updating its version does not count as new: an alert that fired every few weeks would teach you to dismiss the one that matters. The first sign-in on a brand-new account is silent for the same reason.
Your password is changed — you are told, and every active session is signed out. Sent even though the person doing it holds a valid reset link, because that is precisely the case worth covering: a link that reached the wrong hands is otherwise invisible to you.
An administrator opens your account — you are told, with the reason they gave. Impersonation is already recorded in the audit log, but an audit log is something someone has to go and read; this is what makes it visible to the person it happened to.
Authentication (/auth/*)
In plain terms: this is everything under the "Sign In" / "Sign Up" screen — creating an account, signing in five different ways (password, a link emailed to you, a one-time code emailed to you, Google, or resetting a forgotten password), and signing out. You don't need to read the technical detail below unless you're debugging one of these flows.
All of these routes are @Public() in auth.controller.ts (no session required to reach them), and the write-heavy ones (register, login, magic-link, password-reset/request, otp/send) also carry @UseGuards(ThrottlerGuard) — expect a rate limit if you script against them repeatedly (see docs/TESTING.md before scripting at all).
- Sign up / Sign in (
/auth/[mode].vue,mode=sign-in|sign-up) — one page, two modes, switched by route param. Sign-up hitsPOST /auth/register, which creates theUser+ aPERSONALorganization for them (see What is P4P — every user gets a personal workspace) and sends a verification email via Mailpit in dev. Sign-in hitsPOST /auth/login, guarded byLocalAuthGuard(email+password). - Verify email (
/auth/verify-email) — consumes the token from the verification email (POST /auth/verify-email).POST /auth/resend-verificationbacks the "resend" action if the link expired. - Forgot / Reset password (
/auth/forgot-password,/auth/reset-password) —POST /auth/password-reset/request(always returns success, whether or not the email exists — don't treat a "check your email" response as confirmation the account exists), thenPOST /auth/password-reset/confirmwith the emailed token. - Magic link (
/auth/magic-link) — passwordless sign-in.POST /auth/magic-linksends the email,POST /auth/magic-link/verifyconsumes it and starts a session, same token shape as password login. - Email OTP —
POST /auth/otp/send/POST /auth/otp/verify. No dedicated page — reached via a toggle button ("Sign in with a code instead" / «Войти по коду вместо пароля») on/auth/sign-initself (LoginForm.vue'ssignInMethodstate), next to the equivalent Magic Link toggle. - Google OAuth (
/auth/callback,/auth/error) —GET /auth/googlestarts the OAuth handshake (GoogleAuthGuard),GET /auth/google/callbackcompletes it and redirects to/auth/callback(success) or/auth/error(failure — expired/denied consent, account conflicts, etc.). - Logout (
/auth/logout) —POST /auth/logoutrevokes the refresh token; access tokens are short-lived (15 min) so no server-side revocation is needed for those. - Session refresh —
POST /auth/refresh— not a page, called automatically by the portal's HTTP client when the access token expires. Refresh tokens are opaque, 30-day, rotating, with theft detection (reusing an already-rotated token revokes the whole chain) — seedocs/iam/ADR-001-authentication.md.
Home (/dashboard)
In plain terms: where signing in leaves you. Two things are on it: the shortcuts you're entitled to (Platform Administration, Team Management, or an HR Pool Profile card for a contractor), and My Workspaces — the list of every organization you belong to, with a search box once it gets long.
It is deliberately not a summary screen. The counts, activity and charts live inside the sections themselves; this page's whole job is to get you into the right one, which is why a card is the entire interaction.
- Platform Administration is gated on
platform:admin:accessspecifically, not on "holds any platform role" (tightened 2026-08-14) — a Staffing-only platform role otherwise saw, and could enter, the whole platform control room. - Team Management is gated on
workspace:staffing:read. Nothing inside/teamhas a permission-less landing page to fall back to, so the card is hidden rather than shown-and-bounced. - HR Pool Profile appears only for someone with a linked HR pool record who does not have team access — with it, the full HR Pool page already covers everything that limited self-view shows.
- The workspace list comes from
GET /me/organizations, so it carries your own membership and roles with it — the role shown under each name needs no second request. P4P Internal is filtered out of it unless you holdworkspace:organization:managethere: it is a technical anchor organization every staff member is a member of, and for everyone else its card would only bounce offworkspace-access.tson entry. - There is no "create a workspace" button here.
POST /organizationsneeds no special permission and would happily serve one, but no self-service entry point is wired into the portal — see Creating an organization.
Account (/account/profile)
The User-tier profile page — display name, avatar (POST/DELETE /me/avatar), and other fields on GET/PATCH /me. Independent of any organization; see Architectural tiers.
If you hold any platform staff role, the page also shows an Effective permissions panel (2026-08-14) — the real union of every PlatformPermission your current platform roles grant, grouped by scope in the same closed-by-default accordion as the role editor's own permission picker (see Roles above). It's read from GET /me's own response, always reflects your actually-saved roles (not a live preview of unsaved changes — that variant lives on the admin side, see Platform Administration → Users), and stays hidden entirely if you hold no platform role at all.
Language (2026-08-21) — PATCH /me's locale field, backing User.locale. Split into two composables on the frontend: useLocaleSwitch (session-only, the avatar menu's own switcher — never persists) and usePersistedLocale (persists and switches — used only by this field). The split exists because the two used to be the same code path: a "just looking" pick in the avatar menu was silently becoming someone's permanent email language, since the avatar menu wrote straight to User.locale. resolveEmailLocale() (notifications/emails/messages.ts) is what actually reads it — a notification email's language is the recipient's own User.locale if set, else the organization's default language, else en. A platform SUPER_ADMIN can override any organization's own default from Platform Administration → Organizations; neither override reaches an individual member's own User.locale, which always wins first.
Accepting an invitation (/invitations/[token])
In plain terms: the page a colleague lands on when they click the link in an invitation email. Works whether they already have a P4P account or not — a new email/password form appears automatically if they don't.
Public page (GET /organizations/invitations/:token, POST /organizations/invitations/:token/accept) — works for both an existing signed-in user joining another org, and a brand-new person who supplies an email/password to create their account and join in one step. Invitations carry a set of Roles (InvitationRole) assigned on acceptance via MembershipRole.
GET .../:token returns accountExists/hasPassword alongside the invited email — the page uses these, not a guess, to default straight into the right branch (register form vs. sign-in vs. no-password explanation) on first render; every branch stays switchable via an on-page link in case that default is ever wrong for a given account. The invite is addressed to a specific email, so isWrongAccount (profile.email !== invitation.email while already authenticated) blocks the accept UI entirely rather than either silently joining the wrong account or failing with a confusing "already a member" — the only way out is logout(), which navigates to /, not back to this page (the link has to be reopened afterward). The existing-account sign-in branch is inline (password, magic link, or OTP — same three useAuth() methods /auth/sign-in uses) rather than a redirect to /auth/sign-in and back, since the invited email is already known; a Google-only account (no EMAIL_PASSWORD credential) gets a password-reset shortcut instead of a dead-end password field, but that one path alone can't finish in a single step — reset doesn't sign the visitor in, so they have to return to the same link afterward. Live e2e coverage: scripts/e2e/menu/invitation-accept.mjs.
Creating an organization
POST /organizations requires only an authenticated user — no special permission. Any signed-in user can create additional organizations beyond their auto-created personal one (e.g. to represent a company they're starting). Confirmed (2026-08-04, grepped apps/portal for both API paths): there is no portal UI for this at all today — no /org/create page, no "new organization" dialog reachable from /dashboard. The only way to reach POST /organizations from the UI right now is indirectly, as a side effect of registration (which creates the auto PERSONAL org) or from Platform Administration's "New workspace" dialog (platform-admin-only). A self-service "create another organization" entry point is backend-ready but not wired into the frontend yet.
Workspace home (/org/[slug]/dashboard)
In plain terms: the first page inside a workspace — its name, the role you hold in it, and three cards (Members, Roles, Products) with live counts, each opening its own section.
The counts are read from the same list endpoints the sections themselves call (/organizations/:id/members, /roles, /subscriptions), each in parallel and each degrading on its own: a failure or a missing permission shows — for that metric instead of blocking the page.
One deliberate difference from Platform Administration's own hub: there, a card you lack permission for is hidden; here it is shown greyed out and unclickable. The reasoning is that a workspace's three sections are a fixed, small set everyone can be expected to know exists — hiding Roles from a member makes the workspace look like it has no roles, whereas the platform hub's card list is long enough that showing unusable entries would be noise.
Products carries no permission at all — every member of every workspace can see what the workspace is subscribed to.
Organization Settings (/org/[slug]/settings)
In plain terms: the Settings page inside an organization has two cards — one for its name, description, and website, one for its default language. Two things you might expect to find here, "make someone else the owner" and "archive/reactivate this organization," live elsewhere or don't exist in the interface yet (details below).
- Profile fields (name, description, website) —
PATCH /organizations/:id, gated byworkspace:organization:manage, delegable to ADMIN or a custom role. This and the language card below are allsettings.vueexposes — it has no archive/activate controls. - Default language (
PATCH /organizations/:id/default-locale, added 2026-08-18) — OWNER-only, enforced byassertActorIsOwner()in the service rather than aPermissionstring, same shape as Transfer ownership below; not delegable viaworkspace:organization:manage, so the frontend gate mirrorsmembers/index.vue's ownisOwnercheck instead ofusePermissions(). Every other member without their own personal Language pick falls back to whatever this is set to (oren, if it's never been set) — see Language above for the full priority order. A platformSUPER_ADMINcan also override this from outside the organization entirely — see Platform Administration → Organizations. - Transfer ownership — despite being an organization-lifecycle action, it is not on the Settings page. It's a per-row action on the Members list (
workspace.members.actions.transferOwnership,members/index.vue) — "make this member the Owner instead of me."PATCH /organizations/:id/transfer-ownership, gated byTenantGuardalone at the route level, with the real check (assertActorIsOwner()) insideorganizations.service.ts— not delegable viaworkspace:organization:manage; you must actually holdOWNER. - Archive / activate (
POST /organizations/:id/archive,PATCH /organizations/:id/activate) — confirmed (2026-08-04, greppedapps/portalfor both paths): no frontend entry point exists anywhere. Both routes work (sameTenantGuard-only +assertActorIsOwner()gating as transfer-ownership) and are reachable via direct API call, but there is currently no button/dialog in the portal that calls them — an OWNER cannot self-service archive their own organization through the UI today. The only archive-adjacent portal UI is Platform Administration → Organizations's restore action (the reverse direction, platform-admin-only). - Slug is immutable and has no edit path (same reasoning as
User.email).
Members (/org/[slug]/members, /org/[slug]/members/[userId])
In plain terms: the Members list is where you invite people, remove them, or hand over ownership. Click into a specific person's own page to change their role or job title — those two aren't on the list itself.
- List (
GET /organizations/:id/members), with an "Actions" dropdown per row (workspace:members:readfor the list itself; every system role — down to the baselineMEMBER— can see it). - Invite (
POST /organizations/:id/members/invite) —workspace:invitations:manage. Throttled (ThrottlerGuard) in addition toTenantGuard/PermissionsGuard. - Remove a member and transfer ownership — both live on the list page (
members/index.vue), as per-row dropdown actions with their own confirm dialogs. Remove isworkspace:members:remove; transfer ownership is OWNER-only (see Organization Settings above — it's a lifecycle action despite living on this page, not Settings). - Assign/revoke a role — the member detail page (
members/[userId].vue), not the list.POST/DELETE .../members/:userId/roles/:roleIdneedworkspace:members:assign_role— split from job-title editing (2026-08-14, was one combinedportal:members:managebefore) since a role custom to your organization might reasonably want to delegate "can retitle someone" without also handing out "can change what they're allowed to do." - Edit job title — same detail page.
PATCH .../members/:userId/job-titleneedsworkspace:members:edit; editing your own job title (PATCH .../members/me/job-title) only needsTenantGuard— no elevated permission to change your own displayed title. - Leaving an organization (
DELETE /organizations/:id/leave) — confirmed (2026-08-04, greppedapps/portal): no frontend entry point. The backend route works (TenantGuardonly, anyone can remove themselves, blocked for a sole/last OWNER perdocs/iam/ADR-007-security-invariants.md), but there is no "Leave organization" button anywhere in the portal today. Don't confuse this withSetOnLeaveDialog.vue— that's an unrelated Staffing feature (marking a person on HR leave), not membership departure.
Roles (/org/[slug]/roles)
In plain terms: Roles are named bundles of permissions (like "can invite people" or "can edit the organization's name") that you assign to members. Three roles come built in and can't be renamed or deleted — Owner, Admin, Member — and you can create your own on top of those. (A fourth, Viewer, existed until 2026-08-19 — see below.)
roles.controller.ts, mounted at organizations/:id/roles. Every route requires @UseGuards(TenantGuard, PermissionsGuard) in that order — see the note in CLAUDE.md/docs/iam/PERMISSIONS_CATALOG.md on why PermissionsGuard can never be registered globally.
- List roles + list the assignable permission catalog (
GET /organizations/:id/roles,GET .../roles/permissions) —workspace:roles:read. - Create a custom role (
POST) —workspace:roles:create. Edit a role's name/description, and add/remove a permission on it (PATCH :roleId,POST/DELETE :roleId/permissions/:permissionId) —workspace:roles:edit. Delete (DELETE :roleId) —workspace:roles:delete. Split into three permissions 2026-08-14 (was one combinedportal:roles:manage) so a role could, for example, delegate "create and tweak roles" without also handing out "delete them." System roles (OWNER/ADMIN/MEMBER) are protected from deletion/rename by a DB trigger, not just an application check — seedocs/iam/ADR-003-rbac.md.
VIEWER retired, 2026-08-19. It and MEMBER had been documented as deliberately identical in the seeded permission set since 2026-07-05 (both read-only, no real distinction to manufacture) — files:upload/files:manage (added to MEMBER later) was the one remaining difference, removed from MEMBER instead of kept as the reason to preserve two roles. Role.isSystem: true rows are DB-trigger-protected from UPDATE/DELETE; a dedicated migration disabled that trigger for the duration of one intentional DELETE, then re-enabled it. Don't "re-add" a fourth baseline role without reading the full reasoning in docs/iam/PERMISSIONS_CATALOG.md first.
Products (/org/[slug]/products)
In plain terms: the catalog of what P4P offers, with this workspace's own subscription status on each card. Read-only — nothing on this page changes a subscription.
The distinction that page originally got wrong, and is worth keeping straight: GET /products already only ever returns products P4P currently offers (isActive), so that flag says nothing about this workspace. The badge is resolved from GET /organizations/:id/subscriptions instead — ACTIVE/TRIAL read as subscribed, everything else (SUSPENDED, EXPIRED, CANCELLED, and no subscription row at all → Not subscribed) does not. Before that fix every card read Active in every workspace, because nothing consulted subscriptions at all.
Subscriptions are granted and revoked from Platform Administration → Products & Subscriptions, by a platform administrator — there is no self-service purchase flow anywhere in the product yet.
Shared Documents (/team/shared-documents)
In plain terms: a shared file library for P4P staff. Lived briefly at /org/p4p-internal/shared-documents (2026-08-20–2026-08-23) on the reasoning that, under the hood, it was always this platform's generic file-storage capability aimed at one fixed organization, never anything staffing-specific — but that URL swapped a visitor's whole Team Management sidebar out for the org shell's single-item one, which read as "this took me somewhere else" rather than "this page changed," and cost more in day-to-day findability than the routing purity was worth. Moved back.
Reuses the platform's generic file-storage capability (files:read/files:upload/files:manage) against one fixed, synthetic organization (system-org-shared-documents, slug p4p-internal) — the page's own script hardcodes that organization id unconditionally, ignoring whatever route it's reached through, since the backend has no concept of any other organization having its own Shared Documents. Files/Trash tabs, 30-day soft-delete retention.
Gated by middleware/staffing-access.ts with requiresPermission: 'files:read' — the same mechanism every other /team/* page uses, just checking membership in the fixed P4P Internal organization instead of a :slug param. Everyone down to MEMBER-tier files:read reaches it; upload/delete need the org's own files:upload/files:manage (see Roles above). middleware/workspace-access.ts's blanket workspace:organization:manage gate on the rest of /org/p4p-internal/* (Roles, Members — see Members) no longer needs a carve-out for this page at all, since it isn't reached through that middleware any more.
Normal way P4P staff find it: layouts/team.vue's own sidebar keeps a "Shared Documents" entry (gated files:read, same real permission as the page). /team/dashboard's own section card and "Upload document" quick action are the other route in — see Staffing. The legacy /org/p4p-internal/shared-documents URL still resolves — it now just redirects straight to /team/shared-documents, so an old bookmark or shared link doesn't dead-end.
Connected apps (/account/api-tokens, /oauth/authorize)
In plain terms: external AI clients — ChatGPT, Claude Desktop, anything speaking MCP — that you have authorized to reach your P4P account on your behalf. Connected Apps in your account menu is the list; /oauth/authorize is the consent screen you pass through once per app.
This is the reverse direction from the AI module's MCP page: there, P4P's own agents reach out to external tools; here, an external client reaches into P4P.
- What a connection actually is. Each row is a
PersonalAccessToken— hashed at rest like a refresh token, and shown to the client exactly once, at the moment it is issued. The Prefix column is all that is ever displayed afterwards, purely so you can tell two rows apart. - Where the address comes from. The server a client connects to is
https://api.<domain>/mcp/external, shown with a copy button on the Connected Apps page itself. It is the one thing the client cannot work out on its own, which is why connecting starts by copying it from there. - How one gets created. Only through the OAuth 2.1 flow (2026-08-06): the client registers itself (
POST /oauth/register, RFC 7591, PKCE-only public clients — no client secret), sends you to/oauth/authorize, and exchanges the code it gets back for a token. Self-service "create a token" is deliberately gone from this page; it existed until ChatGPT's connector flow made it clear that raw Bearer tokens aren't something every client can even accept. Tokens created that way still work until revoked. - The ceiling on what it can do. A token's scopes can only ever be a subset of your own platform permissions at the time you approve. Scopes also gate tools more tightly than the equivalent HTTP routes do —
platform:staffing:readgrants read tools only,platform:staffing:manageadds task creation, status changes and comments — the whole point being that you can hand an AI a narrower ceiling than your own account. - Everything it does is logged. Every external MCP tool call is written to the audit log keyed by the token that made it, and tool responses are trimmed of personal contact details before they ever reach the model.
- Revoking takes effect immediately and cannot be undone; the app has to be authorized again from scratch.
Backend: apps/core-api/src/oauth (authorization server) and apps/core-api/src/mcp-external (the tools themselves), deliberately kept separate from the internal src/mcp module and its session-JWT trust model. Full reasoning in docs/mcp/ADR-001-external-mcp-server.md.
Impersonation
In plain terms: lets a SUPER_ADMIN temporarily see the platform through someone else's eyes, to help debug their account or reproduce a problem they're reporting. Every use is logged. Impersonate on a user's own detail page in Platform Administration starts it (2026-08-20 — see below; before that date there really was no frontend at all, only a raw API call).
SUPER_ADMIN-only (SuperAdminGuard — a literal role check, not a delegable permission, since impersonation is intentionally non-delegable). POST /auth/impersonate starts a session as another active user; POST /auth/impersonate/:sessionId/end ends it. Every start/end/rejection is written to the audit log (impersonation.started/.ended/.rejected).
Frontend (added 2026-08-20): an Impersonate button on Platform Administration → Users's own user detail page (/platform/users/[id]) — hidden for your own account and for any non-ACTIVE target, since there's nothing valid to act as either way. Confirming a reason opens the session and drops you on /dashboard, now browsing as that user for real (their own data, not a preview) — a global ImpersonationBanner (mounted once above the whole app, so it survives any page or layout) names who you're viewing as and offers End impersonation, which lands you back on the user detail page you started from. The impersonation JWT is deliberately access-only with no refresh token (1h hard TTL, per this module's token model above) — the admin's real tokens are stashed rather than overwritten, so ending the session (or the token simply expiring) restores them instead of leaving the admin logged out. apps/wiki itself has no coverage of this to point at yet beyond this paragraph; live browser coverage is scripts/e2e/menu/impersonation.mjs's testImpersonationUi.
Audit log
In plain terms: a permanent, tamper-proof history of security-relevant actions across the whole platform — who signed in, who changed a role, who archived an organization. Nobody, including a SUPER_ADMIN, can edit or delete an entry once it's written.
Every event in this module (login, role change, org lifecycle, impersonation...) is written to the append-only AuditLog via AuditService (never raw Prisma writes — a DB trigger blocks update/delete). The viewing UI (/platform/audit, platform:audit:read) is a Platform Administration page — see Platform Administration → Audit log.
Testing this module
scripts/e2e/menu/auth.mjs, organizations.mjs, and impersonation.mjs (41 checks total) run this module's flows end to end and set lastVerified above. impersonation.mjs gained real browser coverage 2026-08-21 (testImpersonationUi) — the button/dialog/banner/end-session flow added 2026-08-20 had zero coverage, unit or e2e, before this round; its own API-level checks (start/end a session directly) are unchanged. organizations.mjs also covers the workspace Dashboard (/org/[slug]/dashboard, 2026-08-12) — previously only ever visited as a waypoint on the way to the other tabs, never asserted on its own: the viewer's own role line, the Members/Roles/Products shortcut cards, and a permitted card actually navigating on click. apps/portal/pages/org/[slug]/dashboard.test.ts (5 tests) covers the same page's permission gating — Products has no gate at all (visible to every member of every workspace) while Members/Roles stay greyed out and unclickable without their own read permission. Not covered by automation, and still worth a manual pass — see docs/MANUAL_TESTING.md: Google OAuth, switching between two organizations, restoring an archived org, transfer ownership, an existing user accepting an invitation (only the brand-new-user path is automated), and editing an existing role's permission set (only assigning a role to a member is automated).
LoginForm.vue/RegisterForm.vue/pages/auth/[mode].vue (round 51 of the portal test-coverage initiative, 2026-08-12) — the last major page family with zero unit coverage, though auth.mjs already exercises it thoroughly live (see above). Checked first before writing anything new: every real branch these two forms have is already driven live (register → verify → login, wrong password, password reset, magic link, email OTP) — the only thing genuinely untestable either way is Google OAuth (a real external consent screen, same class as HR Pool's Turnstile), so no new e2e was added. Unit coverage instead: apps/portal/components/auth/LoginForm.test.ts (12 tests) — a successful password login redirecting to /dashboard, the emailNotVerified branch showing a resend link, resend success/rate-limited, invalidCredentials showing the generic message (never leaking the backend's own text), any other error falling back to the server-derived message, magic link send success/rate-limited, the full OTP send→verify→redirect round trip and its own failure path, and the Google divider/button only rendering in the password branch. apps/portal/components/auth/RegisterForm.test.ts (6 tests) — successful register showing the resend link, rate-limited register, an already-registered email's error, and the resend-verification success/rate-limited/no-reveal-on-other-errors paths (matching requestMagicLink/requestOtp's own "never confirm or deny an account exists" contract). apps/portal/pages/auth/[mode].test.ts (3 tests) — the shell's own invisible/inert toggle between the two stacked forms, and picking a tab navigating to the other mode. Found a real, minor UI quirk while writing the resend tests, not fixed (app code, out of this round's scope, same class of finding as the AlertDialogAction auto-close race documented elsewhere in this initiative): LoginForm.vue's onResendVerification() never resets its own resent ref back to false at the top of the function, only ever sets it true on success — since the template checks v-if="resent" before v-else-if="resendError", a rate-limited resend attempt that lands AFTER an earlier successful one still shows the stale "email sent" message instead of the rate-limit one. Low real-world reach (the resend button stays disabled for the full cooldown, so hitting this needs 3+ genuine sends inside one minute) but a real inconsistency — pinned by its own test rather than silently working around it.
/auth/forgot-password, /auth/reset-password (round 52, 2026-08-12, part 2 of the /auth/* family) — auth.mjs's testPasswordReset() already drives the full live round trip (request → real emailed link → confirm → old password rejected → new password works), so this round is unit-only too. apps/portal/pages/auth/forgot-password.test.ts (5 tests) — a successful request showing the sent-to state with a working resend link, a rate-limited request, any other failure falling back to the server-derived message, resend targeting the SAME address the original request used, and "Back to sign in" navigating away. apps/portal/pages/auth/reset-password.test.ts (4 tests) — a missing ?token= showing the invalid-link error instead of any form at all, a successful confirm showing the success state with a sign-in link, an invalid/expired token showing the server's own message, and a failure with no message at all falling back to the generic invalid-link text.
/auth/magic-link, /auth/verify-email, /auth/callback, /auth/error, /auth/logout (round 53, 2026-08-12) — closes out the entire /auth/* family (rounds 51-53). auth.mjs's testMagicLink()/testRegisterVerifyLogin() already drive each page's own real happy path live; this round covers the branches those single-pass live runs never reach — missing/rejected tokens, and the returnTo open-redirect guard (utils/returnTo.ts's isSafeReturnTo, shared by magic-link.vue and callback.vue) neither magic link nor Google OAuth's own live check exercises either direction of. apps/portal/pages/auth/magic-link.test.ts (5 tests) — missing token, a valid token logging in and redirecting to /dashboard, a safe returnTo redirecting there instead, an unsafe one (//evil.example.com) falling back to /dashboard rather than following it, and a rejected token. apps/portal/pages/auth/verify-email.test.ts (4 tests) — missing token, success, the server's own rejection message, and a message-less failure's generic fallback. apps/portal/pages/auth/callback.test.ts (4 tests) — the Google OAuth landing page: missing accessToken/refreshToken redirecting to /auth/error rather than attempting to log in, real tokens logging in and redirecting to /dashboard, and the same safe/unsafe returnTo pair as magic-link. Google OAuth itself stays the one branch of the whole family genuinely untestable live (a real external consent screen, same class as HR Pool's Turnstile) — this page's own logic is exactly what's left once that's carved out. apps/portal/pages/auth/error.test.ts (1 test) — the static OAuth-error landing page has no logic at all, just a render smoke test. apps/portal/pages/auth/logout.test.ts (2 tests) — useCurrentUser().reset() and the final navigateTo('/') both fire on mount, including when the logout request itself fails (neither is visible to a live check that only watches the network request succeed).
/account/profile (had zero coverage, unit or live, before 2026-08-12) — scripts/e2e/menu/ account.mjs (4 checks, round-based, one /account/* page at a time — notifications.vue/ api-tokens.vue are each their own future round) edits Display name, saves, confirms the success toast, then reloads FRESH and reads the field back to confirm it actually persisted (not just that the toast fired), and restores the original value the same way afterward so the shared e2e-test-admin account's own name doesn't stay changed between runs. Companion unit coverage, apps/portal/pages/account/profile.test.ts (9 tests) — the form resetting to the real profile's values on load, an unset timezone defaulting to the detected zone vs. a real one being preserved, onSubmit's payload construction (null, not ''/undefined, for a cleared field, and interestAreaOther only when OTHER is among interestAreas), a failed save's inline+toast error, a staged avatar change calling removeAvatar/uploadAvatar on save (the latter needed its own fetch mock — a second hand-rolled-upload page in this initiative alongside Shared Documents' XHR one), and the "Auto" timezone menu option resolving to the real detected zone. First page in this initiative needing the REAL useCurrentUser() (not the shallow shared test fake every /team/* page uses) — its updateProfile/uploadAvatar/removeAvatar/loaded state aren't in that fake at all.
/account/api-tokens ("Connected Apps" — read/revoke only, self-service creation was removed when OAuth 2.1 replaced it, docs/mcp/ADR-001-external-mcp-server.md Decision 5) — account.mjs grew to 6 checks. Found a real bug on the first live run (2026-08-12, fixed same day, user- approved): GET /me/personal-access-tokens 500'd on this dev DB — two migrations (add_personal_access_tokens, oauth_authorization_server) existed in the repo but had never actually been applied here, so the PersonalAccessToken/OAuthClient/OAuthAuthorizationCode/ OAuthRefreshToken tables genuinely didn't exist. The OAuth 2.1 authorization-server feature had never been run against a live dev database until this round — fixed with pnpm --filter @p4p/database migrate:dev. The file now smoke-tests the page loads a real 200 against the now-working endpoint; no OAuth-connected app exists yet for e2e-test-admin to actually list/revoke, and creating one live needs the full DCR + PKCE + consent-page flow (/oauth/authorize) — a disproportionate setup cost for this round, same class of call as skipping live HR Pool self-service creation elsewhere in this initiative. The revoke flow itself is unit-tested instead: apps/portal/pages/account/api-tokens.test.ts (7 tests) — real data rendering (preferring oauthClientName over the token's own name), "Never used" vs. a real date, the empty state, a load error, and a successful/failed revoke.
/oauth/authorize (the OAuth 2.1 consent screen, docs/mcp/ADR-001-external-mcp-server.md Decision 5) — later did get the full live exercise round 40 called disproportionate: scripts/e2e/ menu/oauth-authorize.mjs (10 checks, new file, 2026-08-12) is the FIRST end-to-end run of the whole flow ever, on any environment this initiative has touched — Dynamic Client Registration (RFC 7591, no approval step, no client secret issued), the portal's own session-authenticated consent screen (real client name and requested scope rendered), approving and getting back a real authorization code, the PKCE-verified token exchange (and confirming a WRONG code_verifier is actually rejected, not just accepted and ignored), the resulting token showing up as a real "Connected App" on /account/api-tokens — closing the loop with round 40 — and revoking it there through the same UI flow round 40 already proved. All 10 checks passed clean on the very first live run: the feature genuinely works, once its migration gap (round 40) was fixed. One real e2e-script gotcha found and fixed while writing this: clicking Approve fires a real window.location.href redirect the instant the response arrives, which invalidates Chrome DevTools Protocol's handle on that same response before a plain waitForResponse(...).json() can read it ("No resource with given identifier found") — fixed by intercepting the request/response pair via page.route(), which reads the body BEFORE the page's own JS ever sees it, and separately blocking the real external redirect outright (the fake client domain doesn't exist) so the page never actually tries to leave. Companion unit coverage, apps/portal/pages/oauth/authorize.test.ts (9 tests) — the first round in this initiative with no team-stubs.ts/org-stubs.ts style permission fake (this page only needs a bare useAuth().isAuthenticated, its own small local mock): redirecting to sign-in with a returnTo when signed out (never even reaching the consent-info call), the invalid-request error for a missing required query param, real consent info rendering with scope hints (an unmapped scope falls back to the raw scope string), the true-empty "no scopes requested" state, a load error, decide(true) sending the full PKCE payload and redirecting to the real callback via window.location.href (needed its own Object.defineProperty mock, same technique round 46's mcp/index.test.ts used — this initiative's second test needing to mock a real browser navigation), decide(false) sending approved: false, and a failed approve/deny resetting the deciding flag.