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:
@@ -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.
|
||||
Reference in New Issue
Block a user