Split from #71. The backend for invite & onboard is complete and merged-pending on feat/issue-71-invite-onboard (email transport, store-not-schema invite table, PASETO invite token, and both endpoints with boot wiring). This issue covers only the generated React admin UI on top of those endpoints.
Backend contract (already built)
POST /api/v1/forge/auth/invites — authenticated. Runs the same privilege guards as POST /users (schema-level CreateUser access, platform_admin-grant guard, Cedar role_rank guard). Emails an accept link, returns the invite reference.
Request:
{ "email": "x@agency.gov", "display_name": "X", "tenant_type": "Organization", "tenant_id": "<ulid>", "role": "member" }
(display_name, tenant_type, tenant_id, role all optional)
Response 201:
{ "invite_id": "<opaque>", "email": "x@agency.gov", "expires_at": "<rfc3339>" }
POST /api/v1/forge/auth/invites/accept — public (no bearer). Looks the invite up by invite_id, reconstructs + re-verifies the stored token (signed claims authoritative), creates the User + TenantMembership, consumes the invite.
Request:
{ "invite_id": "<from emailed link>", "password": "<min 8>", "display_name": "X" }
Response 201:
{ "email": "x@agency.gov", "roles": ["member"] }
The emailed link shape is <public_base_url>/invite/accept?invite=<invite_id>.
Supporting reads already available:
GET /api/v1/forge/users/roles — role picker source (name + rank; platform_admin filtered out unless caller holds it).
- Tenant-root list — schemas annotated
@tenant(root) (the "Organization" in a given domain). The org picker should be filtered to tenants the operator can write to.
Frontend work (this issue)
/admin/users/invite — invite form: email, optional display_name, tenant-root picker (from @tenant(root) schemas), role picker (from GET /users/roles). POST to /auth/invites. In dev (email disabled) the endpoint returns a 5xx on send; consider surfacing the invite_id for copy-paste once a dev-mode return path exists (follow-up).
- Public
/invite/accept?invite=<id> page — unauthenticated route: reads invite from the query string, collects password (+ optional display name), POSTs to /auth/invites/accept, then routes to login on success.
- Hide
@hidden fields (e.g. password_hash) on the generic create/edit forms — the admin shell should respect the annotation even on the dynamic form.
- Registration plumbing: add the new files to
ADMIN_FILES + route-manifest, wire into App.tsx routing and the nav.
Out of scope / deferred
Split from #71. The backend for invite & onboard is complete and merged-pending on
feat/issue-71-invite-onboard(email transport, store-not-schema invite table, PASETO invite token, and both endpoints with boot wiring). This issue covers only the generated React admin UI on top of those endpoints.Backend contract (already built)
POST /api/v1/forge/auth/invites— authenticated. Runs the same privilege guards asPOST /users(schema-levelCreateUseraccess,platform_admin-grant guard, Cedarrole_rankguard). Emails an accept link, returns the invite reference.Request:
{ "email": "x@agency.gov", "display_name": "X", "tenant_type": "Organization", "tenant_id": "<ulid>", "role": "member" }(
display_name,tenant_type,tenant_id,roleall optional)Response
201:{ "invite_id": "<opaque>", "email": "x@agency.gov", "expires_at": "<rfc3339>" }POST /api/v1/forge/auth/invites/accept— public (no bearer). Looks the invite up byinvite_id, reconstructs + re-verifies the stored token (signed claims authoritative), creates the User + TenantMembership, consumes the invite.Request:
{ "invite_id": "<from emailed link>", "password": "<min 8>", "display_name": "X" }Response
201:{ "email": "x@agency.gov", "roles": ["member"] }The emailed link shape is
<public_base_url>/invite/accept?invite=<invite_id>.Supporting reads already available:
GET /api/v1/forge/users/roles— role picker source (name + rank;platform_adminfiltered out unless caller holds it).@tenant(root)(the "Organization" in a given domain). The org picker should be filtered to tenants the operator can write to.Frontend work (this issue)
/admin/users/invite— invite form:email, optionaldisplay_name, tenant-root picker (from@tenant(root)schemas), role picker (fromGET /users/roles). POST to/auth/invites. In dev (email disabled) the endpoint returns a 5xx on send; consider surfacing theinvite_idfor copy-paste once a dev-mode return path exists (follow-up)./invite/accept?invite=<id>page — unauthenticated route: readsinvitefrom the query string, collects password (+ optional display name), POSTs to/auth/invites/accept, then routes to login on success.@hiddenfields (e.g.password_hash) on the generic create/edit forms — the admin shell should respect the annotation even on the dynamic form.ADMIN_FILES+ route-manifest, wire intoApp.tsxrouting and the nav.Out of scope / deferred
change_passwordendpoint exists — small follow-up).