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
+2
View File
@@ -30,6 +30,8 @@ class AuditAction:
MEMBER_JOIN = "member.join"
MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove"
#: An organization's pending invitation was revoked.
MEMBER_INVITE_REVOKE = "member.invite_revoke"
#: The platform owner created an account (manager or plain user).
USER_CREATE = "user.create"
#: A user's platform role changed (user <-> manager, or manager -> user).
+48 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
@@ -187,3 +187,50 @@ class Invite:
def is_pending(self, now: datetime | None = None) -> bool:
return self.accepted_at is None and not self.is_expired(now)
@dataclass(slots=True, kw_only=True)
class PlatformInvite:
"""The platform owner's invitation to create a new manager account (D12).
Different in kind from an ``Invite``: an org invite joins someone to an
organization that already exists, while this one mints the platform account
itself. The token lives only as a hash.
"""
token_hash: str
created_by_user_id: str
expires_at: datetime
email: str | None = None
platform_role: PlatformRole = PlatformRole.MANAGER
used_at: datetime | None = None
used_by_user_id: str | None = None
revoked_at: datetime | None = None
revoked_by_user_id: str | None = None
note: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def is_redeemed(self) -> bool:
return self.used_at is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
def is_expired(self, now: datetime | None = None) -> bool:
"""Redeeming a used or revoked link is also "expired" — one helper, one rule.
Both sides are normalised to aware before comparing: the row comes back
from SQLite with the offset stripped, while ``utcnow()`` carries one, and
comparing the two raises rather than answering.
"""
if self.used_at is not None or self.revoked_at is not None:
return True
return _aware(self.expires_at) <= _aware(now or utcnow())
def _aware(value: datetime) -> datetime:
"""A datetime guaranteed to carry a UTC offset."""
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)