- 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.
128 lines
6.3 KiB
Markdown
128 lines
6.3 KiB
Markdown
# 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.
|