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 mirror the technical sections below — each page's own help icon links straight into the group relevant to it, not this whole list.
Authentication
Create an account — /auth/sign-up → Email, Password → Create account → check your inbox for a verification link. See field guide below.
Sign in without a password — on /auth/sign-in, click Sign in with a magic link or Sign in with a code instead (toggles next to the password form), enter your email, check your inbox. See field guide below.
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 below.
Create an account — field guide
- Email (required) — must look like a valid email address. If it's already registered, you're told so directly on submit — unlike the passwordless flows below, sign-up does not hide whether an address already has an account.
- Password (required) — 8–128 characters. No other complexity rule (no required uppercase/ digit/symbol).
- After submitting, a verification email is sent and a Resend verification email link appears — capped at 3 sends per minute; going over that shows a distinct rate-limit message instead of the generic error. Signing up also silently creates a personal workspace for you in the background (see What is P4P) — nothing on this screen mentions it.
Sign in without a password — field guide
Both the magic-link and one-time-code toggles replace the password form entirely (not a second field added to it) — pick one method for that visit, not both at once.
- Magic link: Email (required, non-empty only — no format check client-side) → Send link. Whatever you type, the confirmation text always reads "If an account exists, a sign-in link has been sent" — the app deliberately never confirms or denies that an address is registered, so a typo'd email shows the exact same success message as a real one. The link itself expires after 15 minutes.
- One-time code: Email (same non-revealing behavior as the magic link) → Send code → a 6-digit code field appears (numeric only, exactly 6 digits — the Verify button stays disabled until all 6 are entered). The code expires after 10 minutes, and only the most recently sent code is ever valid — requesting a new one immediately invalidates whichever code was sent before it.
- Both methods share the same 3-sends-per-minute cooldown as every other email-sending action in this platform, shown as a countdown on the resend button.
Recover a forgotten password — field guide
- Request (
/auth/forgot-password): Email (required, format-checked) → Send reset link. Same non-revealing confirmation message as the passwordless sign-in methods above — you can't tell from this screen alone whether the address you typed actually has an account. - Set new password (the emailed link,
/auth/reset-password?token=…): New password (required, 8–128 characters) and Confirm password (required) — the two must match exactly, checked against Confirm password specifically (that's the field the mismatch error shows under, not New password). A missing token (opening the page with no link at all) replaces the whole form with a plain error message instead; an invalid or already-used token is only caught on submit and shows inline below the form, same as any other submit error.
Account
Edit your profile — /account/profile → update your photo, name, phone, timezone, bio, location, birth date, skills, and interest areas.
Accepting an invitation
Accept a colleague's invitation — open the link from the invitation email (/invitations/[token]) → if you don't have an account yet, a password field appears automatically; if you do, just confirm.
Creating an organization
There's no button for this today — see Creating an organization below for the one indirect way it happens (registration's auto-created personal workspace, or a platform admin's "New workspace" dialog).
Organization Settings
Edit your organization's name/description/website — Settings → edit the fields → Save changes. See field guide below.
Edit organization settings — field guide
Without portal:organization:manage (not delegated to every role by default — see Roles below), this whole page shows the same three values as plain read-only text instead of a form at all — there's no disabled/greyed-out form to signal "you can look but not touch," the inputs simply aren't there.
- Workspace URL (shown above the form, never editable) — set once at creation and permanent; there is no field or action anywhere in the product that changes it afterward.
- Name (required) — 2–100 characters.
- Description (optional) — up to 500 characters.
- Website (optional) — if you type anything at all, it must be a complete, valid URL (e.g.
https://example.com) — a bare domain without a scheme is rejected. Leaving it empty is fine; it's the only field on this form that's genuinely optional both ways. - What you won't find here, even though you might expect to: changing who owns the organization (that's Transfer ownership, a per-member action on the Members list below, OWNER-only) and archiving/reactivating the organization (no UI exists for this at all today — see the technical section below).
Members
Invite someone to your organization — Members → Invite member → Email, pick a Role → Send invitation. See field guide below.
Remove a member, or hand over ownership — Members list → the row's actions menu → Transfer ownership (only the current OWNER can do this) or remove them.
Assign a role to a member, or edit their job title — open that member's own page (not the list) → Roles section or Job title in this workspace → Save. See field guide below.
Invite a member — field guide
- Email (required) — no client-side format check; a malformed address is only caught once you submit. An address that's already a member, or already has a pending invitation, is also only caught then.
- Role (required, single-select) — unlike the platform staff invite, an org member can never land with zero roles; the picker defaults to MEMBER (or whatever role happens to be first, if a MEMBER role doesn't exist) so it's never left blank by accident. OWNER never appears as a choice — the only way to make someone an Owner is Transfer ownership, never an invitation.
- Send invitation is rate-limited to 3 sends per minute, same cooldown-with-countdown behavior as every other invite dialog in this platform.
Member roles & job title — field guide
- Job title in this workspace (optional, plain text, up to 100 characters) — the one field on this page you can always edit on your own membership regardless of any role or permission; editing someone else's job title needs
portal:members:manage. It's per-organization, not a platform-wide title — the same person can hold a different job title in each workspace they belong to. - Roles (checkboxes, requires
portal:members:manage) — any number, including zero (removing every role doesn't remove the membership itself, just leaves them without permissions). OWNER never appears in this list either, same reasoning as the invite dialog — it's only ever moved via Transfer ownership, never checked on or off here. - Save on each of the two panels only enables once something in that specific panel actually changed — they're independent; editing the job title doesn't require also touching Roles, or vice versa.
Roles
Create a custom role for your organization — Roles → Create role → name it, pick permissions. See field guide below.
Edit, duplicate, or delete a role — the role's ⋯ → Edit, Duplicate, or Delete. The four built-in roles (Owner/Admin/Member/Viewer) can't be renamed or deleted — their Edit menu item doesn't even appear, unlike a platform role's equivalent screen where Edit stays available on system roles with just the name locked (see Platform Administration → Roles); Duplicate still works on them, same as any role. Same field guide below for Create/Edit.
Create/edit an organization role — field guide
Structurally the same form as a platform role, with two real differences worth knowing:
- Name (required) — must be unique only within this organization, not platform-wide; two different organizations can both have a role literally named "Billing Manager" with no conflict. The dialog itself additionally refuses fewer than 2 characters, though the backend alone would accept a single character. Up to 100 characters.
- Description (optional) — up to 500 characters.
- Permissions (checkboxes, grouped by scope) — no minimum. On the four built-in roles this section is simply never reachable in the first place (no Edit action exists for them at all, as noted above) — there's no "read-only permissions view" to open, unlike a platform role's system rows where the dialog itself opens with only Name locked.
- Delete additionally refuses a role that's still assigned to any active membership or pending invitation — reassign everyone off it first. Platform roles have no equivalent check.
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.
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.
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.
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.
Organization Settings (/org/[slug]/settings)
In plain terms: the Settings page inside an organization only lets you edit its name, description, and website today — nothing more. 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 byportal:organization:manage, delegable to ADMIN or a custom role. This is allsettings.vueexposes — confirmed by reading the file (2026-08-04), it has no archive/activate controls. - 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 viaportal: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 (portal:members:readfor the list itself; every role including VIEWER can see it). - Invite (
POST /organizations/:id/members/invite) —portal: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 isportal:members:manage; 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, edit job title — both live on the member detail page (
members/[userId].vue), not the list.POST/DELETE .../members/:userId/roles/:roleIdandPATCH .../members/:userId/job-titleneedportal:members:manage; 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. Four roles come built in and can't be renamed or deleted — Owner, Admin, Member, Viewer — and you can create your own on top of those.
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) —portal:roles:read. - Create / edit / delete a custom role, add/remove a permission on a role (
POST,PATCH :roleId,DELETE :roleId,POST/DELETE :roleId/permissions/:permissionId) — allportal:roles:manage. System roles (OWNER/ADMIN/MEMBER/VIEWER) are protected from deletion/rename by a DB trigger, not just an application check — seedocs/iam/ADR-003-rbac.md.
MEMBER and VIEWER are deliberately identical in the seeded permission set today (both read-only) — this is a documented, re-confirmed decision, not a gap. Don't "fix" it without reading the reasoning in docs/iam/PERMISSIONS_CATALOG.md first.
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. There is currently no button for this anywhere — see the confirmed finding below.
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).
Confirmed (2026-08-04, grepped apps/portal for impersonat and for /auth/impersonate specifically): there is no frontend for this at all. Not a button hidden behind a permission gate — the string impersonat appears in the portal source exactly twice, both incidental (an AuditLog-domain filter option on /platform/audit, and a code comment), and nothing calls POST /auth/impersonate. This is fully backend-only today — reachable only via a direct API call with a SUPER_ADMIN session, not through any page in Platform Administration or elsewhere. If you need to impersonate someone right now, there is no UI path — it has to be a raw authenticated request.
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 (38 checks total) run this module's flows end to end and set lastVerified above. 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).