Files
smart-org/docs/plan/reports/agent-1-phase-1.md
T
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

128 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.