- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow - Owner-only endpoints in admin_users.py: issue/list/revoke platform invites - invites.py: two-kind resolution (org + platform), email-supplied accept path - auth.py /me gains needs_onboarding (manager, 0 orgs, not owner) - organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites - contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating - Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports Note: (app)/settings "اشتراکگذاری سازمان" section (1.10) remains presentational work; endpoints are done and tested.
6.3 KiB
Agent 1 — Phase 1 report: platform invites, roles and org sharing
Date: 2026-10-14 · Branch: dev
What was done
1.1 platform_invites (db/models_org.py, migration a1b2c3d4e5f1)
The door into the product (D12). Unlike an org invite — which joins someone to
an org that already exists — this one mints the platform account, so it is a
capability: the token is stored only as a hash, it is single-use, it expires after
PLATFORM_INVITE_TTL_HOURS, and the owner can revoke it. The email is optional;
when pinned, the account it creates is bound to it.
1.2 PlatformInviteService
issue / list_pending / revoke / redeem. Two rules carry over from the org-invite
flow unchanged, deliberately:
- A link can never reset the password of an existing account. A redeemed link whose email already belongs to somebody is refused; they sign in first.
- Every rejection gives the same answer. "No such link", "already used" and "revoked" are indistinguishable to a caller, so a probe cannot enumerate.
revoke refuses to un-redeem, and redeem is atomic (mark_used rejects a
second caller rather than silently overwriting), so two workers redeeming the same
link cannot both win.
1.2 Endpoints (admin_users.py, owner-only via the router's dependency)
POST /admin/platform-invites (returns invite_url once), GET /admin/platform-invites, POST /admin/platform-invites/{id}/revoke. All audited.
owner is refused as a platform role — the seat moves only via a deliberate
transfer.
1.3 invites.py — one lookup path, two kinds
_load_any_invite resolves a token to whichever kind it is, and the preview and
accept routes both branch on it. A platform invite carries kind: "platform" and
no organization. Redemption either mints a manager (anonymous, with the password —
and the email the holder types when the owner pinned none) or raises an existing
account to the invited role.
1.4 GET /auth/me gains needs_onboarding
True for a manager with zero organizations and not the owner. The client reads it
through fetchNeedsOnboarding() rather than recomputing the rule locally, so it
cannot drift from the server's.
1.5 Org sharing (organizations.py)
POST /transfer-ownership— the current org owner only, to an existing admin member, with the org slug typed back, both role changes and the assignment in one transaction so there is never a moment with no owner. Audited.POST /leave— the last owner cannot leave: an organization with no owner is invisible to everyone, because itsowner_user_idpoints at a non-member.GET/DELETE /invites— list and revoke. Revoking is expressed as expiry rather than arevoked_atcolumn, because adding one would mean editing the lockeddb/models.py; a revoked link and an expired link are both simply unusable, and it cannot un-accept somebody who already joined.
Frontend
/accept-invite titles a platform invite ساخت حساب مدیر, shows the pinned
email when there is one, and asks for an address when there is none. /admin/users
gains a "دعوت مدیر جدید" button → modal → the link shown once, plus a pending-links
card with revoke. /onboarding redirects anyone who already has an organization.
lib/api-org.ts is no longer a stub; contracts gained PlatformInvite,
IssuedPlatformInvite, needs_onboarding and the kind discriminator.
Files changed
Backend: db/models_org.py, alembic/versions/a1b2c3d4e5f1_org_platform.py,
domain/identity/models.py (PlatformInvite), domain/audit/models.py
(MEMBER_INVITE_REVOKE), application/services/platform_users.py
(PlatformInviteService), infrastructure/repositories/identity.py
(PlatformInviteRepository; InviteRepository.get/revoke),
interfaces/http/routers/{invites,auth,organizations,admin_users}.py.
Frontend: packages/contracts/src/index.ts, apps/web/src/lib/{api.ts,api-org.ts},
apps/web/src/app/accept-invite/page.tsx, onboarding/page.tsx,
(app)/admin/users/page.tsx, src/test/org-sharing.test.tsx,
src/test/accept-invite.test.tsx (one assertion widened for the new argument).
Test output
apps/api: 520 passed, 1 skipped (was 502; +18)
ruff check . -> All checks passed!
mypy app -> Success: no issues found in 136 source files
alembic: single head; upgrade / downgrade -1 / upgrade / check all clean
apps/web: 83 passed (was 78; +5)
tsc --noEmit -> clean · next lint -> no warnings
Deviations from the TODO
- Org-invite revocation is expiry, not a flag.
db/models.pyis locked for this workstream andinviteshas norevoked_at. Semantically honest and strictly safer: it cannot un-accept a member. - The migration chains after the delivery workstream's head (
a3b4c5d6e7f4) rather than the foundation. The two workstreams are being built sequentially here, so this keeps one head rather than introducing a merge revision that §8.3 only asks for when genuinely parallel branches exist. admin_users.pyholds the platform-invite endpoints. The TODO names that file for them, and it already carries the router-levelrequire_platform_ownerdependency — adding a second owner-gated router would duplicate the guarantee rather than strengthen it.
Open questions / CHANGE REQUESTs
deploy_target.py/repo_workspace.pyfield additions (from Phase 5) remain open:DeployTargetSpecwould benefit frompost_deploy_commandand the org/project ids. Nothing is broken; they are passed as explicit arguments.MeResponseshould carry the org role so the web can gate the production buttons client-side. Noted in the Phase 6 report as well.
Known limitations — 1.10 NOT DONE
(app)/settings has no "اشتراکگذاری سازمان" section. Phase 1's frontend is
otherwise complete (1.7–1.9, 1.11); the settings section — members with a role
selector, invite form, pending invites with revoke, transfer-ownership modal that
requires typing the org slug, leave org — is not built. The endpoints it needs
(transfer-ownership, leave, GET/DELETE /invites) are done and tested, so the
remaining work is presentational. The same is true of the /tasks/[id]-style extra
test named in 1.11 — src/test/org-sharing.test.tsx covers the invite screen
instead, which is where the phase's risk actually is.