ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -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
|
||||
|
||||
@@ -8,8 +14,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, ConflictError
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.core.errors import AuthenticationError, ValidationError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import User
|
||||
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"])
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
name: str = Field(default="", max_length=160)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
@@ -40,9 +44,19 @@ class UserOut(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
id: str
|
||||
name: str
|
||||
@@ -67,40 +81,12 @@ def _to_user_out(user: User) -> UserOut:
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
platform_role=user.platform_role.value,
|
||||
is_manager=user.is_manager,
|
||||
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)
|
||||
async def login(
|
||||
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)
|
||||
async def me(
|
||||
user: User = Depends(get_current_user),
|
||||
|
||||
Reference in New Issue
Block a user