ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+11 -3
View File
@@ -311,10 +311,18 @@
### 5.1 نقش‌ها ### 5.1 نقش‌ها
> **پیاده‌سازی‌شده (ساختار مالکیت پلتفرم):** مالک سامانه **دقیقاً یک نفر** است و
> این یک قاعدهٔ دیتابیسی است (ایندکس یکتای جزئی روی `users.platform_role`).
> فقط او می‌تواند **مدیر** بسازد؛ هر مدیر می‌تواند سازمان خودش را بسازد یا با دعوت
> در سازمان مدیران دیگر عضو شود. ثبت‌نام عمومی وجود ندارد و حساب‌ها فقط توسط مالک
> یا با پذیرش لینک دعوت ساخته می‌شوند. جزئیات در `README.md` و
> `docs/security-checklist.md` §۱۹.۲.
#### 5.1.1 مالک سامانه #### 5.1.1 مالک سامانه
توانایی‌ها: توانایی‌ها:
- ساخت و تنزل مدیران، انتقال مالکیت سامانه، فعال/غیرفعال‌سازی حساب‌ها.
- تعریف Providerهای AI و API keyها. - تعریف Providerهای AI و API keyها.
- تعریف مدل‌های مجاز و مدل‌های داخلی پنهان. - تعریف مدل‌های مجاز و مدل‌های داخلی پنهان.
- تعریف routing policy: ارزان، سریع، دقیق، reasoning، multimodal. - تعریف routing policy: ارزان، سریع، دقیق، reasoning، multimodal.
@@ -323,11 +331,11 @@
- مشاهده کل مصرف، درآمد، خطاها، سازمان‌ها و runها. - مشاهده کل مصرف، درآمد، خطاها، سازمان‌ها و runها.
- تعریف Agent/Employee templateهای پیش‌فرض. - تعریف Agent/Employee templateهای پیش‌فرض.
#### 5.1.2 مدیر سازمان مشتری #### 5.1.2 مدیر (platform manager)
توانایی‌ها: حسابی که فقط مالک سامانه می‌سازد. توانایی‌ها:
- ساخت سازمان. - ساخت سازمان (و مالک شدن آن) و پیوستن با دعوت به سازمان مدیران دیگر.
- دعوت اعضا. - دعوت اعضا.
- خرید اعتبار یا اشتراک. - خرید اعتبار یا اشتراک.
- ساخت پروژه. - ساخت پروژه.
+30 -11
View File
@@ -21,11 +21,28 @@ provider. این‌ها فقط در **Admin Console** و فقط برای مال
پلتفرم سه لایهٔ نقش دارد: پلتفرم سه لایهٔ نقش دارد:
1. **مالک سامانه (platform owner)** — اکانت‌های agy، مدل‌ها، routing policy، 1. **مالک سامانه (platform owner)** — **دقیقاً یک حساب** و صاحب کل سیستم. اکانت‌های
تعرفه‌ها، sandbox templateها، مشاهدهٔ کل مصرف. agy تنها ارائه‌دهندهٔ مدل agy، مدل‌ها، routing policy، تعرفه‌ها، sandbox templateها، مشاهدهٔ کل مصرف، و
سامانه است و بخش جداگانهٔ «ارائه‌دهنده‌ها» وجود ندارد. ساخت/تنزل **مدیران**. agy تنها ارائه‌دهندهٔ مدل سامانه است و بخش جداگانهٔ
2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعال‌سازی کارمند، مصرف. «ارائه‌دهنده‌ها» وجود ندارد.
3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی. 2. **مدیر (platform manager)** — حسابی که فقط مالک سامانه می‌سازد. هر مدیر
می‌تواند **سازمان‌های خودش را بسازد** (و مالک آن‌ها شود) یا با **دعوت** صاحب
سازمان، در سازمان مدیران دیگر عضو شود. مدیر به کنسول مالک سامانه دسترسی ندارد.
3. **کاربر عادی (platform user)** — سازمان نمی‌سازد؛ فقط داخل سازمان‌هایی که با
دعوت به آن‌ها پیوسته کار می‌کند.
**ثبت‌نام عمومی وجود ندارد.** حساب‌ها از دو مسیر ساخته می‌شوند:
- **ساخت مستقیم توسط مالک سامانه** در `/admin/users` (برای مدیر یا کاربر عادی) که
یک رمز موقت یک‌بارمصرف برمی‌گرداند؛
- **پذیرش لینک دعوت**: دعوت‌شده اگر حساب نداشته باشد، در همان صفحه رمز خودش را
تعیین می‌کند و حساب ساخته می‌شود. اگر حساب داشته باشد باید اول وارد شود — یک
لینک دعوت هرگز جای reset رمز را نمی‌گیرد.
«فقط یک مالک» یک قاعدهٔ دیتابیسی است: ایندکس یکتای جزئی روی
`users.platform_role = 'owner'`، پس حتی یک درج مستقیم هم نمی‌تواند مالک دوم بسازد.
جابه‌جایی صندلی مالکیت فقط با `POST /admin/transfer-ownership` انجام می‌شود که در
یک تراکنش، مالک فعلی را به مدیر تنزل می‌دهد و جانشین را مالک می‌کند.
--- ---
@@ -79,14 +96,16 @@ pnpm dev
### حساب‌های نمونه (فقط محیط توسعه) ### حساب‌های نمونه (فقط محیط توسعه)
`pnpm db:seed` دو حساب می‌سازد و **هر دو عضو سازمان نمونه هستند**، پس بعد از ورود `pnpm db:seed` دو حساب می‌سازد:
بلافاصله یک داشبورد پر می‌بینید:
| نقش | ایمیل | رمز | | نقش | ایمیل | رمز | چه می‌بیند |
|---|---|---| |---|---|---|---|
| کاربر نمونه | `demo@example.com` | `demo12345` | | مالک سامانه (admin console) | `owner@example.com` | `owner12345` | کنسول مالک سامانه، بدون هیچ سازمانی |
| مالک سامانه (admin console) | `owner@example.com` | `owner12345` | | مدیر نمونه | `demo@example.com` | `demo12345` | سازمان نمونه که خودش مالک آن است |
> مالک سامانه **عضو هیچ سازمانی نمی‌شود** — او مستأجرها را از کنسول می‌بیند، نه از
> داخل داده‌شان. برای دیدن داشبورد سازمانی با حساب مدیر نمونه وارد شوید.
>
> این‌ها رمزهای **توسعه** هستند و در `app/seed.py` سخت‌کد شده‌اند. هیچ‌وقت در > این‌ها رمزهای **توسعه** هستند و در `app/seed.py` سخت‌کد شده‌اند. هیچ‌وقت در
> production از این‌ها استفاده نکنید و `JWT_SECRET` / `ENCRYPTION_KEY` واقعی > production از این‌ها استفاده نکنید و `JWT_SECRET` / `ENCRYPTION_KEY` واقعی
> بگذارید (نگهبان config در حالت production مقادیر placeholder را رد می‌کند). > بگذارید (نگهبان config در حالت production مقادیر placeholder را رد می‌کند).
+26
View File
@@ -29,6 +29,32 @@
> `src/test/motion.test.tsx` Tests می‌شود تا بازنویسی بعدی آن را بی‌صدا > `src/test/motion.test.tsx` Tests می‌شود تا بازنویسی بعدی آن را بی‌صدا
> حذف نکند. > حذف نکند.
> >
> **افزودهٔ بعد از بستن مراحل (درخواست کاربر):** ساختار مالکیت پلتفرم. **دقیقاً یک
> مالک سامانه** وجود دارد که صاحب کل سیستم است و تنها او می‌تواند **مدیر** بسازد؛
> هر مدیر می‌تواند سازمان‌های خودش را بسازد یا با دعوت در سازمان مدیران دیگر عضو
> شود. جزئیات:
>
> - `users.is_platform_owner` (boolean) جای خود را به `platform_role`
> (`owner` | `manager` | `user`) داد، با مهاجرت `d4e5f6a7b8c9` و backfill.
> - «فقط یک مالک» یک قاعدهٔ دیتابیسی است: ایندکس یکتای جزئی
> `uq_single_platform_owner ... WHERE platform_role = 'owner'`.
> - **ثبت‌نام عمومی حذف شد** (`POST /auth/signup` وجود ندارد). حساب‌ها از
> `POST /admin/users` (فقط مالک) یا از پذیرش لینک دعوت ساخته می‌شوند؛ در مسیر
> دعوت، اگر حساب وجود نداشته باشد خود دعوت‌شده رمز تعیین می‌کند، و اگر وجود
> داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
> - ساخت سازمان فقط برای مالک و مدیران مجاز است؛ کاربر عادی با دعوت عضو می‌شود.
> - تنزل یا حذف یک مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت +
> عضویت، با audit). حساب مالک از API قابل تنزل/غیرفعال/حذف نیست؛ جابه‌جایی
> صندلی فقط با `POST /admin/transfer-ownership`.
> - فرانت‌اند: صفحهٔ جدید `/admin/users`، صفحهٔ `/signup` حذف شد، صفحهٔ
> `/accept-invite` برای ساخت حساب بازنویسی شد، و لینک «ساخت سازمان جدید» برای
> کاربران عادی پنهان است.
> - تست‌ها: `tests/test_platform_hierarchy.py` (۲۲ تست) + به‌روزرسانی
> `test_auth.py`, `test_invites.py`, `test_seed.py`, `test_realtime.py`؛
> جمع بک‌اند از ۲۱۶ به **۳۰۰ تست** و فرانت‌اند از ۴۵ به **۶۴ تست** رسید.
> `PRAGMA foreign_keys=ON` برای SQLite فعال شد تا `ON DELETE CASCADE` در dev و
> prod یکسان رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
>
> **چیزهایی که عمداً باز مانده‌اند:** در `docs/security-checklist.md` دو مورد > **چیزهایی که عمداً باز مانده‌اند:** در `docs/security-checklist.md` دو مورد
> «⚠️ بدون تست اختصاصی» علامت خورده‌اند (نبود secret در frontend، و مقدار پیش‌فرض > «⚠️ بدون تست اختصاصی» علامت خورده‌اند (نبود secret در frontend، و مقدار پیش‌فرض
> `NetworkPolicy.DENY_ALL`). هر دو کنترل در کد درست کار می‌کنند، فقط تست مستقل > `NetworkPolicy.DENY_ALL`). هر دو کنترل در کد درست کار می‌کنند، فقط تست مستقل
@@ -0,0 +1,87 @@
"""Single platform owner; users gain a platform role
The old schema had ``users.is_platform_owner`` as a plain boolean with no
uniqueness guard, so nothing stopped a second (or tenth) platform owner from
appearing. This migration replaces the boolean with ``platform_role``
(``owner`` | ``manager`` | ``user``) and makes "exactly one owner" a *database*
invariant through a partial unique index — a second ``owner`` INSERT now fails
even if some future code path forgets to check.
Backfill rules:
- any row that was ``is_platform_owner = true`` becomes ``owner``;
- if a legacy database somehow had several owners, only the earliest (by
``created_at``) keeps the role and the rest are demoted to ``manager``;
- everything else becomes ``user``.
Revision ID: d4e5f6a7b8c9
Revises: c2d3e4f5b6a7
Create Date: 2026-10-13 18:20:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: str | None = "c2d3e4f5b6a7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_OWNER_WHERE = sa.text("platform_role = 'owner'")
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"platform_role",
sa.String(length=16),
nullable=False,
server_default="user",
),
)
# Defensive backfill: keep only the earliest owner, demote any others.
# Written with the boolean literal ``FALSE`` because both SQLite (>=3.23)
# and PostgreSQL accept it, while ``0`` would be a type error on Postgres.
op.execute(
"UPDATE users SET is_platform_owner = FALSE "
"WHERE is_platform_owner AND id NOT IN "
"(SELECT id FROM users WHERE is_platform_owner ORDER BY created_at LIMIT 1)"
)
op.execute("UPDATE users SET platform_role = 'owner' WHERE is_platform_owner")
op.create_index(
"ix_user_platform_role",
"users",
["platform_role"],
unique=False,
)
op.create_index(
"uq_single_platform_owner",
"users",
["platform_role"],
unique=True,
sqlite_where=_OWNER_WHERE,
postgresql_where=_OWNER_WHERE,
)
op.drop_column("users", "is_platform_owner")
def downgrade() -> None:
op.add_column(
"users",
sa.Column(
"is_platform_owner",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.execute("UPDATE users SET is_platform_owner = TRUE WHERE platform_role = 'owner'")
op.drop_index("uq_single_platform_owner", table_name="users")
op.drop_index("ix_user_platform_role", table_name="users")
op.drop_column("users", "platform_role")
@@ -0,0 +1,86 @@
"""Platform user administration — the owner-side account lifecycle.
Only the platform owner may call into this module; the HTTP layer enforces that
with ``require_platform_owner``. It owns three rules that are easy to get wrong
and expensive to get wrong:
1. **Exactly one ``OWNER`` exists.** Creating a second owner is refused;
moving the seat is the single transactional :func:`transfer_ownership`,
which demotes the previous owner in the same step.
2. **The owner account is immutable from here** — it cannot be demoted,
deactivated or deleted through the API. An owner who lost access is fixed
by the other owner-seat holder or at the database, never by a quiet API
call.
3. **A departing manager's organizations move to the platform owner.** Both
``organizations.owner_user_id`` *and* the membership table are updated: an
organization whose owner has no membership is invisible to its own owner,
and a leftover ``owner`` membership would contradict the new
``owner_user_id``.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.identity.models import (
Membership,
MembershipStatus,
Role,
User,
)
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
@dataclass(slots=True)
class OrganizationTransfer:
"""One organization that changed hands, for the audit trail."""
organization_id: str
organization_name: str
async def transfer_owned_organizations(
session: AsyncSession, *, from_user_id: str, to_user: User
) -> list[OrganizationTransfer]:
"""Move every organization owned by ``from_user_id`` to ``to_user``.
The departing user keeps an ``admin`` membership where they had ``owner``:
they built the tenant, and silently losing all access would be a bigger
surprise than keeping working access without ownership.
"""
orgs = OrganizationRepository(session)
memberships = MembershipRepository(session)
transferred: list[OrganizationTransfer] = []
for org in await orgs.list_owned_by(from_user_id):
await orgs.reassign_owner(org.id, to_user.id)
if await memberships.get(org.id, to_user.id) is None:
await memberships.add(
Membership(
organization_id=org.id,
user_id=to_user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
previous = await memberships.get(org.id, from_user_id)
if previous is not None and previous.role == Role.OWNER:
await memberships.set_role(previous.id, Role.ADMIN)
transferred.append(
OrganizationTransfer(organization_id=org.id, organization_name=org.name)
)
return transferred
async def demote_manager_accounts_organizations(
session: AsyncSession, *, manager: User, owner: User
) -> list[OrganizationTransfer]:
"""Convenience wrapper used when a manager leaves the manager tier."""
return await transfer_owned_organizations(
session, from_user_id=manager.id, to_user=owner
)
+29 -1
View File
@@ -7,7 +7,13 @@ from __future__ import annotations
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings from app.core.config import settings
@@ -17,6 +23,25 @@ class Base(DeclarativeBase):
"""Declarative base for every ORM table.""" """Declarative base for every ORM table."""
def enable_sqlite_foreign_keys(engine: AsyncEngine) -> None:
"""Turn on ``PRAGMA foreign_keys`` for every connection of a SQLite engine.
SQLite defaults to ``foreign_keys=OFF``, which silently turns every
``ondelete="CASCADE"`` in the schema into a no-op — a dev database then
keeps orphaned children (memberships of a deleted user, documents of a
deleted source) that production Postgres would have removed. One pragma
per connection keeps the two backends behaving the same.
"""
@event.listens_for(engine.sync_engine, "connect")
def _set_pragma(dbapi_connection, _connection_record) -> None: # noqa: ANN001
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
finally:
cursor.close()
def _engine_kwargs() -> dict: def _engine_kwargs() -> dict:
url = settings.database_url url = settings.database_url
if url.startswith("sqlite"): if url.startswith("sqlite"):
@@ -32,6 +57,9 @@ def _engine_kwargs() -> dict:
engine = create_async_engine(settings.database_url, **_engine_kwargs()) engine = create_async_engine(settings.database_url, **_engine_kwargs())
if settings.database_url.startswith("sqlite"):
enable_sqlite_foreign_keys(engine)
SessionFactory = async_sessionmaker( SessionFactory = async_sessionmaker(
bind=engine, bind=engine,
class_=AsyncSession, class_=AsyncSession,
+18
View File
@@ -15,13 +15,31 @@ class AuditAction:
LOGIN = "auth.login" LOGIN = "auth.login"
LOGOUT = "auth.logout" LOGOUT = "auth.logout"
SIGNUP = "auth.signup" SIGNUP = "auth.signup"
#: A user changed their own password (or an owner-forced temp password was
#: replaced). Auditable because it is the one action that can lock the
#: legitimate owner out.
PASSWORD_CHANGE = "auth.password_change"
ORG_CREATE = "organization.create" ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete" ORG_DELETE = "organization.delete"
#: An organization changed hands because its owning manager was demoted or
#: deleted — the platform owner takes over. Never automatic for any other
#: reason.
ORG_OWNER_TRANSFER = "organization.owner_transfer"
MEMBER_INVITE = "member.invite" MEMBER_INVITE = "member.invite"
#: An invited user redeemed their token and joined the organization. #: An invited user redeemed their token and joined the organization.
MEMBER_JOIN = "member.join" MEMBER_JOIN = "member.join"
MEMBER_ROLE_CHANGE = "member.role_change" MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove" MEMBER_REMOVE = "member.remove"
#: 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).
USER_PLATFORM_ROLE_CHANGE = "user.platform_role_change"
#: An account was activated or deactivated.
USER_ACTIVE_CHANGE = "user.active_change"
#: An account was deleted by the platform owner.
USER_DELETE = "user.delete"
#: The single platform-owner seat moved to another account.
OWNERSHIP_TRANSFER = "platform.ownership_transfer"
EMPLOYEE_CREATE = "employee.create" EMPLOYEE_CREATE = "employee.create"
EMPLOYEE_UPDATE = "employee.update" EMPLOYEE_UPDATE = "employee.update"
REPO_CREATE = "repo.create" REPO_CREATE = "repo.create"
+41 -1
View File
@@ -17,6 +17,31 @@ class Role(StrEnum):
VIEWER = "viewer" VIEWER = "viewer"
class PlatformRole(StrEnum):
"""Platform-wide role — a different axis from the in-organization ``Role``.
The platform has exactly three levels, and exactly one ``OWNER`` account
exists (enforced by a partial unique index on ``users.platform_role``):
- ``OWNER`` — the single owner of the whole system. The only account that
may enter the admin console, create or demote managers, and transfer
ownership.
- ``MANAGER`` — an account created by the owner. A manager may create
their own organizations (becoming their organization owner) and join
other managers' organizations by invitation.
- ``USER`` — a plain account. Has no platform privileges: cannot create
organizations, only works inside organizations it was invited to.
"""
OWNER = "owner"
MANAGER = "manager"
USER = "user"
#: Roles that may create a new organization. Everyone else must be invited.
PLATFORM_ROLES_CAN_CREATE_ORG = frozenset({PlatformRole.OWNER, PlatformRole.MANAGER})
#: Higher rank implies every permission of the lower ranks. #: Higher rank implies every permission of the lower ranks.
ROLE_RANK: dict[Role, int] = { ROLE_RANK: dict[Role, int] = {
Role.VIEWER: 10, Role.VIEWER: 10,
@@ -88,11 +113,26 @@ class User:
name: str = "" name: str = ""
id: str = field(default_factory=new_id) id: str = field(default_factory=new_id)
avatar_url: str | None = None avatar_url: str | None = None
is_platform_owner: bool = False #: Platform-wide role. ``is_platform_owner`` / ``is_manager`` are derived
#: from it; only one ``OWNER`` may exist (see the partial unique index on
#: ``users.platform_role``).
platform_role: PlatformRole = PlatformRole.USER
is_active: bool = True is_active: bool = True
created_at: datetime = field(default_factory=utcnow) created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow) updated_at: datetime = field(default_factory=utcnow)
@property
def is_platform_owner(self) -> bool:
return self.platform_role == PlatformRole.OWNER
@property
def is_manager(self) -> bool:
return self.platform_role == PlatformRole.MANAGER
@property
def can_create_organization(self) -> bool:
return self.platform_role in PLATFORM_ROLES_CAN_CREATE_ORG
@property @property
def display_name(self) -> str: def display_name(self) -> str:
return self.name.strip() or self.email.split("@")[0] return self.name.strip() or self.email.split("@")[0]
+18 -1
View File
@@ -21,6 +21,7 @@ from sqlalchemy import (
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
text,
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -43,13 +44,29 @@ def _created() -> Mapped[datetime]:
class UserRow(Base): class UserRow(Base):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
# Exactly one platform owner may exist — ever. A partial unique index
# makes that a *database* invariant, not just an application habit: a
# second INSERT with platform_role='owner' fails even if some future
# code path forgets to check. Both dialects support partial indexes.
Index(
"uq_single_platform_owner",
"platform_role",
unique=True,
sqlite_where=text("platform_role = 'owner'"),
postgresql_where=text("platform_role = 'owner'"),
),
Index("ix_user_platform_role", "platform_role"),
)
id: Mapped[str] = _pk() id: Mapped[str] = _pk()
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(160), default="", nullable=False) name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
is_platform_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) #: Platform-wide role: ``owner`` | ``manager`` | ``user`` (see
#: ``PlatformRole``). Exactly one row may hold ``owner``.
platform_role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created() created_at: Mapped[datetime] = _created()
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
@@ -16,6 +16,7 @@ from app.domain.identity.models import (
Membership, Membership,
MembershipStatus, MembershipStatus,
Organization, Organization,
PlatformRole,
Role, Role,
User, User,
) )
@@ -48,7 +49,7 @@ def _user_to_domain(row: UserRow) -> User:
password_hash=row.password_hash, password_hash=row.password_hash,
name=row.name, name=row.name,
avatar_url=row.avatar_url, avatar_url=row.avatar_url,
is_platform_owner=row.is_platform_owner, platform_role=PlatformRole(row.platform_role),
is_active=row.is_active, is_active=row.is_active,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
@@ -107,7 +108,7 @@ class UserRepository:
password_hash=user.password_hash, password_hash=user.password_hash,
name=user.name, name=user.name,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,
is_platform_owner=user.is_platform_owner, platform_role=user.platform_role.value,
is_active=user.is_active, is_active=user.is_active,
created_at=user.created_at, created_at=user.created_at,
updated_at=user.updated_at, updated_at=user.updated_at,
@@ -122,6 +123,41 @@ class UserRepository:
row.password_hash = password_hash row.password_hash = password_hash
await self.session.flush() await self.session.flush()
async def set_platform_role(self, user_id: str, role: PlatformRole) -> None:
row = await self.session.get(UserRow, user_id)
if row:
row.platform_role = role.value
await self.session.flush()
async def set_active(self, user_id: str, active: bool) -> None:
row = await self.session.get(UserRow, user_id)
if row:
row.is_active = active
await self.session.flush()
async def delete(self, user_id: str) -> None:
"""Remove the account. Memberships cascade via the FK."""
row = await self.session.get(UserRow, user_id)
if row:
await self.session.delete(row)
await self.session.flush()
async def get_platform_owner(self) -> User | None:
"""The single platform owner. ``None`` on a system that never had one."""
stmt = select(UserRow).where(
UserRow.platform_role == PlatformRole.OWNER.value
)
row = (await self.session.execute(stmt)).scalars().first()
return _user_to_domain(row) if row else None
async def list_all(self) -> list[User]:
"""Every account. Platform-owner console only — never expose this
through a tenant-scoped endpoint."""
stmt = select(UserRow).order_by(UserRow.created_at)
return [
_user_to_domain(r) for r in (await self.session.execute(stmt)).scalars()
]
async def count(self) -> int: async def count(self) -> int:
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one()) return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
@@ -168,6 +204,24 @@ class OrganizationRepository:
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at) stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()] return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_owned_by(self, user_id: str) -> list[Organization]:
"""Organizations whose ``owner_user_id`` is this user — the set that
must move when a manager is demoted or deleted."""
stmt = (
select(OrganizationRow)
.where(OrganizationRow.owner_user_id == user_id)
.order_by(OrganizationRow.created_at)
)
return [
_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()
]
async def reassign_owner(self, org_id: str, new_owner_user_id: str) -> None:
row = await self.session.get(OrganizationRow, org_id)
if row:
row.owner_user_id = new_owner_user_id
await self.session.flush()
async def delete(self, org_id: str) -> None: async def delete(self, org_id: str) -> None:
row = await self.session.get(OrganizationRow, org_id) row = await self.session.get(OrganizationRow, org_id)
if row: if row:
@@ -0,0 +1,404 @@
"""Platform owner console: the account lifecycle (managers and users).
Everything in this router is owner-only, guarded at the *router* level like the
rest of the console — an endpoint added later is protected without its author
having to remember. The rules encoded here:
- The owner creates every manager account directly, with a temporary password
shown exactly once. There is no public signup.
- Platform role changes are limited to ``user`` <-> ``manager``; the owner seat
moves only through ``POST /admin/transfer-ownership``.
- The owner account itself cannot be demoted, deactivated or deleted here.
- When a manager is demoted or deleted, the organizations they own move to the
platform owner (see ``application/services/platform_users.py``).
"""
from __future__ import annotations
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.platform_users import (
transfer_owned_organizations,
)
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.core.security import hash_password
from app.domain.audit.models import AuditAction
from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import (
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin"],
# Deny-by-default for the whole console — see ``routers/admin.py`` for why
# this is a router-level dependency and not per-endpoint checks.
dependencies=[Depends(require_platform_owner)],
)
#: Length of the one-time password the owner hands to a new account. Twelve
#: URL-safe characters is ~71 bits — brute-force proof at any login attempt
#: rate this API permits, and short enough to read over a chat message.
TEMP_PASSWORD_BYTES = 9
def _temp_password() -> str:
return secrets.token_urlsafe(TEMP_PASSWORD_BYTES)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class AdminUserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
email: str
name: str
platform_role: str
is_manager: bool
is_platform_owner: bool
is_active: bool
created_at: datetime
#: How many organizations this account owns — the number that decides what
#: happens to them if the account is demoted or deleted.
owned_organizations: int
class AdminUserCreatedOut(BaseModel):
user: AdminUserOut
#: Shown exactly once, at creation. Never stored in plaintext and never
#: retrievable afterwards — a reset issues a new one.
temp_password: str
class CreateUserRequest(BaseModel):
email: EmailStr
name: str = Field(default="", max_length=160)
#: ``owner`` is refused: the seat moves only via transfer-ownership.
platform_role: PlatformRole = PlatformRole.USER
class UpdateUserRequest(BaseModel):
#: ``owner`` is refused here for the same reason.
platform_role: PlatformRole | None = None
is_active: bool | None = None
class TransferOwnershipRequest(BaseModel):
#: Must be an existing, active, non-owner account.
user_id: str = Field(min_length=1, max_length=64)
class OrganizationTransferOut(BaseModel):
organization_id: str
organization_name: str
class DeleteUserResult(BaseModel):
deleted_user_id: str
transferred_organizations: list[OrganizationTransferOut]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_owner(session: AsyncSession) -> User:
owner = await UserRepository(session).get_platform_owner()
if owner is None: # pragma: no cover - seed guarantees one exists
raise NotFoundError("مالک سامانه یافت نشد.")
return owner
async def _load_target(session: AsyncSession, user_id: str) -> User:
target = await UserRepository(session).get(user_id)
if target is None:
raise NotFoundError("کاربر یافت نشد.")
return target
def _assert_mutable_target(target: User) -> None:
"""The single owner seat is not mutable through this router."""
if target.is_platform_owner:
raise PermissionDeniedError(
"حساب مالک سامانه از این مسیر قابل تغییر نیست؛ "
"برای جابه‌جایی مالکیت از «انتقال مالکیت سامانه» استفاده کنید."
)
async def _owned_org_count(session: AsyncSession, user_id: str) -> int:
return len(await OrganizationRepository(session).list_owned_by(user_id))
async def _to_out(session: AsyncSession, user: User) -> AdminUserOut:
return AdminUserOut(
id=user.id,
email=user.email,
name=user.display_name,
platform_role=user.platform_role.value,
is_manager=user.is_manager,
is_platform_owner=user.is_platform_owner,
is_active=user.is_active,
created_at=user.created_at,
owned_organizations=await _owned_org_count(session, user.id),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/users", response_model=list[AdminUserOut])
async def list_users(
session: AsyncSession = Depends(get_session),
) -> list[AdminUserOut]:
users = await UserRepository(session).list_all()
return [await _to_out(session, user) for user in users]
@router.post("/users", response_model=AdminUserCreatedOut, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: CreateUserRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> AdminUserCreatedOut:
"""Create a manager or plain account. The temp password is returned once.
Every account in the system is created here or through an invite link; the
owner is the only one who can mint a ``manager`` (the tier that may create
organizations). Creating a second ``owner`` is refused by the schema — the
seat moves through the transfer-ownership endpoint only.
"""
if payload.platform_role == PlatformRole.OWNER:
raise ValidationError(
"مالک سامانه از این مسیر ساخته نمی‌شود؛ برای جابه‌جایی مالکیت "
"از «انتقال مالکیت سامانه» استفاده کنید."
)
users = UserRepository(session)
if await users.get_by_email(payload.email) is not None:
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
temp_password = _temp_password()
user = await users.create(
User(
email=payload.email,
password_hash=hash_password(temp_password),
name=payload.name.strip(),
platform_role=payload.platform_role,
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.USER_CREATE,
actor_user_id=owner.id,
target_type="user",
target_id=user.id,
metadata={"email": user.email, "platform_role": user.platform_role.value},
ip=ip,
user_agent=ua,
)
return AdminUserCreatedOut(user=await _to_out(session, user), temp_password=temp_password)
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def update_user(
user_id: str,
payload: UpdateUserRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> AdminUserOut:
"""Change a platform role (user <-> manager) or activate/deactivate.
Demoting a manager to a plain user hands every organization they own to the
platform owner first — a demoted manager with a tenant still under their
name would quietly keep the capacity to create tenants, which is what the
demotion was meant to revoke.
"""
target = await _load_target(session, user_id)
_assert_mutable_target(target)
users = UserRepository(session)
transferred: list = []
if payload.platform_role is not None and payload.platform_role != target.platform_role:
if payload.platform_role == PlatformRole.OWNER:
raise ValidationError(
"ارتقا به مالک سامانه فقط از مسیر «انتقال مالکیت سامانه» ممکن است."
)
if target.is_manager and payload.platform_role == PlatformRole.USER:
transferred = await transfer_owned_organizations(
session, from_user_id=target.id, to_user=owner
)
await users.set_platform_role(target.id, payload.platform_role)
target.platform_role = payload.platform_role
if payload.is_active is not None and payload.is_active != target.is_active:
await users.set_active(target.id, payload.is_active)
target.is_active = payload.is_active
ip, ua = client_meta(request)
if payload.platform_role is not None:
await record_audit(
session,
action=AuditAction.USER_PLATFORM_ROLE_CHANGE,
actor_user_id=owner.id,
target_type="user",
target_id=target.id,
metadata={
"platform_role": payload.platform_role.value,
"transferred_organizations": [t.organization_id for t in transferred],
},
ip=ip,
user_agent=ua,
)
if payload.is_active is not None:
await record_audit(
session,
action=AuditAction.USER_ACTIVE_CHANGE,
actor_user_id=owner.id,
target_type="user",
target_id=target.id,
metadata={"is_active": payload.is_active},
ip=ip,
user_agent=ua,
)
return await _to_out(session, target)
@router.delete("/users/{user_id}", response_model=DeleteUserResult)
async def delete_user(
user_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> DeleteUserResult:
"""Delete an account, moving its owned organizations to the platform owner.
Memberships cascade with the user row, so the deleted account disappears
from every organization it was a member of — the organizations it *owned*
survive under the platform owner instead of being orphaned or deleted with
their data.
"""
target = await _load_target(session, user_id)
_assert_mutable_target(target)
transferred = await transfer_owned_organizations(
session, from_user_id=target.id, to_user=owner
)
await UserRepository(session).delete(target.id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.USER_DELETE,
actor_user_id=owner.id,
target_type="user",
target_id=target.id,
metadata={
"email": target.email,
"transferred_organizations": [t.organization_id for t in transferred],
},
ip=ip,
user_agent=ua,
)
return DeleteUserResult(
deleted_user_id=target.id,
transferred_organizations=[
OrganizationTransferOut(
organization_id=t.organization_id, organization_name=t.organization_name
)
for t in transferred
],
)
@router.post("/users/{user_id}/reset-password", response_model=AdminUserCreatedOut)
async def reset_user_password(
user_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> AdminUserCreatedOut:
"""Issue a new temp password for an account. Shown once, never stored raw."""
target = await _load_target(session, user_id)
temp_password = _temp_password()
await UserRepository(session).update_password(
target.id, hash_password(temp_password)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.PASSWORD_CHANGE,
actor_user_id=owner.id,
target_type="user",
target_id=target.id,
metadata={"via": "owner_reset"},
ip=ip,
user_agent=ua,
)
return AdminUserCreatedOut(user=await _to_out(session, target), temp_password=temp_password)
@router.post("/transfer-ownership", response_model=AdminUserOut)
async def transfer_ownership(
payload: TransferOwnershipRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> AdminUserOut:
"""Move the single owner seat to another active account.
The requester is demoted to ``manager`` in the same step, so the invariant
"exactly one owner" holds throughout — there is no window in which two
accounts own the platform, and no window in which none does. This is the
safety valve for a lost owner account; everything else about the owner
seat is immutable.
"""
successor = await _load_target(session, payload.user_id)
if successor.is_platform_owner:
raise ConflictError("این کاربر همین حالا مالک سامانه است.")
if not successor.is_active:
raise ValidationError("حساب غیرفعال را نمی‌توان مالک سامانه کرد.")
users = UserRepository(session)
# The requester keeps every organization they own; only the platform seat
# moves. The successor is usually a manager, and their organizations stay
# with them.
#
# Order matters: demote the current owner *before* promoting the successor,
# so the partial unique index on platform_role='owner' never sees two rows
# at once. Both writes land in the same transaction.
await users.set_platform_role(owner.id, PlatformRole.MANAGER)
await users.set_platform_role(successor.id, PlatformRole.OWNER)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.OWNERSHIP_TRANSFER,
actor_user_id=owner.id,
target_type="user",
target_id=successor.id,
metadata={"from_user_id": owner.id, "to_user_id": successor.id},
ip=ip,
user_agent=ua,
)
updated = await users.get(successor.id)
assert updated is not None
return await _to_out(session, updated)
+57 -39
View File
@@ -1,4 +1,10 @@
"""Authentication endpoints.""" """Authentication endpoints.
There is deliberately **no public signup**: accounts are created either by the
platform owner (``POST /admin/users`` — managers and plain users) or by
redeeming an organization invite (``POST /invites/accept`` with a password).
Login, the profile view and self-service password change live here.
"""
from __future__ import annotations from __future__ import annotations
@@ -8,8 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit from app.application.services.audit_service import record_audit
from app.core.database import get_session from app.core.database import get_session
from app.core.errors import AuthenticationError, ConflictError from app.core.errors import AuthenticationError, ValidationError
from app.core.security import create_access_token, hash_password, verify_password from app.core.security import (
create_access_token,
hash_password,
verify_password,
)
from app.domain.audit.models import AuditAction from app.domain.audit.models import AuditAction
from app.domain.identity.models import User from app.domain.identity.models import User
from app.infrastructure.repositories.identity import ( from app.infrastructure.repositories.identity import (
@@ -22,12 +32,6 @@ from app.interfaces.http.deps import client_meta, get_current_user
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
name: str = Field(default="", max_length=160)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
@@ -40,9 +44,19 @@ class UserOut(BaseModel):
email: str email: str
name: str name: str
avatar_url: str | None = None avatar_url: str | None = None
#: Platform-wide role — ``owner`` | ``manager`` | ``user``. Exactly one
#: account is ``owner``; only ``owner`` and ``manager`` may create
#: organizations.
platform_role: str = "user"
is_manager: bool = False
is_platform_owner: bool = False is_platform_owner: bool = False
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class OrganizationSummary(BaseModel): class OrganizationSummary(BaseModel):
id: str id: str
name: str name: str
@@ -67,40 +81,12 @@ def _to_user_out(user: User) -> UserOut:
email=user.email, email=user.email,
name=user.display_name, name=user.display_name,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,
platform_role=user.platform_role.value,
is_manager=user.is_manager,
is_platform_owner=user.is_platform_owner, is_platform_owner=user.is_platform_owner,
) )
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def signup(
payload: SignupRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
users = UserRepository(session)
if await users.get_by_email(payload.email) is not None:
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
user = await users.create(
User(
email=payload.email,
password_hash=hash_password(payload.password),
name=payload.name.strip(),
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.SIGNUP,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,
@@ -146,6 +132,38 @@ async def logout(
) )
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
payload: ChangePasswordRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
"""Replace the caller's own password.
This is how a temp password handed out by the platform owner (or by an
invite) becomes a private one — without it, every account created by
someone else would live on a shared secret forever.
"""
users = UserRepository(session)
if not verify_password(payload.current_password, user.password_hash):
raise AuthenticationError("رمز عبور فعلی نادرست است.")
if payload.new_password == payload.current_password:
raise ValidationError("رمز عبور جدید باید با رمز فعلی متفاوت باشد.")
await users.update_password(user.id, hash_password(payload.new_password))
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.PASSWORD_CHANGE,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
@router.get("/me", response_model=MeResponse) @router.get("/me", response_model=MeResponse)
async def me( async def me(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
+92 -18
View File
@@ -3,8 +3,14 @@
Deliberately *not* nested under ``/organizations/{org_id}``: the person Deliberately *not* nested under ``/organizations/{org_id}``: the person
accepting an invite is by definition not yet a member, so ``get_org_context`` accepting an invite is by definition not yet a member, so ``get_org_context``
would (correctly) refuse them. Here the invite token itself is the would (correctly) refuse them. Here the invite token itself is the
authorization. It is stored only as a hash, matched to the signed-in user's authorization. It is stored only as a hash and is single-use.
email, and single-use — see ``_load_pending_invite``.
Because signup is closed (accounts come from the owner or from an invite), the
token doubles as an account-creation capability: a brand-new invitee redeems
it **with a password**, which creates their account bound to the invited email
and joins the organization in one step. The token can never reset the password
of an *existing* account — those must authenticate first, which keeps a leaked
link from taking over an account that already exists.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,7 +18,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request, status from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit from app.application.services.audit_service import record_audit
@@ -23,21 +29,23 @@ from app.core.errors import (
PermissionDeniedError, PermissionDeniedError,
ValidationError, ValidationError,
) )
from app.core.security import hash_token from app.core.security import hash_password, hash_token
from app.domain.audit.models import AuditAction from app.domain.audit.models import AuditAction
from app.domain.identity.models import ( from app.domain.identity.models import (
Invite, Invite,
Membership, Membership,
MembershipStatus, MembershipStatus,
Organization, Organization,
PlatformRole,
User, User,
) )
from app.infrastructure.repositories.identity import ( from app.infrastructure.repositories.identity import (
InviteRepository, InviteRepository,
MembershipRepository, MembershipRepository,
OrganizationRepository, OrganizationRepository,
UserRepository,
) )
from app.interfaces.http.deps import client_meta, get_current_user from app.interfaces.http.deps import client_meta, get_optional_user
router = APIRouter(prefix="/invites", tags=["invites"]) router = APIRouter(prefix="/invites", tags=["invites"])
@@ -51,10 +59,18 @@ class InvitePreview(BaseModel):
#: True when the caller is already a member, so the UI can say "open it" #: True when the caller is already a member, so the UI can say "open it"
#: instead of "join". #: instead of "join".
already_member: bool already_member: bool
#: True when an account exists for the invited email. The invitee then logs
#: in first; anonymous redemption with a password is only for brand-new
#: accounts (a token must never be able to reset an existing password).
has_account: bool
class AcceptInviteRequest(BaseModel): class AcceptInviteRequest(BaseModel):
token: str token: str
#: Required only when redeeming anonymously — it becomes the password of
#: the account the invite creates. Ignored for a signed-in user, who has
#: already proven ownership of the email.
password: str | None = Field(default=None, min_length=8, max_length=128)
class AcceptInviteResult(BaseModel): class AcceptInviteResult(BaseModel):
@@ -64,22 +80,26 @@ class AcceptInviteResult(BaseModel):
async def _load_pending_invite( async def _load_pending_invite(
token: str, user: User, session: AsyncSession token: str, user: User | None, session: AsyncSession
) -> tuple[Invite, Organization]: ) -> tuple[Invite, Organization]:
"""Resolve a raw token into a redeemable invite, or raise. """Resolve a raw token into a redeemable invite, or raise.
Every rejection is a distinct, user-actionable error rather than a generic Every rejection is a distinct, user-actionable error rather than a generic
"invalid link": the three real failure modes (already used, expired, wrong "invalid link": the real failure modes (already used, expired, wrong
account) each need a different response from the person holding the link. account) each need a different response from the person holding the link.
The email binding is only enforced when a user is signed in: an anonymous
holder of the token sees the preview but must create an account for exactly
the invited email, which the accept path checks.
""" """
invite = await InviteRepository(session).get_by_token_hash(hash_token(token)) invite = await InviteRepository(session).get_by_token_hash(hash_token(token))
if invite is None: if invite is None:
raise NotFoundError("این لینک دعوت معتبر نیست.") raise NotFoundError("این لینک دعوت معتبر نیست.")
if invite.accepted_at is not None: if invite.accepted_at is not None:
raise ConflictError("این دعوت‌نامه قبلاً پذیرفته شده است.") raise ConflictError("این دعوت‌نامه قبلاً پذیرافته شده است.")
if invite.is_expired(): if invite.is_expired():
raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.") raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.")
if invite.email.strip().lower() != user.email.strip().lower(): if user is not None and invite.email.strip().lower() != user.email.strip().lower():
raise PermissionDeniedError( raise PermissionDeniedError(
"این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.", "این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.",
details={"invite_email": invite.email}, details={"invite_email": invite.email},
@@ -94,19 +114,30 @@ async def _load_pending_invite(
@router.get("/{token}", response_model=InvitePreview) @router.get("/{token}", response_model=InvitePreview)
async def preview_invite( async def preview_invite(
token: str, token: str,
user: User = Depends(get_current_user), user: User | None = Depends(get_optional_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> InvitePreview: ) -> InvitePreview:
"""Show the invitee what they are about to join, without joining yet.""" """Show the invitee what they are about to join, without joining yet.
Works without a session on purpose: a brand-new invitee must be able to
see which organization invited them (and whether they already have an
account) *before* deciding to sign in or create one.
"""
invite, organization = await _load_pending_invite(token, user, session) invite, organization = await _load_pending_invite(token, user, session)
existing = await MembershipRepository(session).get(organization.id, user.id) existing_user = await UserRepository(session).get_by_email(invite.email)
membership = (
await MembershipRepository(session).get(organization.id, user.id)
if user is not None
else None
)
return InvitePreview( return InvitePreview(
organization_id=organization.id, organization_id=organization.id,
organization_name=organization.name, organization_name=organization.name,
email=invite.email, email=invite.email,
role=invite.role.value, role=invite.role.value,
expires_at=invite.expires_at, expires_at=invite.expires_at,
already_member=existing is not None, already_member=membership is not None,
has_account=existing_user is not None,
) )
@@ -114,17 +145,46 @@ async def preview_invite(
async def accept_invite( async def accept_invite(
payload: AcceptInviteRequest, payload: AcceptInviteRequest,
request: Request, request: Request,
user: User = Depends(get_current_user), user: User | None = Depends(get_optional_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> AcceptInviteResult: ) -> AcceptInviteResult:
"""Redeem an invite: create the membership and burn the token. """Redeem an invite: create the account if needed, join, and burn the token.
Idempotent by construction — accepting twice is a no-op the second time, so Two redemption paths, one outcome:
a double-click or a retried request cannot create two memberships.
- **Signed in** — the account already exists and has proven ownership of
the invited email; no password is needed.
- **Anonymous** — the invite itself is the capability: a *new* account is
created for the invited email with the supplied password and joins the
organization. If an account for that email already exists, the caller
must authenticate first — a link can never become a password reset.
Idempotent by construction — accepting twice is a no-op the second time,
so a double-click or a retried request cannot create two memberships.
""" """
invite, organization = await _load_pending_invite(payload.token, user, session) invite, organization = await _load_pending_invite(payload.token, user, session)
users = UserRepository(session)
memberships = MembershipRepository(session) memberships = MembershipRepository(session)
created_account = False
if user is None:
existing = await users.get_by_email(invite.email)
if existing is not None:
raise PermissionDeniedError(
"برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید."
)
if not payload.password:
raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.")
user = await users.create(
User(
email=invite.email,
password_hash=hash_password(payload.password),
name="",
platform_role=PlatformRole.USER,
)
)
created_account = True
if await memberships.get(organization.id, user.id) is None: if await memberships.get(organization.id, user.id) is None:
await memberships.add( await memberships.add(
Membership( Membership(
@@ -146,6 +206,20 @@ async def accept_invite(
ip=ip, ip=ip,
user_agent=ua, user_agent=ua,
) )
if created_account:
# Account creation is audited separately from the join: one is a
# platform identity event, the other a tenant event.
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.SIGNUP,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
metadata={"via": "invite", "organization_id": organization.id},
ip=ip,
user_agent=ua,
)
# Burn the token regardless: whether the membership was just created or # Burn the token regardless: whether the membership was just created or
# already existed, this invite must not be redeemable a second time. # already existed, this invite must not be redeemable a second time.
@@ -114,6 +114,15 @@ async def create_organization(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> OrganizationWithRole: ) -> OrganizationWithRole:
# The platform has exactly one owner and the managers the owner creates.
# Everyone else works *inside* organizations they were invited to — if
# plain users could create organizations, the owner's control over the
# tenant map would mean nothing.
if not user.can_create_organization:
raise PermissionDeniedError(
"ساخت سازمان فقط برای مالک سامانه و مدیران امکان‌پذیر است. "
"برای عضویت در یک سازمان از لینک دعوت استفاده کنید."
)
orgs = OrganizationRepository(session) orgs = OrganizationRepository(session)
base = slugify(payload.name) base = slugify(payload.name)
slug = ensure_unique_slug(base, await orgs.list_all_slugs()) slug = ensure_unique_slug(base, await orgs.list_all_slugs())
+2 -2
View File
@@ -49,8 +49,8 @@ ChannelAuthorizer = Callable[["Connection", str], Awaitable[bool]]
@dataclass(slots=True, eq=False) @dataclass(slots=True, eq=False)
class Connection: class Connection:
"""One browser socket. ``user_id`` is ``None`` for an anonymous (pre-login) """One browser socket. ``user_id`` is ``None`` for an anonymous (pre-login)
connection: it may only call the bootstrap RPCs (login/signup) and may not connection: it may only call the bootstrap RPCs (login and invite
subscribe to live channels.""" redemption) and may not subscribe to live channels."""
user_id: str | None user_id: str | None
is_platform_owner: bool is_platform_owner: bool
+14 -6
View File
@@ -25,7 +25,7 @@ share the ``type`` discriminator; client->server frames use ``action``::
server -> {"type": "error", "message": "…"} server -> {"type": "error", "message": "…"}
A connection may be **anonymous** (no token): that is the bootstrap state that A connection may be **anonymous** (no token): that is the bootstrap state that
lets a brand-new visitor call ``/api/auth/login`` and ``/api/auth/signup`` over lets a brand-new visitor call ``/api/auth/login`` and redeem an invite over
RPC. Every other RPC and every ``subscribe`` requires an authenticated RPC. Every other RPC and every ``subscribe`` requires an authenticated
connection; the token presented at connect time is re-injected as a bearer connection; the token presented at connect time is re-injected as a bearer
header when the RPC is dispatched into the internal app, so the handlers' own header when the RPC is dispatched into the internal app, so the handlers' own
@@ -67,9 +67,17 @@ def realtime_manager() -> ConnectionManager:
return _manager return _manager
#: Paths an anonymous (not-yet-logged-in) connection is allowed to call. These are #: What an anonymous (not-yet-logged-in) connection may call: login — the only
#: the only two calls a visitor can make; everything else needs a session. #: way in now that signup is closed — plus the invite redemption endpoints,
_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login", "/api/auth/signup"}) #: which authorize themselves with the invite token. Previewing an invite and
#: creating the account it names must both be possible before the visitor has
#: a session, so a fresh invitee is never stuck at the login screen.
_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login"})
_ANONYMOUS_RPC_PREFIXES = ("/api/invites/",)
def _is_anonymous_rpc_allowed(path: str) -> bool:
return path in _ANONYMOUS_RPC_PATHS or path.startswith(_ANONYMOUS_RPC_PREFIXES)
#: One in-process HTTP client that dispatches RPCs into the internal app. Built #: One in-process HTTP client that dispatches RPCs into the internal app. Built
#: lazily so importing this module never pulls the app into existence. #: lazily so importing this module never pulls the app into existence.
@@ -216,8 +224,8 @@ async def _handle_rpc(connection: Connection, message: dict) -> None:
) )
return return
# Gate unauthenticated callers to login/signup only. # Gate unauthenticated callers to the bootstrap RPCs only.
if connection.user_id is None and path not in _ANONYMOUS_RPC_PATHS: if connection.user_id is None and not _is_anonymous_rpc_allowed(path):
_send_rpc_result( _send_rpc_result(
connection, connection,
req_id, req_id,
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import ( from app.interfaces.http.routers import (
admin, admin,
admin_users,
agy, agy,
auth, auth,
billing, billing,
@@ -68,6 +69,7 @@ def build_rpc_app() -> FastAPI:
application.include_router(knowledge.router, prefix="/api") application.include_router(knowledge.router, prefix="/api")
application.include_router(sandbox.router, prefix="/api") application.include_router(sandbox.router, prefix="/api")
application.include_router(admin.router, prefix="/api") application.include_router(admin.router, prefix="/api")
application.include_router(admin_users.router, prefix="/api")
application.include_router(agy.router, prefix="/api") application.include_router(agy.router, prefix="/api")
application.include_router(realtime_router, prefix="/api") application.include_router(realtime_router, prefix="/api")
return application return application
+107 -34
View File
@@ -1,7 +1,7 @@
"""Idempotent development seed. """Idempotent development seed.
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample Run with ``pnpm db:seed``. Creates the platform owner, a demo *manager* with a
organization that both of them own, the system agent templates, routing sample organization that manager owns, the system agent templates, routing
policies, rate cards and the agy (Google Antigravity) provider with its model policies, rate cards and the agy (Google Antigravity) provider with its model
catalog. agy is the only model provider the platform talks to; accounts are catalog. agy is the only model provider the platform talks to; accounts are
added from the admin console. added from the admin console.
@@ -11,6 +11,16 @@ Safe to run repeatedly: every step checks for existing rows first.
The two accounts it prints at the end are meant to be usable immediately — The two accounts it prints at the end are meant to be usable immediately —
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the ``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
login endpoint rejects is worse than no seed at all. login endpoint rejects is worse than no seed at all.
Platform hierarchy this seed models (there is deliberately no public signup):
- exactly one ``owner`` — the account that owns the whole system and is the
only one allowed into the admin console;
- the demo account is a ``manager`` — the tier that may create organizations
and join other managers' organizations by invitation;
- the platform owner is *not* auto-joined to any organization: the product
rule is that the owner observes tenants from the console, rather than
silently sitting inside their data.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,7 +28,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from sqlalchemy import select from sqlalchemy import select, update
from app.core.database import Base, SessionFactory, engine from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password from app.core.security import hash_password
@@ -32,7 +42,14 @@ from app.domain.ai_control.models import (
RoutingMode, RoutingMode,
RoutingPolicy, RoutingPolicy,
) )
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
PlatformRole,
Role,
User,
)
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
from app.domain.workforce.models import AgentTemplate, RoleKey from app.domain.workforce.models import AgentTemplate, RoleKey
from app.infrastructure.db.models import ( from app.infrastructure.db.models import (
@@ -45,6 +62,7 @@ from app.infrastructure.db.models import (
SandboxTemplateRow, SandboxTemplateRow,
UserRow, UserRow,
) )
from app.infrastructure.repositories.identity import UserRepository
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
logger = logging.getLogger("agy.seed") logger = logging.getLogger("agy.seed")
@@ -61,7 +79,6 @@ DEMO_PASSWORD = "demo12345"
#: the seed to be idempotent — see ``_ensure_org``. #: the seed to be idempotent — see ``_ensure_org``.
DEMO_ORG_NAME = "سازمان نمونه" DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org" DEMO_ORG_SLUG = "sample-org"
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id #: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
#: the Antigravity backend expects; the display name is admin-only. The 4th #: the Antigravity backend expects; the display name is admin-only. The 4th
#: element is the reasoning effort (empty = provider default) — the console can #: element is the reasoning effort (empty = provider default) — the console can
@@ -185,36 +202,66 @@ SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
async def _ensure_user( async def _ensure_user(
session, *, email: str, password: str, name: str, platform_owner: bool session, *, email: str, password: str, name: str, platform_role: PlatformRole
) -> User: ) -> User:
"""Get or create one seeded account, converging its platform role.
The reconciliation matters for installs seeded *before* the single-owner
model: the migration already mapped the old boolean onto
``platform_role``, and this pass fixes everything the migration could not
know (for example which of several legacy owners the seed intended).
Exactly one row may hold ``owner`` — the partial unique index enforces it —
so promoting this account first demotes any other owner to ``manager``.
"""
existing = ( existing = (
await session.execute(select(UserRow).where(UserRow.email == email)) await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one_or_none() ).scalar_one_or_none()
if existing: if existing is not None:
if existing.platform_role != platform_role.value:
if platform_role == PlatformRole.OWNER:
# Demote the current seat holder before taking it, so the
# unique index never sees two owners.
await session.execute(
update(UserRow)
.where(
UserRow.platform_role == PlatformRole.OWNER.value,
UserRow.id != existing.id,
)
.values(platform_role=PlatformRole.MANAGER.value)
)
existing.platform_role = platform_role.value
await session.flush()
return User( return User(
id=existing.id, email=existing.email, password_hash=existing.password_hash, id=existing.id,
name=existing.name, is_platform_owner=existing.is_platform_owner, email=existing.email,
password_hash=existing.password_hash,
name=existing.name,
platform_role=PlatformRole(existing.platform_role),
) )
from app.infrastructure.repositories.identity import UserRepository
user = await UserRepository(session).create( user = await UserRepository(session).create(
User( User(
email=email, email=email,
password_hash=hash_password(password), password_hash=hash_password(password),
name=name, name=name,
is_platform_owner=platform_owner, platform_role=platform_role,
) )
) )
logger.info("created user %s", email) logger.info("created user %s (%s)", email, platform_role.value)
return user return user
async def _ensure_org(session, *, members: list[User]) -> Organization: async def _ensure_org(session, *, owner: User) -> Organization:
"""Get or create the sample organization, with every given user in it. """Get or create the sample organization, owned by ``owner``.
Idempotent in all three parts: the organization, its memberships and its Idempotent in all three parts: the organization, the owner's membership and
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with the credit wallet. A developer who runs ``pnpm db:seed`` twice must end up
exactly one organization and no duplicate rows. with exactly one organization and no duplicate rows.
The platform owner is deliberately *not* a member — see the module
docstring. Any membership a platform owner picked up in an older database
(the seed used to join both accounts) is removed here so the model stays
honest after a re-seed.
""" """
from app.infrastructure.repositories.identity import ( from app.infrastructure.repositories.identity import (
MembershipRepository, MembershipRepository,
@@ -232,22 +279,46 @@ async def _ensure_org(session, *, members: list[User]) -> Organization:
org = await orgs.get_by_slug(DEMO_ORG_SLUG) org = await orgs.get_by_slug(DEMO_ORG_SLUG)
if org is None: if org is None:
org = await orgs.create( org = await orgs.create(
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id) Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=owner.id)
) )
logger.info("created organization %s", org.slug) logger.info("created organization %s", org.slug)
elif org.owner_user_id != owner.id:
await orgs.reassign_owner(org.id, owner.id)
org = await orgs.get(org.id)
assert org is not None
# Every listed user becomes an owner of the sample org. Without this the # The org owner is always an active OWNER member. Without this the demo
# demo account can log in but belongs to no organization, so every screen # account can log in but belongs to no organization, so every screen
# renders empty and the account looks broken. # renders empty and the account looks broken.
for user in members: membership = await memberships.get(org.id, owner.id)
if await memberships.get(org.id, user.id) is None: if membership is None:
await memberships.add( await memberships.add(
Membership( Membership(
organization_id=org.id, user_id=user.id, organization_id=org.id, user_id=owner.id,
role=Role.OWNER, status=MembershipStatus.ACTIVE, role=Role.OWNER, status=MembershipStatus.ACTIVE,
) )
)
logger.info("added %s to %s as owner", owner.email, org.slug)
elif membership.role != Role.OWNER or not membership.is_active:
await memberships.set_role(membership.id, Role.OWNER)
await memberships.set_status(membership.id, MembershipStatus.ACTIVE)
# Platform owners observe tenants from the console; they do not sit inside
# them. Remove any such membership left by an older seed.
platform_owners = (
await session.execute(
select(UserRow).where(UserRow.platform_role == PlatformRole.OWNER.value)
)
).scalars().all()
for platform_owner in platform_owners:
stale = await memberships.get(org.id, platform_owner.id)
if stale is not None:
await memberships.remove(stale.id)
logger.info(
"removed platform owner %s from %s (owner stays out of tenants)",
platform_owner.email,
org.slug,
) )
logger.info("added %s to %s as owner", user.email, org.slug)
wallet = ( wallet = (
await session.execute( await session.execute(
@@ -264,17 +335,19 @@ async def seed() -> None:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with SessionFactory() as session: async with SessionFactory() as session:
owner = await _ensure_user( await _ensure_user(
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD, session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
name="مالک سامانه", platform_owner=True, name="مالک سامانه", platform_role=PlatformRole.OWNER,
) )
# The demo account is a *manager*: the tier the owner creates that can
# build its own organizations. It owns the sample organization, so
# logging in with it lands on a populated dashboard rather than an
# empty one.
demo = await _ensure_user( demo = await _ensure_user(
session, email=DEMO_EMAIL, password=DEMO_PASSWORD, session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
name="کاربر نمونه", platform_owner=False, name="کاربر نمونه", platform_role=PlatformRole.MANAGER,
) )
# Both accounts join the sample organization, so a developer who logs in await _ensure_org(session, owner=demo)
# as either one lands on a populated dashboard rather than an empty one.
await _ensure_org(session, members=[owner, demo])
# The agy catalog (Google Antigravity). agy is the only model provider, # The agy catalog (Google Antigravity). agy is the only model provider,
# so the provider row and its models are created on a first run and # so the provider row and its models are created on a first run and
+79 -22
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -21,9 +20,12 @@ from sqlalchemy.pool import StaticPool
# and binding the package to the same name would shadow it. # and binding the package to the same name would shadow it.
import app.infrastructure.db.models as _orm_models # noqa: F401 import app.infrastructure.db.models as _orm_models # noqa: F401
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, get_session from app.core.database import Base, enable_sqlite_foreign_keys, get_session
from app.core.security import create_access_token, hash_password
from app.domain.base import new_id from app.domain.base import new_id
from app.infrastructure.db.models import UserRow from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import UserRepository
# The internal "full" app holds every HTTP handler; the public app (app.main.app) # The internal "full" app holds every HTTP handler; the public app (app.main.app)
# now serves only /health + /api/ws, so REST tests must target the full app. # now serves only /health + /api/ws, so REST tests must target the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app from app.interfaces.realtime.rpc_app import get_rpc_app
@@ -64,6 +66,9 @@ async def engine():
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool, poolclass=StaticPool,
) )
# Same pragma the app engine gets, so ON DELETE CASCADE behaves here the
# way it does on Postgres.
enable_sqlite_foreign_keys(test_engine)
async with test_engine.begin() as conn: async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
yield test_engine yield test_engine
@@ -109,12 +114,20 @@ async def client(engine, session_factory):
await session.rollback() await session.rollback()
raise raise
# Publish the factory for the module-level ``signup`` helper below. The
# in-memory engine each test builds is private to its fixtures; a helper
# that needs the same database has to reach it through this door.
_session_factory_holder["factory"] = session_factory
app.dependency_overrides[get_session] = _override_session app.dependency_overrides[get_session] = _override_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
yield test_client yield test_client
app.dependency_overrides.clear() app.dependency_overrides.clear()
#: Set by the ``client`` fixture; read by :func:`signup`.
_session_factory_holder: dict[str, async_sessionmaker] = {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fake model provider — the only sanctioned stand-in for agy in tests # Fake model provider — the only sanctioned stand-in for agy in tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -186,13 +199,45 @@ def _fake_model_provider(monkeypatch):
async def signup( async def signup(
client: AsyncClient, email: str, password: str = "password123", name: str = "" client: AsyncClient,
email: str,
password: str = "password123",
name: str = "",
platform_role: PlatformRole = PlatformRole.MANAGER,
) -> dict: ) -> dict:
response = await client.post( """Create an account directly in the database and mint a token for it.
"/api/auth/signup", json={"email": email, "password": password, "name": name}
) There is no public signup endpoint anymore — accounts are created by the
assert response.status_code == 201, response.text platform owner or by redeeming an invite — so this helper writes the row
return response.json() those flows would and returns the same shape ``POST /auth/signup`` used to.
The ``client`` argument is kept for the historical call signature.
The default platform role is ``manager`` because most tests exercise the
tenant surface, and creating an organization requires that tier. Tests
that model a plain invited user pass ``platform_role=PlatformRole.USER``.
"""
factory = _session_factory_holder["factory"]
async with factory() as session:
user = await UserRepository(session).create(
User(
email=email,
password_hash=hash_password(password),
name=name,
platform_role=platform_role,
)
)
await session.commit()
return {
"access_token": create_access_token(user.id),
"user": {
"id": user.id,
"email": user.email,
"name": user.display_name,
"platform_role": user.platform_role.value,
"is_manager": user.is_manager,
"is_platform_owner": user.is_platform_owner,
},
}
def auth_header(token: str) -> dict[str, str]: def auth_header(token: str) -> dict[str, str]:
@@ -201,22 +246,30 @@ def auth_header(token: str) -> dict[str, str]:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def platform_owner_token(client: AsyncClient, session_factory) -> str: async def platform_owner_token(client: AsyncClient, session_factory) -> str:
"""A signed-up user promoted to platform owner. """A token for the one and only platform owner.
The promotion is done directly in the database because there is deliberately Owner seats are not grantable through the API by design (a manager must
no API that can grant platform-owner status. not be able to mint themselves an owner), so the fixture creates the
account with the owner role directly — the same seat the seed installs.
""" """
# ``example.com`` is a documentation domain and passes email validation.
# Do NOT use ``.local``/``.test``: those are special-use TLDs and newer
# ``email-validator`` releases reject them, which fails the fixture.
email = "platform-owner@example.com" email = "platform-owner@example.com"
body = await signup(client, email, name="مالک سامانه") body = await signup(client, email, name="مالک سامانه", platform_role=PlatformRole.OWNER)
async with session_factory() as session: return body["access_token"]
row = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one() @pytest_asyncio.fixture
row.is_platform_owner = True async def manager_token(client: AsyncClient) -> str:
await session.commit() """A token for a plain manager account (the org-creating tier)."""
body = await signup(client, "manager@example.com", name="مدیر")
return body["access_token"]
@pytest_asyncio.fixture
async def plain_user_token(client: AsyncClient) -> str:
"""A token for a plain user — the tier that may not create organizations."""
body = await signup(
client, "plain-user@example.com", name="کاربر عادی", platform_role=PlatformRole.USER
)
return body["access_token"] return body["access_token"]
@@ -328,6 +381,10 @@ async def seeded(session_factory):
id=provider_id, name="agy", type="agy", base_url="", is_active=True id=provider_id, name="agy", type="agy", base_url="", is_active=True
) )
) )
# Flush the provider before its models: with foreign keys enforced the
# batched model INSERT must not run before the row it references
# exists.
await session.flush()
for internal, display, capabilities, effort in SYSTEM_MODELS: for internal, display, capabilities, effort in SYSTEM_MODELS:
session.add( session.add(
AiModelRow( AiModelRow(
+63 -15
View File
@@ -13,37 +13,36 @@ async def test_health_is_public(client: AsyncClient):
assert response.json()["status"] == "ok" assert response.json()["status"] == "ok"
async def test_signup_returns_token_and_user(client: AsyncClient): async def test_signup_helper_returns_token_and_user(client: AsyncClient):
"""There is no public signup endpoint — the test helper is the owner-side
creation path — and what it returns must carry the platform role."""
body = await signup(client, "alice@example.com", name="آلیس") body = await signup(client, "alice@example.com", name="آلیس")
assert body["access_token"] assert body["access_token"]
assert body["user"]["email"] == "alice@example.com" assert body["user"]["email"] == "alice@example.com"
assert body["user"]["name"] == "آلیس" assert body["user"]["name"] == "آلیس"
assert body["user"]["platform_role"] == "manager"
assert body["user"]["is_manager"] is True
assert body["user"]["is_platform_owner"] is False assert body["user"]["is_platform_owner"] is False
assert "password" not in str(body).lower() or "password_hash" not in str(body) assert "password" not in str(body).lower() or "password_hash" not in str(body)
async def test_signup_never_returns_password_hash(client: AsyncClient): async def test_signup_helper_never_returns_password_hash(client: AsyncClient):
body = await signup(client, "hashcheck@example.com") body = await signup(client, "hashcheck@example.com")
assert "password_hash" not in body["user"] assert "password_hash" not in body["user"]
assert "password" not in body["user"] assert "password" not in body["user"]
async def test_signup_rejects_duplicate_email(client: AsyncClient): async def test_public_signup_endpoint_is_gone(client: AsyncClient):
await signup(client, "dup@example.com") """Signup is closed: accounts come from the owner or from an invite link.
A stray old client must not silently succeed — the route simply does not
exist anymore.
"""
response = await client.post( response = await client.post(
"/api/auth/signup", "/api/auth/signup",
json={"email": "dup@example.com", "password": "password123", "name": ""}, json={"email": "nope@example.com", "password": "password123", "name": ""},
) )
assert response.status_code == 409 assert response.status_code == 404
assert response.json()["error"]["code"] == "conflict"
async def test_signup_rejects_short_password(client: AsyncClient):
response = await client.post(
"/api/auth/signup",
json={"email": "short@example.com", "password": "123", "name": ""},
)
assert response.status_code == 422
async def test_login_succeeds_with_correct_password(client: AsyncClient): async def test_login_succeeds_with_correct_password(client: AsyncClient):
@@ -53,6 +52,9 @@ async def test_login_succeeds_with_correct_password(client: AsyncClient):
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["access_token"] assert response.json()["access_token"]
payload = response.json()["user"]
assert payload["platform_role"] == "manager"
assert payload["is_platform_owner"] is False
async def test_login_fails_with_wrong_password(client: AsyncClient): async def test_login_fails_with_wrong_password(client: AsyncClient):
@@ -96,3 +98,49 @@ async def test_logout_is_recorded_and_returns_204(client: AsyncClient):
body = await signup(client, "frank@example.com") body = await signup(client, "frank@example.com")
response = await client.post("/api/auth/logout", headers=auth_header(body["access_token"])) response = await client.post("/api/auth/logout", headers=auth_header(body["access_token"]))
assert response.status_code == 204 assert response.status_code == 204
async def test_change_password_replaces_the_old_one(client: AsyncClient):
"""A temp password handed out by the owner must become a private one.
Without this, every account created by someone else would live forever on
a secret two people know.
"""
body = await signup(client, "grace@example.com", password="password123")
headers = auth_header(body["access_token"])
response = await client.post(
"/api/auth/change-password",
json={"current_password": "password123", "new_password": "newpassword456"},
headers=headers,
)
assert response.status_code == 204, response.text
stale = await client.post(
"/api/auth/login", json={"email": "grace@example.com", "password": "password123"}
)
assert stale.status_code == 401
fresh = await client.post(
"/api/auth/login", json={"email": "grace@example.com", "password": "newpassword456"}
)
assert fresh.status_code == 200
async def test_change_password_rejects_a_wrong_current_password(client: AsyncClient):
body = await signup(client, "heidi@example.com", password="password123")
response = await client.post(
"/api/auth/change-password",
json={"current_password": "not-my-password", "new_password": "newpassword456"},
headers=auth_header(body["access_token"]),
)
assert response.status_code == 401
async def test_change_password_rejects_a_short_new_password(client: AsyncClient):
body = await signup(client, "ivan@example.com", password="password123")
response = await client.post(
"/api/auth/change-password",
json={"current_password": "password123", "new_password": "123"},
headers=auth_header(body["access_token"]),
)
assert response.status_code == 422
+7 -1
View File
@@ -22,12 +22,13 @@ from app.core.errors import ProviderUnavailableError
from app.infrastructure.db.models import ErrorEventRow from app.infrastructure.db.models import ErrorEventRow
from app.interfaces.http.deps import OrgContext, get_org_context from app.interfaces.http.deps import OrgContext, get_org_context
from app.interfaces.http.request_context import REQUEST_ID_HEADER from app.interfaces.http.request_context import REQUEST_ID_HEADER
# The internal "full" app holds every HTTP handler; the public app serves only # The internal "full" app holds every HTTP handler; the public app serves only
# /health + /api/ws, so this module's REST exercises must target the full app. # /health + /api/ws, so this module's REST exercises must target the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app from app.interfaces.realtime.rpc_app import get_rpc_app
from tests.conftest import auth_header, signup
app = get_rpc_app() app = get_rpc_app()
from tests.conftest import auth_header, signup
#: The password in this DSN must never reach the database. #: The password in this DSN must never reach the database.
LEAKY_DSN = "postgresql://agy:sup3rs3cret@db.internal:5432/agy" LEAKY_DSN = "postgresql://agy:sup3rs3cret@db.internal:5432/agy"
@@ -55,6 +56,11 @@ async def client(engine, session_factory):
await session.rollback() await session.rollback()
raise raise
# The shared ``signup`` helper reads the factory from the conftest holder;
# this module shadows ``client``, so publish it here too.
from tests import conftest as _conftest
_conftest._session_factory_holder["factory"] = session_factory
app.dependency_overrides[get_session] = _override_session app.dependency_overrides[get_session] = _override_session
async with AsyncClient( async with AsyncClient(
transport=ASGITransport(app=app, raise_app_exceptions=False), transport=ASGITransport(app=app, raise_app_exceptions=False),
+117 -10
View File
@@ -1,8 +1,13 @@
"""Invite redemption: the link an admin hands out must actually work. """Invite redemption: the link an admin hands out must actually work.
Covers the full loop — create the invite, preview it as the invitee, redeem it, Covers the full loop — create the invite, preview it as the invitee, redeem it,
and the three ways redemption can legitimately fail. Also pins the absolute and the ways redemption can legitimately fail. Also pins the absolute invite
invite URL, since a bare path is not clickable for the person receiving it. URL, since a bare path is not clickable for the person receiving it.
Because signup is closed, the invite link doubles as the account-creation
path: a brand-new invitee redeems it with a password and gets an account bound
to the invited email. The token can never reset an existing account's password
— that is what the "account already exists" rejection guards.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +18,7 @@ from tests.conftest import auth_header, signup
async def _owner_with_org(client: AsyncClient, name: str = "Acme") -> tuple[str, str]: async def _owner_with_org(client: AsyncClient, name: str = "Acme") -> tuple[str, str]:
"""``(token, org_id)`` for a freshly signed-up owner.""" """``(token, org_id)`` for a freshly created manager and their organization."""
body = await signup(client, "owner@example.com", name="Owner") body = await signup(client, "owner@example.com", name="Owner")
headers = auth_header(body["access_token"]) headers = auth_header(body["access_token"])
org = await client.post("/api/organizations", json={"name": name}, headers=headers) org = await client.post("/api/organizations", json={"name": name}, headers=headers)
@@ -104,19 +109,121 @@ async def test_invite_cannot_be_redeemed_by_a_different_account(client: AsyncCli
async def test_unknown_invite_token_is_not_found(client: AsyncClient): async def test_unknown_invite_token_is_not_found(client: AsyncClient):
user = await signup(client, "nobody@example.com") response = await client.get("/api/invites/definitely-not-a-real-token")
response = await client.get(
"/api/invites/definitely-not-a-real-token",
headers=auth_header(user["access_token"]),
)
assert response.status_code == 404 assert response.status_code == 404
async def test_accepting_an_invite_requires_authentication(client: AsyncClient): async def test_invite_preview_works_without_a_session(client: AsyncClient):
"""A brand-new invitee must see what they were invited to *before* they can
log in — otherwise the login button on the accept screen is a dead end."""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
preview = await client.get(f"/api/invites/{raw}")
assert preview.status_code == 200, preview.text
body = preview.json()
assert body["organization_name"] == "Acme"
assert body["email"] == "newbie@example.com"
assert body["role"] == "member"
# No account exists for the invited email yet, so the UI offers to create
# one instead of demanding a login.
assert body["has_account"] is False
assert body["already_member"] is False
async def test_invite_preview_reports_an_existing_account(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "existing@example.com", "member"))[
"invite_token"
]
await signup(client, "existing@example.com", name="Existing")
preview = await client.get(f"/api/invites/{raw}")
assert preview.status_code == 200, preview.text
assert preview.json()["has_account"] is True
async def test_anonymous_redemption_creates_the_account_and_joins(client: AsyncClient):
"""The closed-signup flow: the link is the capability, the password makes
the account private, and the org membership is created in one step."""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
accepted = await client.post(
"/api/invites/accept", json={"token": raw, "password": "password123"}
)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["organization_id"] == org_id
assert accepted.json()["role"] == "member"
# The account exists for exactly the invited email, with that password, as
# a plain platform user — an invite must never mint a manager.
login = await client.post(
"/api/auth/login", json={"email": "newbie@example.com", "password": "password123"}
)
assert login.status_code == 200, login.text
token = login.json()["access_token"]
assert login.json()["user"]["platform_role"] == "user"
me = await client.get("/api/auth/me", headers=auth_header(token))
joined = [o for o in me.json()["organizations"] if o["id"] == org_id]
assert joined and joined[0]["role"] == "member"
async def test_anonymous_redemption_without_a_password_is_rejected(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client) owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[ raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token" "invite_token"
] ]
response = await client.post("/api/invites/accept", json={"token": raw}) response = await client.post("/api/invites/accept", json={"token": raw})
assert response.status_code == 401 assert response.status_code == 422
assert response.json()["error"]["code"] == "validation_error"
async def test_anonymous_redemption_rejects_a_short_password(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
response = await client.post(
"/api/invites/accept", json={"token": raw, "password": "123"}
)
assert response.status_code == 422
async def test_a_link_cannot_reset_an_existing_account(client: AsyncClient):
"""A leaked invite must not become a password reset for the real account.
Whoever holds the link can create an account *only if none exists* for the
invited email; otherwise they must authenticate first.
"""
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "taken@example.com", "member"))[
"invite_token"
]
await signup(client, "taken@example.com", password="password123")
response = await client.post(
"/api/invites/accept", json={"token": raw, "password": "attacker-password"}
)
assert response.status_code == 403
assert "وارد شوید" in response.json()["error"]["message"]
# The real account's password is untouched, and the invite is not burned:
# the invitee can still redeem it after signing in.
login = await client.post(
"/api/auth/login", json={"email": "taken@example.com", "password": "password123"}
)
assert login.status_code == 200
accepted = await client.post(
"/api/invites/accept",
json={"token": raw},
headers=auth_header(login.json()["access_token"]),
)
assert accepted.status_code == 200, accepted.text
+519
View File
@@ -0,0 +1,519 @@
"""The platform hierarchy: one owner, owner-created managers, invited tenants.
This is the security core of the identity model, so every rule gets both sides
tested — the allow and the deny:
1. **Exactly one owner.** The database refuses a second owner row outright;
the API refuses to mint or demote one. The seat moves only through a
transactional transfer that demotes the previous owner in the same step.
2. **Only the owner and managers create organizations.** A plain user may not.
3. **Only the owner creates managers.** No manager can mint another manager,
and no manager can enter the admin console.
4. **A departing manager's organizations move to the owner** — never orphaned,
never deleted with their data.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.domain.identity.models import Role
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from tests.conftest import auth_header
async def _create_org(client: AsyncClient, token: str, name: str) -> dict:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def _login(client: AsyncClient, email: str, password: str) -> str:
response = await client.post(
"/api/auth/login", json={"email": email, "password": password}
)
assert response.status_code == 200, response.text
return response.json()["access_token"]
async def _owner_creates_user(
client: AsyncClient,
owner_token: str,
email: str,
*,
platform_role: str = "manager",
name: str = "",
) -> dict:
response = await client.post(
"/api/admin/users",
json={"email": email, "name": name, "platform_role": platform_role},
headers=auth_header(owner_token),
)
assert response.status_code == 201, response.text
return response.json()
# ---------------------------------------------------------------------------
# Exactly one owner
# ---------------------------------------------------------------------------
async def test_only_one_platform_owner_may_exist(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The invariant is enforced by the database, not by habit.
Even a direct INSERT — the kind a future code path or a migration would
perform — cannot produce a second owner row.
"""
from sqlalchemy.exc import IntegrityError as _IntegrityError
async with session_factory() as session:
session.add(
UserRow(
id="second-owner-row",
email="second-owner@example.com",
password_hash="x",
platform_role="owner",
)
)
with pytest.raises(_IntegrityError):
await session.flush()
await session.rollback()
async def test_owner_cannot_be_created_through_the_api(
client: AsyncClient, platform_owner_token: str
):
response = await client.post(
"/api/admin/users",
json={"email": "wannabe@example.com", "platform_role": "owner"},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 422
assert "مالکیت" in response.json()["error"]["message"]
async def test_owner_account_is_immutable_from_the_api(
client: AsyncClient, platform_owner_token: str
):
"""No API path may demote, deactivate or delete the single owner seat."""
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
owner_id = me.json()["user"]["id"]
demote = await client.patch(
f"/api/admin/users/{owner_id}",
json={"platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert demote.status_code == 403
deactivate = await client.patch(
f"/api/admin/users/{owner_id}",
json={"is_active": False},
headers=auth_header(platform_owner_token),
)
assert deactivate.status_code == 403
delete = await client.delete(
f"/api/admin/users/{owner_id}", headers=auth_header(platform_owner_token)
)
assert delete.status_code == 403
async def test_transfer_ownership_moves_the_seat_and_keeps_exactly_one(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The safety valve for a lost owner account — transactional by design."""
manager = await _owner_creates_user(
client, platform_owner_token, "successor@example.com", name="جانشین"
)
successor_token = await _login(
client, "successor@example.com", manager["temp_password"]
)
response = await client.post(
"/api/admin/transfer-ownership",
json={"user_id": manager["user"]["id"]},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json()["platform_role"] == "owner"
async with session_factory() as session:
owners = (
(await session.execute(select(UserRow).where(UserRow.platform_role == "owner")))
.scalars()
.all()
)
assert [o.email for o in owners] == ["successor@example.com"]
# The previous owner is demoted to manager in the same step — a manager,
# not a powerless user, so they keep the ability to run organizations.
previous = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
assert previous.json()["user"]["platform_role"] == "manager"
# And the successor really is the owner: the console opens for them.
console = await client.get("/api/admin/users", headers=auth_header(successor_token))
assert console.status_code == 200
# ---------------------------------------------------------------------------
# Organization creation is gated by platform role
# ---------------------------------------------------------------------------
async def test_plain_user_cannot_create_an_organization(
client: AsyncClient, platform_owner_token: str, plain_user_token: str
):
response = await client.post(
"/api/organizations",
json={"name": "سازمان ممنوعه"},
headers=auth_header(plain_user_token),
)
assert response.status_code == 403
assert "دعوت" in response.json()["error"]["message"]
async def test_manager_can_create_an_organization(client: AsyncClient, manager_token: str):
org = await _create_org(client, manager_token, "سازمان مدیر")
assert org["owner_user_id"] == (
await client.get("/api/auth/me", headers=auth_header(manager_token))
).json()["user"]["id"]
async def test_owner_can_create_an_organization(client: AsyncClient, platform_owner_token: str):
org = await _create_org(client, platform_owner_token, "سازمان مالک")
assert org["name"] == "سازمان مالک"
# ---------------------------------------------------------------------------
# Only the owner creates managers
# ---------------------------------------------------------------------------
async def test_manager_cannot_create_accounts(client: AsyncClient, manager_token: str):
response = await client.post(
"/api/admin/users",
json={"email": "sneaky@example.com", "platform_role": "manager"},
headers=auth_header(manager_token),
)
assert response.status_code == 403
async def test_plain_user_cannot_create_accounts(client: AsyncClient, plain_user_token: str):
response = await client.post(
"/api/admin/users",
json={"email": "sneaky2@example.com", "platform_role": "manager"},
headers=auth_header(plain_user_token),
)
assert response.status_code == 403
async def test_manager_cannot_reach_the_admin_console(client: AsyncClient, manager_token: str):
for path in ("/api/admin/users", "/api/admin/models", "/api/admin/organizations"):
response = await client.get(path, headers=auth_header(manager_token))
assert response.status_code == 403, path
async def test_owner_creates_a_manager_whose_temp_password_works(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "manager1@example.com", name="مدیر یک"
)
assert created["user"]["platform_role"] == "manager"
assert created["user"]["is_manager"] is True
assert created["user"]["owned_organizations"] == 0
temp = created["temp_password"]
assert temp
login = await client.post(
"/api/auth/login",
json={"email": "manager1@example.com", "password": temp},
)
assert login.status_code == 200, login.text
assert login.json()["user"]["platform_role"] == "manager"
async def test_duplicate_email_is_rejected(client: AsyncClient, platform_owner_token: str):
await _owner_creates_user(client, platform_owner_token, "twin@example.com")
response = await client.post(
"/api/admin/users",
json={"email": "twin@example.com", "platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 409
async def test_owner_can_create_plain_users(client: AsyncClient, platform_owner_token: str):
created = await _owner_creates_user(
client,
platform_owner_token,
"plain@example.com",
platform_role="user",
name="کاربر عادی",
)
assert created["user"]["platform_role"] == "user"
assert created["user"]["is_manager"] is False
async def test_owner_resets_a_forgotten_temp_password(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "forgetful@example.com", name="فراموشکار"
)
first = created["temp_password"]
reset = await client.post(
f"/api/admin/users/{created['user']['id']}/reset-password",
headers=auth_header(platform_owner_token),
)
assert reset.status_code == 200, reset.text
second = reset.json()["temp_password"]
assert second and second != first
stale = await client.post(
"/api/auth/login", json={"email": "forgetful@example.com", "password": first}
)
assert stale.status_code == 401
fresh = await client.post(
"/api/auth/login", json={"email": "forgetful@example.com", "password": second}
)
assert fresh.status_code == 200
# ---------------------------------------------------------------------------
# A departing manager's organizations move to the owner
# ---------------------------------------------------------------------------
async def test_demoting_a_manager_transfers_organizations_to_the_owner(
client: AsyncClient, platform_owner_token: str, session_factory
):
manager = await _owner_creates_user(
client, platform_owner_token, "leaving@example.com", name="خداحافظ"
)
manager_token = await _login(
client, "leaving@example.com", manager["temp_password"]
)
org = await _create_org(client, manager_token, "سازمان خداحافظ")
demote = await client.patch(
f"/api/admin/users/{manager['user']['id']}",
json={"platform_role": "user"},
headers=auth_header(platform_owner_token),
)
assert demote.status_code == 200, demote.text
assert demote.json()["platform_role"] == "user"
assert demote.json()["owned_organizations"] == 0
async with session_factory() as session:
row = await session.get(OrganizationRow, org["id"])
assert row is not None
owner_id = (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]
assert row.owner_user_id == owner_id
memberships = (
(
await session.execute(
select(MembershipRow).where(MembershipRow.organization_id == org["id"])
)
)
.scalars()
.all()
)
by_user = {m.user_id: m for m in memberships}
# The new owner can actually see the tenant...
assert by_user[owner_id].role == Role.OWNER.value
# ...and the demoted manager keeps working access without ownership.
assert by_user[manager["user"]["id"]].role == Role.ADMIN.value
async def test_deleting_a_manager_transfers_organizations_and_memberships(
client: AsyncClient, platform_owner_token: str, session_factory
):
manager = await _owner_creates_user(
client, platform_owner_token, "doomed@example.com", name="حذف‌شونده"
)
manager_token = await _login(client, "doomed@example.com", manager["temp_password"])
org = await _create_org(client, manager_token, "سازمان حذف‌شونده")
response = await client.delete(
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["deleted_user_id"] == manager["user"]["id"]
assert [t["organization_id"] for t in body["transferred_organizations"]] == [org["id"]]
async with session_factory() as session:
assert await session.get(UserRow, manager["user"]["id"]) is None
row = await session.get(OrganizationRow, org["id"])
assert row is not None, "the organization must survive its owner's deletion"
owner_id = (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]
assert row.owner_user_id == owner_id
stale = (
await session.execute(
select(MembershipRow).where(
MembershipRow.user_id == manager["user"]["id"]
)
)
).scalars().all()
assert stale == [], "the deleted account's memberships must be gone"
# ---------------------------------------------------------------------------
# Managers create and join organizations
# ---------------------------------------------------------------------------
async def test_manager_can_join_another_managers_organization_by_invite(
client: AsyncClient, platform_owner_token: str
):
"""The invited manager keeps their own organization and gains a second."""
first = await _owner_creates_user(
client, platform_owner_token, "first@example.com", name="مدیر یکم"
)
second = await _owner_creates_user(
client, platform_owner_token, "second@example.com", name="مدیر دوم"
)
first_token = await _login(client, "first@example.com", first["temp_password"])
second_token = await _login(client, "second@example.com", second["temp_password"])
own_org = await _create_org(client, first_token, "سازمان مدیر یکم")
# The second manager has a tenant of their own before being invited.
second_own_org = await _create_org(client, second_token, "سازمان مدیر دوم")
invite = await client.post(
f"/api/organizations/{own_org['id']}/invites",
json={"email": "second@example.com", "role": "admin"},
headers=auth_header(first_token),
)
assert invite.status_code == 201, invite.text
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(second_token),
)
assert accepted.status_code == 200, accepted.text
me = await client.get("/api/auth/me", headers=auth_header(second_token))
roles = {o["id"]: o["role"] for o in me.json()["organizations"]}
# Joined the other manager's organization with the invited role...
assert roles[own_org["id"]] == "admin"
# ...and still owns their own, untouched.
assert roles[second_own_org["id"]] == "owner"
assert len(me.json()["organizations"]) == 2
async def test_promoting_a_plain_user_to_manager_is_possible(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "promoted@example.com", platform_role="user"
)
promoted = await client.patch(
f"/api/admin/users/{created['user']['id']}",
json={"platform_role": "manager"},
headers=auth_header(platform_owner_token),
)
assert promoted.status_code == 200, promoted.text
assert promoted.json()["platform_role"] == "manager"
# The new tier takes effect immediately: organizations are now allowed.
promoted_token = await _login(
client, "promoted@example.com", created["temp_password"]
)
org = await _create_org(client, promoted_token, "سازمان ارتقایافته")
assert org["name"] == "سازمان ارتقایافته"
async def test_deactivating_an_account_blocks_login(
client: AsyncClient, platform_owner_token: str
):
created = await _owner_creates_user(
client, platform_owner_token, "frozen@example.com", name="مسدود"
)
response = await client.patch(
f"/api/admin/users/{created['user']['id']}",
json={"is_active": False},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json()["is_active"] is False
login = await client.post(
"/api/auth/login",
json={"email": "frozen@example.com", "password": created["temp_password"]},
)
assert login.status_code == 401
async def test_user_list_reports_roles_and_owned_organization_counts(
client: AsyncClient, platform_owner_token: str
):
manager = await _owner_creates_user(
client, platform_owner_token, "listed@example.com", name="فهرست"
)
manager_token = await _login(client, "listed@example.com", manager["temp_password"])
await _create_org(client, manager_token, "سازمان فهرست")
listing = await client.get("/api/admin/users", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
rows = {u["email"]: u for u in listing.json()}
assert rows["listed@example.com"]["platform_role"] == "manager"
assert rows["listed@example.com"]["owned_organizations"] == 1
me = await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
owner_email = me.json()["user"]["email"]
assert rows[owner_email]["platform_role"] == "owner"
assert rows[owner_email]["is_platform_owner"] is True
async def test_admin_user_endpoints_require_authentication(client: AsyncClient):
assert (await client.get("/api/admin/users")).status_code == 401
assert (
await client.post("/api/admin/users", json={"email": "x@example.com"})
).status_code == 401
async def test_every_seat_move_is_audited(
client: AsyncClient, platform_owner_token: str, session_factory
):
"""The transfer and the deletion are the two irreversible moves; both must
leave a trail that names the actor and the moved organizations."""
from app.domain.audit.models import AuditAction
manager = await _owner_creates_user(
client, platform_owner_token, "audited@example.com", name="ممیزی"
)
manager_token = await _login(client, "audited@example.com", manager["temp_password"])
org = await _create_org(client, manager_token, "سازمان ممیزی")
response = await client.delete(
f"/api/admin/users/{manager['user']['id']}", headers=auth_header(platform_owner_token)
)
assert response.status_code == 200
from app.infrastructure.repositories.audit import AuditRepository
async with session_factory() as session:
events = await AuditRepository(session).list_platform(limit=100)
actions = {e.action: e for e in events}
assert AuditAction.USER_CREATE in actions
assert AuditAction.USER_DELETE in actions
delete_event = actions[AuditAction.USER_DELETE]
assert delete_event.metadata_json["transferred_organizations"] == [org["id"]]
assert delete_event.actor_user_id == (
await client.get("/api/auth/me", headers=auth_header(platform_owner_token))
).json()["user"]["id"]
+52 -20
View File
@@ -25,13 +25,14 @@ from sqlalchemy.pool import NullPool
import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata
from app.application.ports.realtime import RealtimeEvent, set_publisher from app.application.ports.realtime import RealtimeEvent, set_publisher
from app.core.config import settings from app.core.config import settings
from app.core.database import Base, get_session from app.core.database import Base, enable_sqlite_foreign_keys, get_session
from app.core.security import create_access_token from app.core.security import create_access_token
from app.domain.base import new_id from app.domain.base import new_id
from app.infrastructure.db.models import UserRow from app.infrastructure.db.models import UserRow
from app.interfaces.realtime.authz import authorize_channel from app.interfaces.realtime.authz import authorize_channel
from app.interfaces.realtime.bus import get_bus from app.interfaces.realtime.bus import get_bus
from app.interfaces.realtime.manager import Connection, ConnectionManager from app.interfaces.realtime.manager import Connection, ConnectionManager
# The internal "full" app holds every HTTP handler; the public app serves only # The internal "full" app holds every HTTP handler; the public app serves only
# /health + /api/ws. The socket and the RPC surface both live on the full app. # /health + /api/ws. The socket and the RPC surface both live on the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app from app.interfaces.realtime.rpc_app import get_rpc_app
@@ -85,7 +86,7 @@ async def _user_with_org(session: AsyncSession, *, platform_owner: bool = False)
id=user_id, id=user_id,
email=f"{user_id}@example.com", email=f"{user_id}@example.com",
password_hash="x", password_hash="x",
is_platform_owner=platform_owner, platform_role=("owner" if platform_owner else "user"),
) )
) )
await session.flush() await session.flush()
@@ -277,6 +278,7 @@ class _NullPublisher:
async def _create_schema(engine) -> None: async def _create_schema(engine) -> None:
enable_sqlite_foreign_keys(engine)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@@ -299,6 +301,9 @@ def ws_client(monkeypatch):
monkeypatch.setattr("app.main.engine", engine) monkeypatch.setattr("app.main.engine", engine)
monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory) monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory)
monkeypatch.setattr(settings, "realtime_transport", "local") monkeypatch.setattr(settings, "realtime_transport", "local")
# Published for ``_create_account`` below: signup is closed, so the socket
# tests that need an identity mint it directly against this database.
_ws_factory_holder["factory"] = factory
async def _override_session(): async def _override_session():
async with factory() as session: async with factory() as session:
@@ -335,11 +340,45 @@ def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
raise AssertionError(f"no {wanted_type} event arrived") raise AssertionError(f"no {wanted_type} event arrived")
def test_socket_allows_anonymous_then_restricts_actions(ws_client): #: Set by the ``ws_client`` fixture; read by ``_create_account``.
"""No token opens an anonymous socket, but only login/signup may be called. _ws_factory_holder: dict[str, async_sessionmaker] = {}
A visitor who has not logged in can reach ``/api/auth/login`` and
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused. def _create_account(email: str, *, name: str = "", platform_role: str = "manager") -> str:
"""Create an account directly in the throwaway database; return its token.
Signup is closed — accounts come from the platform owner or from an
invite — so a socket test that needs an identity writes the row the way
those flows would and signs a token for it.
"""
from app.core.security import create_access_token, hash_password
from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import UserRepository
factory = _ws_factory_holder["factory"]
async def _create():
async with factory() as session:
user = await UserRepository(session).create(
User(
email=email,
password_hash=hash_password("password123"),
name=name,
platform_role=PlatformRole(platform_role),
)
)
await session.commit()
return user
user = asyncio.run(_create())
return create_access_token(user.id)
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
"""No token opens an anonymous socket, but only the bootstrap RPCs run.
A visitor who has not logged in can reach ``/api/auth/login`` and redeem an
invite over RPC; every other RPC and any subscribe is refused.
""" """
with ws_client.websocket_connect("/api/ws") as socket: with ws_client.websocket_connect("/api/ws") as socket:
hello = socket.receive_json() hello = socket.receive_json()
@@ -347,7 +386,9 @@ def test_socket_allows_anonymous_then_restricts_actions(ws_client):
assert hello["user_id"] is None assert hello["user_id"] is None
# A protected RPC is refused for an anonymous connection. # A protected RPC is refused for an anonymous connection.
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"}) socket.send_json(
{"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"}
)
result = socket.receive_json() result = socket.receive_json()
assert result["type"] == "rpc_result" assert result["type"] == "rpc_result"
assert result["status"] == 401 assert result["status"] == 401
@@ -378,13 +419,9 @@ def test_anonymous_may_call_login_over_rpc(ws_client):
def test_task_creation_is_pushed_live(ws_client): def test_task_creation_is_pushed_live(ws_client):
# Sign up, create an organization — the tenant the socket subscribes to. # Create a manager account and its organization — the tenant the socket
signup = ws_client.post( # subscribes to. (Signup is closed; this is the owner-created path.)
"/api/auth/signup", token = _create_account("live@example.com", name="زنده")
json={"email": "live@example.com", "password": "password123", "name": "زنده"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
org = ws_client.post( org = ws_client.post(
"/api/organizations", "/api/organizations",
json={"name": "سازمان زنده"}, json={"name": "سازمان زنده"},
@@ -425,12 +462,7 @@ def test_second_user_cannot_read_another_org(ws_client):
tokens = [] tokens = []
org_ids = [] org_ids = []
for index in range(2): for index in range(2):
signup = ws_client.post( token = _create_account(f"user{index}@example.com")
"/api/auth/signup",
json={"email": f"user{index}@example.com", "password": "password123"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
org = ws_client.post( org = ws_client.post(
"/api/organizations", "/api/organizations",
json={"name": f"org {index}"}, json={"name": f"org {index}"},
+49 -17
View File
@@ -71,28 +71,59 @@ async def test_seeded_platform_owner_can_log_in(client: AsyncClient, seeded_inst
assert me.json()["user"]["is_platform_owner"] is True assert me.json()["user"]["is_platform_owner"] is True
async def test_seeded_accounts_belong_to_the_sample_organization( async def test_seeded_demo_manager_owns_the_sample_organization(
client: AsyncClient, seeded_install client: AsyncClient, seeded_install
): ):
"""A login that lands on an empty dashboard is a broken demo. """A login that lands on an empty dashboard is a broken demo.
Both seeded accounts must see the sample organization, otherwise every The demo account is a *manager* — the tier that creates organizations — so
screen behind the login renders nothing. it must own the sample organization and land on a populated dashboard.
""" """
for email, password in ( login = await client.post(
(seeded_install.DEMO_EMAIL, seeded_install.DEMO_PASSWORD), "/api/auth/login",
(seeded_install.PLATFORM_OWNER_EMAIL, seeded_install.PLATFORM_OWNER_PASSWORD), json={
): "email": seeded_install.DEMO_EMAIL,
login = await client.post("/api/auth/login", json={"email": email, "password": password}) "password": seeded_install.DEMO_PASSWORD,
assert login.status_code == 200, login.text },
)
assert login.status_code == 200, login.text
assert login.json()["user"]["platform_role"] == "manager"
assert login.json()["user"]["is_manager"] is True
assert login.json()["user"]["is_platform_owner"] is False
me = await client.get( me = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"]) "/api/auth/me", headers=auth_header(login.json()["access_token"])
) )
organizations = me.json()["organizations"] organizations = me.json()["organizations"]
assert organizations, f"{email} belongs to no organization" assert organizations, "the demo manager belongs to no organization"
assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME
assert organizations[0]["role"] == "owner" assert organizations[0]["role"] == "owner"
async def test_platform_owner_is_not_joined_to_any_organization(
client: AsyncClient, seeded_install
):
"""The product rule: the owner observes tenants from the console.
Auto-joining the owner to a tenant would put the single owner seat inside
customer data, which is exactly what the hierarchy is designed to avoid.
"""
login = await client.post(
"/api/auth/login",
json={
"email": seeded_install.PLATFORM_OWNER_EMAIL,
"password": seeded_install.PLATFORM_OWNER_PASSWORD,
},
)
assert login.status_code == 200, login.text
me = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"])
)
assert me.status_code == 200, me.text
assert me.json()["user"]["is_platform_owner"] is True
assert me.json()["user"]["platform_role"] == "owner"
assert me.json()["organizations"] == []
async def test_seeded_install_can_use_the_product( async def test_seeded_install_can_use_the_product(
@@ -216,4 +247,5 @@ async def test_seed_is_idempotent(client: AsyncClient, seeded_install, session_f
assert after == before, f"re-seeding changed row counts: {before} -> {after}" assert after == before, f"re-seeding changed row counts: {before} -> {after}"
assert after["organizations"] == 1 assert after["organizations"] == 1
assert after["memberships"] == 2, "one per seeded account, and no duplicates" # Only the demo manager: the platform owner stays out of tenants.
assert after["memberships"] == 1
+132
View File
@@ -0,0 +1,132 @@
"use client";
/**
* Personal account settings.
*
* Deliberately not inside `/settings`: that page is organization-scoped and
* shows nothing when no organization is selected — which is exactly the state
* of a freshly created manager or an invited user before they join anything.
* Everyone must be able to replace the temporary password someone else chose
* for them, so this lives behind the user menu instead.
*/
import * as React from "react";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ErrorState,
Field,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
const MIN_PASSWORD_LENGTH = 8;
export default function AccountPage() {
const { user } = useSession();
const [current, setCurrent] = React.useState("");
const [next, setNext] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [done, setDone] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setDone(false);
if (next !== confirm) {
setError("رمز عبور جدید و تکرار آن یکسان نیستند.");
return;
}
setSubmitting(true);
try {
await api.changePassword({ current_password: current, new_password: next });
setDone(true);
setCurrent("");
setNext("");
setConfirm("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "تغییر رمز عبور ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">حساب کاربری</h1>
<p className="text-sm text-slate-400">{user?.email}</p>
</header>
<Card>
<CardTitle>نقش شما در سامانه</CardTitle>
<CardHint>
{user?.is_platform_owner
? "شما مالک سامانه هستید: تنها حساب با دسترسی به کنسول مدیریت، ساخت مدیران و انتقال مالکیت."
: user?.is_manager
? "شما مدیر هستید: می‌توانید سازمان بسازید و با دعوت در سازمان مدیران دیگر عضو شوید."
: "شما کاربر عادی هستید: با دعوت به سازمان‌ها می‌پیوندید و داخل آن‌ها کار می‌کنید."}
</CardHint>
<div className="mt-3">
<Badge tone={user?.is_platform_owner ? "info" : user?.is_manager ? "success" : "neutral"}>
{user ? PLATFORM_ROLE_LABELS[user.platform_role] : "—"}
</Badge>
</div>
</Card>
<Card>
<CardTitle>تغییر رمز عبور</CardTitle>
<CardHint>
اگر رمز شما را شخص دیگری (مالک سامانه) تعیین کرده است، از اینجا آن را به
رمزی خصوصی تغییر دهید.
</CardHint>
<form onSubmit={onSubmit} className="mt-4 flex max-w-sm flex-col gap-4">
<Field
label="رمز عبور فعلی"
type="password"
dir="ltr"
autoComplete="current-password"
required
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Field
label="رمز عبور جدید"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={next}
onChange={(e) => setNext(e.target.value)}
hint={`حداقل ${MIN_PASSWORD_LENGTH} کاراکتر.`}
/>
<Field
label="تکرار رمز عبور جدید"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{error && <ErrorState title="تغییر رمز ناموفق" message={error} />}
{done && (
<p className="text-xs text-emerald-400">رمز عبور با موفقیت تغییر کرد.</p>
)}
<Button type="submit" loading={submitting}>
تغییر رمز عبور
</Button>
</form>
</Card>
</div>
);
}
+1
View File
@@ -16,6 +16,7 @@ import { useSession } from "@/lib/session";
const SECTIONS = [ const SECTIONS = [
{ href: "/admin", label: "نمای کلی" }, { href: "/admin", label: "نمای کلی" },
{ href: "/admin/users", label: "کاربران و مدیران" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy" }, { href: "/admin/agy-accounts", label: "اکانت‌های agy" },
{ href: "/admin/models", label: "مدل‌ها" }, { href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" }, { href: "/admin/routing", label: "مسیریابی" },
+5
View File
@@ -83,6 +83,11 @@ export default function AdminOverviewPage() {
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{[ {[
{
title: "کاربران و مدیران",
hint: "مالک سامانه دقیقاً یکی است؛ فقط او مدیر می‌سازد و می‌تواند مالکیت را منتقل کند.",
path: "/admin/users",
},
{ {
title: "اکانت‌های agy", title: "اکانت‌های agy",
hint: "ورود با Google و پول چرخشی اکانت‌ها. agy تنها ارائه‌دهندهٔ سامانه است.", hint: "ورود با Google و پول چرخشی اکانت‌ها. agy تنها ارائه‌دهندهٔ سامانه است.",
+441
View File
@@ -0,0 +1,441 @@
"use client";
/**
* Platform owner console: accounts.
*
* The whole hierarchy lives on this screen — there is exactly one owner, the
* owner mints the manager accounts (the tier allowed to create organizations),
* and every other account joins an organization by invitation. The destructive
* actions (demote, delete, transfer ownership) each say what will happen to the
* organizations involved *before* they run, because the API moves them to the
* owner and there is no undo.
*/
import * as React from "react";
import type { AdminUser, PlatformRole } from "@agy/contracts";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
Stat,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const ROLE_TONES: Record<PlatformRole, "info" | "success" | "neutral"> = {
owner: "info",
manager: "success",
user: "neutral",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminUsersPage() {
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [busy, setBusy] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const [newEmail, setNewEmail] = React.useState("");
const [newName, setNewName] = React.useState("");
const [newRole, setNewRole] = React.useState<"manager" | "user">("manager");
const [creating, setCreating] = React.useState(false);
const [createError, setCreateError] = React.useState<string | null>(null);
//: The one-time password, shown in a modal until dismissed.
const [tempPassword, setTempPassword] = React.useState<{ email: string; value: string } | null>(
null,
);
const [confirm, setConfirm] = React.useState<
| { kind: "demote"; user: AdminUser }
| { kind: "delete"; user: AdminUser }
| { kind: "transfer"; user: AdminUser }
| null
>(null);
const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
}, []);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setUsers(await api.adminUsers());
} catch (err) {
setError(errorMessage(err, "دریافت فهرست کاربران ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setCreating(true);
setCreateError(null);
try {
const created = await api.adminCreateUser({
email: newEmail.trim(),
name: newName.trim(),
platform_role: newRole,
});
setTempPassword({ email: created.user.email, value: created.temp_password });
setCreateOpen(false);
setNewEmail("");
setNewName("");
setNewRole("manager");
await load();
} catch (err) {
setCreateError(errorMessage(err, "ساخت کاربر ناموفق بود."));
} finally {
setCreating(false);
}
};
const onConfirm = async () => {
if (!confirm) return;
const { kind, user } = confirm;
setConfirm(null);
setBusy(user.id);
try {
if (kind === "demote") {
await api.adminUpdateUser(user.id, { platform_role: "user" });
toast(
"کاربر به سطح عادی تنزل یافت",
"success",
user.owned_organizations > 0
? `${user.owned_organizations} سازمان به مالک سامانه منتقل شد.`
: undefined,
);
} else if (kind === "delete") {
const result = await api.adminDeleteUser(user.id);
toast(
"حساب حذف شد",
"success",
result.transferred_organizations.length > 0
? `${result.transferred_organizations.length} سازمان به مالک سامانه منتقل شد.`
: undefined,
);
} else {
await api.adminTransferOwnership(user.id);
toast("مالکیت سامانه منتقل شد", "success", "حساب شما اکنون مدیر است.");
}
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const onToggleActive = async (user: AdminUser) => {
setBusy(user.id);
try {
await api.adminUpdateUser(user.id, { is_active: !user.is_active });
toast(user.is_active ? "حساب غیرفعال شد" : "حساب فعال شد");
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const onResetPassword = async (user: AdminUser) => {
setBusy(user.id);
try {
const result = await api.adminResetUserPassword(user.id);
setTempPassword({ email: user.email, value: result.temp_password });
} catch (err) {
toast("تغییر رمز ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
if (loading) return <LoadingState label="در حال دریافت کاربران…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const owner = users.find((user) => user.is_platform_owner) ?? null;
const managers = users.filter((user) => user.platform_role === "manager");
const plainUsers = users.filter((user) => user.platform_role === "user");
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کاربران و مدیران</h1>
<p className="text-sm text-slate-400">
مالک سامانه دقیقاً یکی است و فقط او می‌تواند مدیر بسازد. مدیران سازمان
خودشان را می‌سازند؛ کاربران عادی با دعوت به سازمان‌ها می‌پیوندند.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="مالک سامانه" value={owner ? 1 : 0} />
<Stat label="مدیران" value={managers.length} />
<Stat label="کاربران عادی" value={plainUsers.length} />
</div>
{users.length === 0 ? (
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
) : (
<Card>
<CardTitle>فهرست حساب‌ها</CardTitle>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">ایمیل</th>
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">نقش پلتفرم</th>
<th className="px-2 py-2 text-right font-medium">سازمان‌ها</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium">عملیات</th>
</tr>
</thead>
<tbody className="anim-stagger">
{users.map((user) => (
<tr key={user.id} className="border-b border-surface-border/60">
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">{user.email}</td>
<td className="px-2 py-2 text-slate-200">{user.name}</td>
<td className="px-2 py-2">
<Badge tone={ROLE_TONES[user.platform_role]}>
{PLATFORM_ROLE_LABELS[user.platform_role]}
</Badge>
</td>
<td className="px-2 py-2">
<Badge tone="neutral">{user.owned_organizations}</Badge>
</td>
<td className="px-2 py-2">
<Badge tone={user.is_active ? "success" : "warning"}>
{user.is_active ? "فعال" : "غیرفعال"}
</Badge>
</td>
<td className="px-2 py-2">
{user.is_platform_owner ? (
<span className="text-xs text-slate-500">
حساب مالک سامانه قابل تغییر نیست
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{user.platform_role === "manager" ? (
<Button
size="sm"
variant="secondary"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "demote", user })}
>
تنزل به کاربر
</Button>
) : (
<Button
size="sm"
variant="secondary"
disabled={busy === user.id}
onClick={async () => {
setBusy(user.id);
try {
await api.adminUpdateUser(user.id, {
platform_role: "manager",
});
toast("کاربر به مدیر ارتقا یافت");
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
}}
>
ارتقا به مدیر
</Button>
)}
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => void onToggleActive(user)}
>
{user.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => void onResetPassword(user)}
>
رمز جدید
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "transfer", user })}
>
انتقال مالکیت
</Button>
<Button
size="sm"
variant="danger"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "delete", user })}
>
حذف
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<Modal
open={createOpen}
title="ساخت کاربر"
description="رمز موقت فقط یک‌بار نمایش داده می‌شود؛ کاربر باید آن را در تنظیمات تغییر دهد."
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" form="create-user-form" loading={creating}>
ساخت
</Button>
</>
}
>
<form id="create-user-form" onSubmit={onCreate} className="flex flex-col gap-4">
<Field
label="ایمیل"
type="email"
dir="ltr"
required
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
<Field
label="نام"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="مثلاً مریم رضایی"
/>
<Select
label="نقش پلتفرم"
value={newRole}
onChange={(e) => setNewRole(e.target.value as "manager" | "user")}
options={[
{ value: "manager", label: "مدیر — می‌تواند سازمان بسازد" },
{ value: "user", label: "کاربر عادی — فقط با دعوت عضو می‌شود" },
]}
/>
{createError && <ErrorState title="ساخت کاربر ناموفق" message={createError} />}
</form>
</Modal>
<Modal
open={tempPassword !== null}
title="رمز موقت"
description="این رمز فقط همین یک‌بار نمایش داده می‌شود. آن را به‌صورت امن به کاربر برسانید."
onClose={() => setTempPassword(null)}
footer={
<>
<Button
variant="secondary"
onClick={() => {
if (tempPassword) void navigator.clipboard?.writeText(tempPassword.value);
toast("رمز موقت کپی شد");
}}
>
کپی رمز
</Button>
<Button onClick={() => setTempPassword(null)}>متوجه شدم</Button>
</>
}
>
<p className="text-xs text-slate-400">
حساب: <span className="ltr-inline text-slate-200">{tempPassword?.email}</span>
</p>
<p className="ltr-inline mt-3 rounded-lg border border-surface-border bg-surface p-3 font-mono text-sm text-slate-100">
{tempPassword?.value}
</p>
</Modal>
<ConfirmDialog
open={confirm?.kind === "demote"}
title="تنزل مدیر به کاربر عادی؟"
description={
confirm?.kind === "demote"
? confirm.user.owned_organizations > 0
? `این مدیر ${confirm.user.owned_organizations} سازمان دارد؛ همهٔ آن‌ها به مالک سامانه منتقل می‌شوند و او دیگر نمی‌تواند سازمان بسازد.`
: "این کاربر دیگر نمی‌تواند سازمان بسازد."
: undefined
}
confirmLabel="تنزل"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={confirm?.kind === "delete"}
title="حذف حساب؟"
description={
confirm?.kind === "delete"
? confirm.user.owned_organizations > 0
? `این حساب ${confirm.user.owned_organizations} سازمان دارد؛ سازمان‌ها و داده‌هایشان حفظ می‌شوند و به مالک سامانه منتقل می‌شوند. عضویت‌های این حساب حذف می‌شوند.`
: "این حساب و همهٔ عضویت‌هایش حذف می‌شوند."
: undefined
}
confirmLabel="حذف حساب"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={confirm?.kind === "transfer"}
title="انتقال مالکیت سامانه؟"
description={
confirm?.kind === "transfer"
? `مالکیت کل سامانه به ${confirm.user.email} منتقل می‌شود و حساب شما به مدیر تنزل می‌یابد. دسترسی کنسول مالک سامانه از شما گرفته می‌شود.`
: undefined
}
confirmLabel="انتقال مالکیت"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+116 -63
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
/** /**
* Invite redemption screen. * Invite redemption screen — the only door into the product now that signup is
* closed.
* *
* Lives outside the ``(app)`` group on purpose: the invitee is not a member of * Lives outside the ``(app)`` group on purpose: the invitee is not a member of
* anything yet, so the authenticated shell would bounce them to /login before * anything yet, so the authenticated shell would bounce them to /login before
* they could accept. Here we render a standalone card, and only ask for a * they could accept. The preview call is anonymous, which is what lets a
* session when there is actually an invite to redeem. * brand-new invitee see who invited them and then create their account (by
* choosing a password) without ever visiting a signup page.
*/ */
import Link from "next/link"; import Link from "next/link";
@@ -20,6 +22,7 @@ import {
CardHint, CardHint,
CardTitle, CardTitle,
ErrorState, ErrorState,
Field,
LoadingState, LoadingState,
} from "@agy/ui"; } from "@agy/ui";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
@@ -33,6 +36,8 @@ const ROLE_LABELS: Record<string, string> = {
viewer: "بیننده", viewer: "بیننده",
}; };
const MIN_PASSWORD_LENGTH = 8;
function Centered({ children }: { children: React.ReactNode }) { function Centered({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <div className="flex min-h-screen items-center justify-center p-5">
@@ -45,26 +50,29 @@ function AcceptInviteCard() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const token = params.get("token") ?? ""; const token = params.get("token") ?? "";
const { ready, user, refresh, setActiveOrganization } = useSession(); const { ready, user, login, refresh, setActiveOrganization } = useSession();
const [preview, setPreview] = React.useState<InvitePreview | null>(null); const [preview, setPreview] = React.useState<InvitePreview | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [accepting, setAccepting] = React.useState(false); const [accepting, setAccepting] = React.useState(false);
const [password, setPassword] = React.useState("");
// The invitee may not have an account yet; after they sign in or sign up we // The invitee may not have an account yet; after they sign in we send them
// send them straight back here so the token is not lost. // straight back here so the token is not lost.
const nextPath = `/accept-invite?token=${encodeURIComponent(token)}`; const nextPath = `/accept-invite?token=${encodeURIComponent(token)}`;
React.useEffect(() => { React.useEffect(() => {
if (!ready) return; if (!ready) return;
if (!token || !user) { if (!token) {
setLoading(false); setLoading(false);
return; return;
} }
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
// Deliberately called even without a session: a brand-new invitee must be
// able to see the organization and whether they already have an account.
api api
.previewInvite(token) .previewInvite(token)
.then((result) => { .then((result) => {
@@ -72,7 +80,9 @@ function AcceptInviteCard() {
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات دعوت‌نامه ناموفق بود."); setError(
err instanceof ApiError ? err.message : "دریافت اطلاعات دعوت‌نامه ناموفق بود.",
);
} }
}) })
.finally(() => { .finally(() => {
@@ -83,16 +93,24 @@ function AcceptInviteCard() {
}; };
}, [ready, token, user]); }, [ready, token, user]);
const finish = React.useCallback(
async (organizationId: string) => {
// Land the invitee inside the organization they just joined rather than
// whichever tenant happened to be active before.
setActiveOrganization(organizationId);
await refresh();
router.replace("/dashboard");
},
[refresh, router, setActiveOrganization],
);
/** Signed-in invitee: the account exists, so no password is needed. */
const onAccept = async () => { const onAccept = async () => {
setError(null); setError(null);
setAccepting(true); setAccepting(true);
try { try {
const result = await api.acceptInvite(token); const result = await api.acceptInvite(token);
// Land the invitee inside the organization they just joined rather than await finish(result.organization_id);
// whichever tenant happened to be active before.
setActiveOrganization(result.organization_id);
await refresh();
router.replace("/dashboard");
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود."); setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود.");
} finally { } finally {
@@ -100,6 +118,25 @@ function AcceptInviteCard() {
} }
}; };
/** Anonymous invitee with no account: the link creates the account. */
const onCreateAndAccept = async (event: React.FormEvent) => {
event.preventDefault();
if (!preview) return;
setError(null);
setAccepting(true);
try {
const result = await api.acceptInvite(token, password);
// Sign in with the password just chosen so the invitee lands inside the
// app instead of being bounced back to the login screen.
await login(preview.email, password);
await finish(result.organization_id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
} finally {
setAccepting(false);
}
};
if (!ready) { if (!ready) {
return ( return (
<Centered> <Centered>
@@ -119,32 +156,8 @@ function AcceptInviteCard() {
توکن دعوت در آدرس وجود ندارد. لینک کامل را از فرستنده دوباره بگیرید. توکن دعوت در آدرس وجود ندارد. لینک کامل را از فرستنده دوباره بگیرید.
</CardHint> </CardHint>
<div className="mt-5"> <div className="mt-5">
<Link href="/dashboard" className="text-xs text-accent hover:underline"> <Link href="/login" className="text-xs text-accent hover:underline">
بازگشت به داشبورد ورود به حساب
</Link>
</div>
</Card>
</Centered>
);
}
if (!user) {
return (
<Centered>
<Card className="anim-rise">
<CardTitle>دعوت به یک سازمان</CardTitle>
<CardHint>
برای پذیرش این دعوت باید وارد شوید. اگر هنوز حساب ندارید، با همان ایمیلی که
دعوت‌نامه برایش ارسال شده ثبت‌نام کنید.
</CardHint>
<div className="mt-5 flex gap-2">
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="flex-1">
<Button className="w-full">ورود</Button>
</Link>
<Link href={`/signup?next=${encodeURIComponent(nextPath)}`} className="flex-1">
<Button variant="secondary" className="w-full">
ثبت‌نام
</Button>
</Link> </Link>
</div> </div>
</Card> </Card>
@@ -162,17 +175,14 @@ function AcceptInviteCard() {
); );
} }
if (error || !preview) { if (error && !preview) {
return ( return (
<Centered> <Centered>
<Card className="anim-rise"> <Card className="anim-rise">
<ErrorState <ErrorState title="دعوت‌نامه قابل استفاده نیست" message={error} />
title="دعوت‌نامه قابل استفاده نیست"
message={error ?? "اطلاعات دعوت‌نامه در دسترس نیست."}
/>
<div className="mt-5"> <div className="mt-5">
<Link href="/dashboard" className="text-xs text-accent hover:underline"> <Link href="/login" className="text-xs text-accent hover:underline">
بازگشت به داشبورد ورود به حساب
</Link> </Link>
</div> </div>
</Card> </Card>
@@ -180,35 +190,78 @@ function AcceptInviteCard() {
); );
} }
if (!preview) return null;
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
const signedInWithInvitedEmail =
user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase();
return ( return (
<Centered> <Centered>
<Card className="anim-rise"> <Card className="anim-rise">
<CardTitle>دعوت به {preview.organization_name}</CardTitle> <CardTitle>دعوت به {preview.organization_name}</CardTitle>
<CardHint> <CardHint>
برای <span className="ltr-inline">{preview.email}</span> با نقش{" "} برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
{ROLE_LABELS[preview.role] ?? preview.role} دعوت شده‌اید. شده‌اید.
</CardHint> </CardHint>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Badge tone="info">{ROLE_LABELS[preview.role] ?? preview.role}</Badge> <Badge tone="info">{roleLabel}</Badge>
{preview.already_member && <Badge tone="neutral">شما از قبل عضو هستید</Badge>} {preview.already_member && <Badge tone="neutral">شما از قبل عضو هستید</Badge>}
</div> </div>
<div className="mt-5"> {signedInWithInvitedEmail ? (
<Button className="w-full" loading={accepting} onClick={onAccept}> <div className="mt-5">
{preview.already_member ? "ورود به سازمان" : "پذیرش دعوت"} <Button className="w-full" loading={accepting} onClick={onAccept}>
</Button> {preview.already_member ? "ورود به سازمان" : "پذیرش دعوت"}
</div> </Button>
</div>
) : !user && !preview.has_account ? (
// Brand-new invitee: the link creates the account. The password they
// choose here becomes their login credential.
<form onSubmit={onCreateAndAccept} className="mt-5 flex flex-col gap-4">
<Field
label="رمز عبور برای ساخت حساب"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(e) => setPassword(e.target.value)}
hint={`حداقل ${MIN_PASSWORD_LENGTH} کاراکتر.`}
/>
{error && <ErrorState title="ساخت حساب ناموفق" message={error} />}
<Button type="submit" loading={accepting}>
ساخت حساب و پیوستن
</Button>
</form>
) : (
// An account already exists for the invited email: the link must
// never act as a password reset, so the invitee logs in first.
<div className="mt-5 flex flex-col gap-3">
<CardHint>
برای این ایمیل از قبل حساب ساخته شده است. برای پذیرش دعوت با همان حساب
وارد شوید.
</CardHint>
{error && <ErrorState title="پذیرش دعوت ناموفق" message={error} />}
<Link href={`/login?next=${encodeURIComponent(nextPath)}`}>
<Button className="w-full">ورود و پذیرش دعوت</Button>
</Link>
</div>
)}
<p className="mt-4 text-xs text-slate-400"> {user && !signedInWithInvitedEmail && (
با حساب دیگری وارد شده‌اید؟{" "} <p className="mt-4 text-xs text-slate-400">
<Link با حساب دیگری وارد شده‌اید؟{" "}
href={`/login?next=${encodeURIComponent(nextPath)}`} <Link
className="text-accent hover:underline" href={`/login?next=${encodeURIComponent(nextPath)}`}
> className="text-accent hover:underline"
تعویض حساب >
</Link> تعویض حساب
</p> </Link>
</p>
)}
</Card> </Card>
</Centered> </Centered>
); );
+2 -15
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
@@ -25,13 +24,6 @@ export default function LoginPage() {
const [password, setPassword] = React.useState(""); const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
// Read once on mount so the "no account?" link carries the invite token along.
const [nextPath, setNextPath] = React.useState<string | null>(null);
React.useEffect(() => {
const next = new URLSearchParams(window.location.search).get("next");
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
}, []);
React.useEffect(() => { React.useEffect(() => {
if (ready && user) router.replace(nextDestination("/dashboard")); if (ready && user) router.replace(nextDestination("/dashboard"));
@@ -85,13 +77,8 @@ export default function LoginPage() {
</form> </form>
<p className="mt-4 text-xs text-slate-400"> <p className="mt-4 text-xs text-slate-400">
حساب ندارید؟{" "} حساب ندارید؟ دسترسی فقط با دعوت انجام می‌شود؛ لینک دعوت را از مدیر
<Link سازمان خود بگیرید و آن را باز کنید.
href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : "/signup"}
className="text-accent hover:underline"
>
ثبت‌نام کنید
</Link>
</p> </p>
</Card> </Card>
</div> </div>
+30 -1
View File
@@ -2,11 +2,18 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { canCreateOrganization } from "@agy/contracts";
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui"; import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
/** First-run flow: create the user's first organization. */ /**
* First-run flow: create an organization.
*
* Only the platform owner and the managers they create may do this. A plain
* account reaches this screen with nothing to do but wait for an invite, and
* saying so is more honest than showing a form the API would reject.
*/
export default function OnboardingPage() { export default function OnboardingPage() {
const router = useRouter(); const router = useRouter();
const { ready, user, organizations, refresh } = useSession(); const { ready, user, organizations, refresh } = useSession();
@@ -41,6 +48,28 @@ export default function OnboardingPage() {
); );
} }
if (user && !canCreateOrganization(user.platform_role)) {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md">
<CardTitle>در انتظار دعوت</CardTitle>
<CardHint>
حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران
عادی فعال نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت
برایتان بفرستد.
</CardHint>
{organizations.length > 0 && (
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/dashboard")}>
بازگشت به داشبورد
</Button>
</div>
)}
</Card>
</div>
);
}
return ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md"> <Card className="anim-rise w-full max-w-md">
-104
View File
@@ -1,104 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { useSession } from "@/lib/session";
/**
* Where to land after signing up. Only same-origin paths are honoured, so a
* crafted `?next=https://evil.example` cannot turn the form into an open
* redirect.
*/
function nextDestination(fallback: string): string {
if (typeof window === "undefined") return fallback;
const next = new URLSearchParams(window.location.search).get("next");
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
}
export default function SignupPage() {
const router = useRouter();
const { signup } = useSession();
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
// Read once on mount so the "already registered?" link carries the invite token.
const [nextPath, setNextPath] = React.useState<string | null>(null);
React.useEffect(() => {
const next = new URLSearchParams(window.location.search).get("next");
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await signup(email, password, name);
router.replace(nextDestination("/onboarding"));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-sm">
<CardTitle>ساخت حساب</CardTitle>
<CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint>
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
<Field
label="نام"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="نام شما"
/>
<Field
label="ایمیل"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="رمز عبور"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={8}
hint="حداقل ۸ کاراکتر."
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <ErrorState title="ثبت‌نام ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ثبت‌نام
</Button>
</form>
<p className="mt-4 text-xs text-slate-400">
قبلاً ثبت‌نام کرده‌اید؟{" "}
<Link
href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : "/login"}
className="text-accent hover:underline"
>
ورود
</Link>
</p>
</Card>
</div>
);
}
+17 -7
View File
@@ -215,13 +215,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button> </button>
))} ))}
<div className="my-1 border-t border-surface-border" /> <div className="my-1 border-t border-surface-border" />
<Link {(user?.is_platform_owner || user?.is_manager) && (
href="/onboarding" <Link
className="transition-colors block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised" href="/onboarding"
onClick={() => setMenuOpen(false)} className="transition-colors block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised"
> onClick={() => setMenuOpen(false)}
ساخت سازمان جدید >
</Link> ساخت سازمان جدید
</Link>
)}
</div> </div>
)} )}
</div> </div>
@@ -245,6 +247,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{user?.name || user?.email} {user?.name || user?.email}
</span> </span>
{user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>} {user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>}
{user?.is_manager && <Badge tone="success">مدیر</Badge>}
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="anim-slide-up absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5"> <div className="anim-slide-up absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
@@ -255,6 +258,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<p className="truncate text-xs text-slate-500">{user?.email}</p> <p className="truncate text-xs text-slate-500">{user?.email}</p>
</div> </div>
<div className="my-1 border-t border-surface-border" /> <div className="my-1 border-t border-surface-border" />
<Link
href="/account"
onClick={() => setUserMenuOpen(false)}
className="transition-colors block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
>
حساب کاربری و رمز عبور
</Link>
<Link <Link
href="/settings" href="/settings"
onClick={() => setUserMenuOpen(false)} onClick={() => setUserMenuOpen(false)}
+38 -8
View File
@@ -148,17 +148,16 @@ async function fetchHealth<T>(
export const api = { export const api = {
health: () => apiRequest<{ status: string }>("/health"), health: () => apiRequest<{ status: string }>("/health"),
readiness: () => apiRequest<import("@agy/contracts").ReadinessResponse>("/health/ready"), readiness: () => apiRequest<import("@agy/contracts").ReadinessResponse>("/health/ready"),
signup: (body: { email: string; password: string; name?: string }) => // There is no `signup`: accounts are created by the platform owner or by
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/signup", { // redeeming an invite link (see `acceptInvite`).
method: "POST",
body,
}),
login: (body: { email: string; password: string }) => login: (body: { email: string; password: string }) =>
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/login", { apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/login", {
method: "POST", method: "POST",
body, body,
}), }),
logout: () => apiRequest<void>("/api/auth/logout", { method: "POST" }), logout: () => apiRequest<void>("/api/auth/logout", { method: "POST" }),
changePassword: (body: { current_password: string; new_password: string }) =>
apiRequest<void>("/api/auth/change-password", { method: "POST", body }),
me: () => apiRequest<import("@agy/contracts").MeResponse>("/api/auth/me"), me: () => apiRequest<import("@agy/contracts").MeResponse>("/api/auth/me"),
listOrganizations: () => listOrganizations: () =>
apiRequest<import("@agy/contracts").OrganizationWithRole[]>("/api/organizations"), apiRequest<import("@agy/contracts").OrganizationWithRole[]>("/api/organizations"),
@@ -175,15 +174,17 @@ export const api = {
body, body,
}), }),
// Redeeming an invite is intentionally *not* org-scoped: the invitee is not a // Redeeming an invite is intentionally *not* org-scoped: the invitee is not a
// member yet, so the token is what authorizes the call. // member yet, so the token is what authorizes the call. `password` is only
// sent when the invitee has no account yet — it becomes their password and
// the account is created for the invited email.
previewInvite: (token: string) => previewInvite: (token: string) =>
apiRequest<import("@agy/contracts").InvitePreview>( apiRequest<import("@agy/contracts").InvitePreview>(
`/api/invites/${encodeURIComponent(token)}`, `/api/invites/${encodeURIComponent(token)}`,
), ),
acceptInvite: (token: string) => acceptInvite: (token: string, password?: string) =>
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", { apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
method: "POST", method: "POST",
body: { token }, body: password ? { token, password } : { token },
}), }),
updateMember: ( updateMember: (
orgId: string, orgId: string,
@@ -567,6 +568,35 @@ export const api = {
{ method: "POST" }, { method: "POST" },
), ),
// --- accounts (platform owner only) --------------------------------------
// The owner creates every manager account; a plain user joins by invitation.
adminUsers: () => apiRequest<import("@agy/contracts").AdminUser[]>("/api/admin/users"),
adminCreateUser: (body: import("@agy/contracts").CreateUserRequest) =>
apiRequest<import("@agy/contracts").AdminUserCreated>("/api/admin/users", {
method: "POST",
body,
}),
adminUpdateUser: (userId: string, body: import("@agy/contracts").UpdateUserRequest) =>
apiRequest<import("@agy/contracts").AdminUser>(
`/api/admin/users/${encodeURIComponent(userId)}`,
{ method: "PATCH", body },
),
adminDeleteUser: (userId: string) =>
apiRequest<import("@agy/contracts").DeleteUserResult>(
`/api/admin/users/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
),
adminResetUserPassword: (userId: string) =>
apiRequest<import("@agy/contracts").AdminUserCreated>(
`/api/admin/users/${encodeURIComponent(userId)}/reset-password`,
{ method: "POST" },
),
adminTransferOwnership: (userId: string) =>
apiRequest<import("@agy/contracts").AdminUser>("/api/admin/transfer-ownership", {
method: "POST",
body: { user_id: userId },
}),
// --- error console -------------------------------------------------------- // --- error console --------------------------------------------------------
// The console exists so a failure that already happened can be diagnosed // The console exists so a failure that already happened can be diagnosed
// without reproducing it. Grouped by fingerprint, so a bug that fired a // without reproducing it. Grouped by fingerprint, so a bug that fired a
+1
View File
@@ -32,6 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
*/ */
export const ADMIN_ITEMS: NavItem[] = [ export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" }, { href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
{ href: "/admin/users", label: "کاربران و مدیران", hint: "ساخت مدیر و نقش‌ها" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy", hint: "ورود و پول چرخشی" }, { href: "/admin/agy-accounts", label: "اکانت‌های agy", hint: "ورود و پول چرخشی" },
{ href: "/admin/models", label: "مدل‌ها", hint: "قابلیت و قیمت" }, { href: "/admin/models", label: "مدل‌ها", hint: "قابلیت و قیمت" },
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" }, { href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
+1 -13
View File
@@ -21,7 +21,6 @@ interface SessionValue {
organizations: OrganizationWithRole[]; organizations: OrganizationWithRole[];
activeOrganization: OrganizationWithRole | null; activeOrganization: OrganizationWithRole | null;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string, name: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
setActiveOrganization: (orgId: string) => void; setActiveOrganization: (orgId: string) => void;
@@ -102,16 +101,6 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
[applyMe], [applyMe],
); );
const signup = React.useCallback(
async (email: string, password: string, name: string) => {
const result = await api.signup({ email, password, name });
setStoredToken(result.access_token);
setToken(result.access_token);
applyMe(await api.me());
},
[applyMe],
);
const logout = React.useCallback(async () => { const logout = React.useCallback(async () => {
try { try {
await api.logout(); await api.logout();
@@ -144,12 +133,11 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
organizations, organizations,
activeOrganization, activeOrganization,
login, login,
signup,
logout, logout,
refresh, refresh,
setActiveOrganization, setActiveOrganization,
}), }),
[ready, token, user, organizations, activeOrganization, login, signup, logout, refresh, setActiveOrganization], [ready, token, user, organizations, activeOrganization, login, logout, refresh, setActiveOrganization],
); );
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
+66 -32
View File
@@ -1,7 +1,7 @@
/** /**
* The accept-invite screen is the only place an invited user can turn a link * The accept-invite screen is now the *only* door into the product: signup is
* into a membership, so its states matter: missing token, signed out, preview * closed, so the link must both preview the invitation to a stranger and let
* failure, and the happy path. * them create their account by choosing a password. Every state matters.
*/ */
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
session: { session: {
ready: true, ready: true,
user: null as { id: string; email: string } | null, user: null as { id: string; email: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(), setActiveOrganization: vi.fn(),
}, },
@@ -65,6 +66,16 @@ vi.mock("@/lib/api", () => ({
import AcceptInvitePage from "@/app/accept-invite/page"; import AcceptInvitePage from "@/app/accept-invite/page";
const PREVIEW = {
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
has_account: false,
};
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.token = "tok-123"; mocks.token = "tok-123";
@@ -73,12 +84,56 @@ beforeEach(() => {
}); });
describe("accept-invite", () => { describe("accept-invite", () => {
it("asks a signed-out visitor to authenticate and keeps the token in the link", () => { it("previews the invite to a signed-out stranger instead of bouncing to login", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />); render(<AcceptInvitePage />);
expect(screen.getByText("دعوت به یک سازمان")).toBeInTheDocument(); // The whole point of the closed-signup flow: a brand-new invitee sees who
const login = screen.getByRole("link", { name: "ورود" }); // invited them without having an account yet.
// Losing the token here would strand the invitee on the dashboard. expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
});
it("offers a set-password form when no account exists for the invited email", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />);
expect(await screen.findByLabelText(/رمز عبور برای ساخت حساب/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ساخت حساب و پیوستن" })).toBeInTheDocument();
});
it("creates the account, signs in, and joins on submit", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
role: "member",
});
render(<AcceptInvitePage />);
fireEvent.change(await screen.findByLabelText(/رمز عبور برای ساخت حساب/), {
target: { value: "password123" },
});
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
await waitFor(() =>
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"),
);
// The password just chosen becomes the login credential, so the invitee
// lands inside the app rather than back at the login screen.
await waitFor(() =>
expect(mocks.session.login).toHaveBeenCalledWith("newbie@example.com", "password123"),
);
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
});
it("asks an invitee whose email already has an account to log in first", async () => {
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
render(<AcceptInvitePage />);
// A leaked link must never act as a password reset.
const login = await screen.findByRole("link", { name: "ورود و پذیرش دعوت" });
expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe( expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe(
"/login?next=/accept-invite?token=tok-123", "/login?next=/accept-invite?token=tok-123",
); );
@@ -86,36 +141,23 @@ describe("accept-invite", () => {
it("shows the organization and role to the signed-in invitee", async () => { it("shows the organization and role to the signed-in invitee", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" }; mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({ mocks.previewInvite.mockResolvedValue({ ...PREVIEW, role: "manager", has_account: true });
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "manager",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
render(<AcceptInvitePage />); render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument(); expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
}); });
it("surfaces the server's reason when the invite is unusable", async () => { it("surfaces the server's reason when the invite is unusable", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockRejectedValue( mocks.previewInvite.mockRejectedValue(
new mocks.FakeApiError( new mocks.FakeApiError(409, "conflict", "این دعوت‌نامه قبلاً پذیرفته شده است."),
403,
"permission_denied",
"این دعوت‌نامه برای ایمیل دیگری صادر شده است.",
),
); );
render(<AcceptInvitePage />); render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت‌نامه قابل استفاده نیست")).toBeInTheDocument(); expect(await screen.findByText("دعوت‌نامه قابل استفاده نیست")).toBeInTheDocument();
expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument(); expect(screen.getByText(/قبلاً پذیرفته/)).toBeInTheDocument();
}); });
it("reports a missing token instead of calling the API", () => { it("reports a missing token instead of calling the API", () => {
@@ -128,14 +170,7 @@ describe("accept-invite", () => {
it("activates the joined organization and redirects after accepting", async () => { it("activates the joined organization and redirects after accepting", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" }; mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({ mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
mocks.acceptInvite.mockResolvedValue({ mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1", organization_id: "org-1",
organization_name: "Acme", organization_name: "Acme",
@@ -146,7 +181,6 @@ describe("accept-invite", () => {
fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" })); fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" }));
await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123")); await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123"));
// Landing in the new tenant, not whichever one was active before.
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1")); await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard")); await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
}); });
+11 -5
View File
@@ -162,15 +162,19 @@ CORS را تنظیم می‌کند.
Next.js App Router با دو ناحیهٔ مسیر: Next.js App Router با دو ناحیهٔ مسیر:
**ناحیهٔ عمومی (بدون احراز هویت):** `/login`, `/signup`, `/onboarding` **ناحیهٔ عمومی (بدون احراز هویت):** `/login`, `/accept-invite`, `/onboarding`
> `/signup` وجود ندارد: ثبت‌نام عمومی بسته است و حساب‌ها فقط توسط مالک سامانه یا
> با پذیرش لینک دعوت ساخته می‌شوند.
**ناحیهٔ اپلیکیشن (`(app)` — نیازمند نشست):** **ناحیهٔ اپلیکیشن (`(app)` — نیازمند نشست):**
`/dashboard`, `/workforce`, `/workforce/[id]`, `/manager`, `/tasks`, `/dashboard`, `/workforce`, `/workforce/[id]`, `/manager`, `/tasks`,
`/tasks/[id]`, `/projects`, `/projects/[id]`, `/knowledge`, `/billing`, `/tasks/[id]`, `/projects`, `/projects/[id]`, `/knowledge`, `/billing`,
`/settings`, `/traces` `/settings`, `/traces`
**ناحیهٔ مالک سامانه:** `/admin`, `/admin/providers`, `/admin/models`, **ناحیهٔ مالک سامانه:** `/admin`, `/admin/users`, `/admin/agy-accounts`,
`/admin/routing`, `/admin/rate-cards`, `/admin/organizations`, `/admin/errors` `/admin/models`, `/admin/routing`, `/admin/rate-cards`, `/admin/organizations`,
`/admin/usage`, `/admin/errors`
**دو نقطهٔ اتصال مرکزی:** **دو نقطهٔ اتصال مرکزی:**
@@ -236,11 +240,13 @@ storage/artifact، tool call) یک رکورد در ledger می‌گذارد با
## ۹. تست ## ۹. تست
**بک‌اند (`apps/api/tests/`)** — ۲۱۶ تست: **بک‌اند (`apps/api/tests/`)** — ۳۰۰ تست:
| فایل | چه چیزی را می‌سنجد | | فایل | چه چیزی را می‌سنجد |
| --- | --- | | --- | --- |
| `test_auth.py` | ثبت‌نام، ورود، نشست، نشت‌نداشتن hash | | `test_auth.py` | ورود، نشست، تغییر رمز، بسته‌بودن ثبت‌نام، نشت‌نداشتن hash |
| `test_platform_hierarchy.py` | یکتایی مالک، گارد ساخت سازمان، ساخت مدیر توسط مالک، انتقال سازمان هنگام تنزل/حذف مدیر، انتقال مالکیت |
| `test_invites.py` | دعوت، پیش‌نمایش ناشناس، ساخت حساب از لینک، رد reset رمز با لینک |
| `test_tenant_isolation.py` | جداسازی مستأجر، ۴۰۴ به‌جای ۴۰۳، دعوت | | `test_tenant_isolation.py` | جداسازی مستأجر، ۴۰۴ به‌جای ۴۰۳، دعوت |
| `test_admin_control_plane.py` | دسترسی مالک سامانه، mask شدن کلید | | `test_admin_control_plane.py` | دسترسی مالک سامانه، mask شدن کلید |
| `test_platform_rules.py` | برچسب دامنه، ضدتزریق host، ضدSSRF، پورت‌ها | | `test_platform_rules.py` | برچسب دامنه، ضدتزریق host، ضدSSRF، پورت‌ها |
+35 -3
View File
@@ -34,7 +34,7 @@
- **چگونه:** argon2-cffi (برندهٔ PHC، مقاوم به GPU)؛ هیچ‌جا `md5`/`sha1`/`sha256` - **چگونه:** argon2-cffi (برندهٔ PHC، مقاوم به GPU)؛ هیچ‌جا `md5`/`sha1`/`sha256`
خام برای رمز استفاده نشده است. خام برای رمز استفاده نشده است.
- **تست مدافع:** - **تست مدافع:**
- `test_signup_never_returns_password_hash` — پاسخ ثبت‌نام هیچ `password_hash` ندارد. - `test_signup_helper_never_returns_password_hash` — هیچ پاسخی `password_hash` ندارد.
- `test_login_fails_with_wrong_password` — تأیید مسیر verify. - `test_login_fails_with_wrong_password` — تأیید مسیر verify.
### ✅ JWT secret از env می‌آید و در production اعتبارسنجی می‌شود ### ✅ JWT secret از env می‌آید و در production اعتبارسنجی می‌شود
@@ -120,9 +120,41 @@
### ✅ تست منفی برای unauthorized / forbidden ### ✅ تست منفی برای unauthorized / forbidden
- **محل:** `tests/test_admin_control_plane.py`، `tests/test_auth.py`، - **محل:** `tests/test_admin_control_plane.py`، `tests/test_auth.py`،
`tests/test_tenant_isolation.py`. `tests/test_tenant_isolation.py`، `tests/test_platform_hierarchy.py`.
- **تست مدافع:** ۴ تست ۴۰۱ در `test_auth.py`، ۳ تست ۴۰۳/۴۰۴ در - **تست مدافع:** ۴ تست ۴۰۱ در `test_auth.py`، ۳ تست ۴۰۳/۴۰۴ در
`test_admin_control_plane.py`، ۶ تست cross-tenant در `test_tenant_isolation.py`. `test_admin_control_plane.py`، ۶ تست cross-tenant در `test_tenant_isolation.py`،
و کل `test_platform_hierarchy.py` (۲۲ تست).
### ✅ سلسله‌مراتب پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک
- **محل:** `apps/api/app/domain/identity/models.py` (`PlatformRole`)،
`apps/api/app/infrastructure/db/models.py` (ایندکس یکتا)،
`apps/api/app/interfaces/http/routers/admin_users.py`،
`apps/api/app/application/services/platform_users.py`.
- **چگونه:** چهار قاعده در کد و دیتابیس قفل شده‌اند:
1. **دقیقاً یک مالک.** ایندکس یکتای جزئی
(`uq_single_platform_owner ... WHERE platform_role = 'owner'`) در سطح دیتابیس
جلوی مالک دوم را می‌گیرد؛ API هم ساخت/تنزل مالک را رد می‌کند. جابه‌جایی صندلی
فقط با `POST /admin/transfer-ownership` و در یک تراکنش است (اول تنزل مالک فعلی،
بعد ارتقای جانشین — تا ایندکس هرگز دو مالک نبیند).
2. **ثبت‌نام عمومی وجود ندارد.** حساب‌ها فقط از `POST /admin/users` (مالک) یا از
پذیرش دعوت ساخته می‌شوند. مسیرهای `/api/invites/*` تنها فراخوانی‌های ناشناس
مجاز در لایهٔ RPC هستند (`router.py`)، چون خود توکن دعوت مجوز است.
3. **لینک دعوت هرگز reset رمز نمی‌شود.** اگر برای ایمیل دعوت‌شده حساب وجود داشته
باشد، پذیرش ناشناس با رمز `403` می‌گیرد و کاربر باید اول وارد شود
(`has_account` در preview به UI می‌گوید کدام مسیر است).
4. **سازمان‌های مدیرِ رفته به مالک می‌رسند.** تنزل یا حذف مدیر،
`owner_user_id` و عضویت‌ها را جابه‌جا می‌کند؛ داده هرگز یتیم یا حذف نمی‌شود.
- **تست مدافع:** `tests/test_platform_hierarchy.py`:
- `test_only_one_platform_owner_may_exist` — درج مستقیم مالک دوم `IntegrityError`.
- `test_owner_cannot_be_created_through_the_api` / `test_owner_account_is_immutable_from_the_api`
- `test_transfer_ownership_moves_the_seat_and_keeps_exactly_one`
- `test_plain_user_cannot_create_an_organization`
- `test_manager_cannot_create_accounts` / `test_manager_cannot_reach_the_admin_console`
- `test_demoting_a_manager_transfers_organizations_to_the_owner`
- `test_deleting_a_manager_transfers_organizations_and_memberships`
- `test_a_link_cannot_reset_an_existing_account` (در `test_invites.py`)
- `test_every_seat_move_is_audited`
### ✅ کاربر عادی مدل/provider خام نمی‌بیند ### ✅ کاربر عادی مدل/provider خام نمی‌بیند
+81
View File
@@ -10,6 +10,30 @@ export type Role = "owner" | "admin" | "manager" | "member" | "viewer";
export type MembershipStatus = "active" | "invited" | "suspended"; export type MembershipStatus = "active" | "invited" | "suspended";
/**
* Platform-wide role — a different axis from the in-organization `Role`.
*
* - `owner` — the single owner of the whole system. Exactly one exists; only
* they may enter the admin console, create or demote managers, and move the
* owner seat.
* - `manager` — an account the owner creates. May build its own organizations
* and join other managers' organizations by invitation.
* - `user` — a plain account. Cannot create organizations; works only inside
* organizations it was invited to.
*/
export type PlatformRole = "owner" | "manager" | "user";
/** Roles that may create an organization. */
export function canCreateOrganization(role: PlatformRole | undefined | null): boolean {
return role === "owner" || role === "manager";
}
export const PLATFORM_ROLE_LABELS: Record<PlatformRole, string> = {
owner: "مالک سامانه",
manager: "مدیر",
user: "کاربر",
};
/** /**
* Provider kinds the AI Control Plane can talk to. agy (Google Antigravity) * Provider kinds the AI Control Plane can talk to. agy (Google Antigravity)
* is the only one — the platform no longer has a provider picker. * is the only one — the platform no longer has a provider picker.
@@ -160,6 +184,10 @@ export interface User {
email: string; email: string;
name: string; name: string;
avatar_url?: string | null; avatar_url?: string | null;
/** Platform-wide role. Exactly one account in the system is `owner`. */
platform_role: PlatformRole;
/** True for the manager tier (may create organizations). */
is_manager: boolean;
is_platform_owner: boolean; is_platform_owner: boolean;
} }
@@ -202,6 +230,11 @@ export interface InvitePreview {
expires_at: string; expires_at: string;
/** True when the signed-in user is already a member of that organization. */ /** True when the signed-in user is already a member of that organization. */
already_member: boolean; already_member: boolean;
/**
* True when an account exists for the invited email. The invitee then logs
* in first; setting a password anonymously is only for brand-new accounts.
*/
has_account: boolean;
} }
export interface AcceptInviteResult { export interface AcceptInviteResult {
@@ -305,6 +338,54 @@ export interface AdminUsageRow {
total_price: number; total_price: number;
} }
/* Account administration (platform owner only) */
/** One row in the owner's account list. */
export interface AdminUser {
id: string;
email: string;
name: string;
platform_role: PlatformRole;
is_manager: boolean;
is_platform_owner: boolean;
is_active: boolean;
created_at: string;
/** How many organizations this account owns — what moves if it is demoted. */
owned_organizations: number;
}
/**
* A created account (or a password reset). `temp_password` is shown exactly
* once and never retrievable again.
*/
export interface AdminUserCreated {
user: AdminUser;
temp_password: string;
}
export interface CreateUserRequest {
email: string;
name?: string;
/** `owner` is refused: the seat moves only through transfer-ownership. */
platform_role: "manager" | "user";
}
export interface UpdateUserRequest {
platform_role?: "manager" | "user";
is_active?: boolean;
}
export interface OrganizationTransfer {
organization_id: string;
organization_name: string;
}
export interface DeleteUserResult {
deleted_user_id: string;
/** Organizations that moved to the platform owner. */
transferred_organizations: OrganizationTransfer[];
}
export interface PreviewDomain { export interface PreviewDomain {
id: string; id: string;
host: string; host: string;