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.
This commit is contained in:
mjalmoudy94
2026-10-14 09:14:09 +03:30
parent 3c8296e354
commit 5095a29df9
20 changed files with 1933 additions and 37 deletions
+127
View File
@@ -0,0 +1,127 @@
# 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.