Files
mjalmoudy94 5095a29df9 feat(org): platform invites, roles, org sharing (v2 phase A1-1)
- 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.
2026-10-14 09:14:09 +03:30

6.3 KiB
Raw Permalink Blame History

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 its owner_user_id points at a non-member.
  • GET/DELETE /invites — list and revoke. Revoking is expressed as expiry rather than a revoked_at column, because adding one would mean editing the locked db/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

  1. Org-invite revocation is expiry, not a flag. db/models.py is locked for this workstream and invites has no revoked_at. Semantically honest and strictly safer: it cannot un-accept a member.
  2. 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.
  3. admin_users.py holds the platform-invite endpoints. The TODO names that file for them, and it already carries the router-level require_platform_owner dependency — adding a second owner-gated router would duplicate the guarantee rather than strengthen it.

Open questions / CHANGE REQUESTs

  • deploy_target.py / repo_workspace.py field additions (from Phase 5) remain open: DeployTargetSpec would benefit from post_deploy_command and the org/project ids. Nothing is broken; they are passed as explicit arguments.
  • MeResponse should 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.