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