- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки. - Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок инициализации (показ admin-only после initAuthSession, currentUser). - GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool). Команды и роли - Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды в модалке, только команды с активным членством; API team_id в details/ update. - GET /api/v1/teams?membership=active для списка «своих» команд. - Форма роли: сегмент Team, панель выбора команды только при Team и не при с
259 lines
9.3 KiB
Python
259 lines
9.3 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from jose import JWTError, jwt
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
hash_password,
|
|
verify_password,
|
|
)
|
|
from app.deps.auth import get_current_user_id
|
|
from app.db.pool import get_pool
|
|
from app.services.account_status import clear_expired_temporary_ban
|
|
from app.services.founder_policy import account_group_from_role
|
|
from app.services.avatar_storage import avatar_urls
|
|
from app.schemas.auth import (
|
|
ForgotPasswordRequest,
|
|
LoginRequest,
|
|
RefreshRequest,
|
|
RegisterRequest,
|
|
ResetPasswordRequest,
|
|
TokenPairResponse,
|
|
)
|
|
from app.schemas.profile import ProfileMeResponse
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
@router.post("/register", response_model=TokenPairResponse)
|
|
async def register(payload: RegisterRequest) -> TokenPairResponse:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
user_count = await conn.fetchval("select count(*)::int from users")
|
|
exists = await conn.fetchval(
|
|
"select id from users where email=$1 or username=$2",
|
|
payload.email,
|
|
payload.username,
|
|
)
|
|
if exists:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User with this email or username already exists",
|
|
)
|
|
is_first = int(user_count or 0) == 0
|
|
user_id = await conn.fetchval(
|
|
"""
|
|
insert into users (username, email, password_hash, full_name, role, is_founder)
|
|
values ($1, $2, $3, $4, $5, $6)
|
|
returning id::text
|
|
""",
|
|
payload.username,
|
|
payload.email,
|
|
hash_password(payload.password),
|
|
payload.full_name,
|
|
"root" if is_first else "user",
|
|
is_first,
|
|
)
|
|
return TokenPairResponse(
|
|
access_token=create_access_token(user_id),
|
|
refresh_token=create_refresh_token(user_id),
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=TokenPairResponse)
|
|
async def login(payload: LoginRequest) -> TokenPairResponse:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
select
|
|
id::text as id,
|
|
password_hash
|
|
from users
|
|
where email=$1 or username=$1
|
|
""",
|
|
payload.login,
|
|
)
|
|
if not row or not verify_password(payload.password, row["password_hash"]):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
await clear_expired_temporary_ban(conn, row["id"])
|
|
row2 = await conn.fetchrow(
|
|
"""
|
|
select
|
|
id::text as id,
|
|
coalesce(is_active, true) as is_active,
|
|
deleted_at,
|
|
ban_until,
|
|
coalesce(ban_reason, '') as ban_reason
|
|
from users
|
|
where id = $1::uuid
|
|
""",
|
|
row["id"],
|
|
)
|
|
if not row2:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
if row2["deleted_at"] is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Account removed from directory",
|
|
)
|
|
now = datetime.now(tz=timezone.utc)
|
|
if row2["ban_until"] is not None and row2["ban_until"] > now:
|
|
br = str(row2["ban_reason"] or "").strip()
|
|
msg = f"Account suspended until {row2['ban_until'].isoformat()}."
|
|
if br:
|
|
msg = f"{msg} Reason: {br}"
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=msg)
|
|
if not row2["is_active"]:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account disabled")
|
|
return TokenPairResponse(
|
|
access_token=create_access_token(row2["id"]),
|
|
refresh_token=create_refresh_token(row2["id"]),
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenPairResponse)
|
|
async def refresh(payload: RefreshRequest) -> TokenPairResponse:
|
|
settings = get_settings()
|
|
try:
|
|
data = jwt.decode(payload.refresh_token, settings.app_secret_key, algorithms=["HS256"])
|
|
except JWTError as exc:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
|
if data.get("type") != "refresh":
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
|
|
user_id = str(data.get("sub", ""))
|
|
if not user_id:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await clear_expired_temporary_ban(conn, user_id)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
select
|
|
coalesce(is_active, true) as is_active,
|
|
deleted_at,
|
|
ban_until,
|
|
coalesce(ban_reason, '') as ban_reason
|
|
from users
|
|
where id = $1::uuid
|
|
""",
|
|
user_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
|
|
if row["deleted_at"] is not None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account removed from directory")
|
|
now = datetime.now(tz=timezone.utc)
|
|
if row["ban_until"] is not None and row["ban_until"] > now:
|
|
br = str(row["ban_reason"] or "").strip()
|
|
msg = f"Account suspended until {row['ban_until'].isoformat()}."
|
|
if br:
|
|
msg = f"{msg} Reason: {br}"
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=msg)
|
|
if not row["is_active"]:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account disabled")
|
|
return TokenPairResponse(
|
|
access_token=create_access_token(user_id),
|
|
refresh_token=create_refresh_token(user_id),
|
|
)
|
|
|
|
|
|
@router.post("/forgot-password")
|
|
async def forgot_password(payload: ForgotPasswordRequest) -> dict[str, str]:
|
|
expires_at = datetime.now(tz=timezone.utc) + timedelta(minutes=30)
|
|
token = ""
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
user_id = await conn.fetchval(
|
|
"select id from users where email=$1 or username=$1",
|
|
payload.login,
|
|
)
|
|
if user_id:
|
|
token = str(uuid4())
|
|
await conn.execute(
|
|
"""
|
|
insert into password_reset_tokens (user_id, token, expires_at)
|
|
values ($1, $2, $3)
|
|
""",
|
|
user_id,
|
|
token,
|
|
expires_at,
|
|
)
|
|
return {"message": "If account exists, reset link has been generated", "token_for_dev": token}
|
|
|
|
|
|
@router.post("/reset-password")
|
|
async def reset_password(payload: ResetPasswordRequest) -> dict[str, str]:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
select t.id, t.user_id
|
|
from password_reset_tokens t
|
|
where t.token=$1 and t.used_at is null and t.expires_at > now()
|
|
""",
|
|
payload.token,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired token")
|
|
await conn.execute(
|
|
"update users set password_hash=$1 where id=$2",
|
|
hash_password(payload.new_password),
|
|
row["user_id"],
|
|
)
|
|
await conn.execute("update password_reset_tokens set used_at=now() where id=$1", row["id"])
|
|
return {"message": "Password updated"}
|
|
|
|
|
|
def _avatar_cache_ts(updated_at) -> str | None:
|
|
if not updated_at:
|
|
return None
|
|
return str(int(updated_at.timestamp()))
|
|
|
|
|
|
@router.get("/me", response_model=ProfileMeResponse)
|
|
async def me(user_id: str = Depends(get_current_user_id)) -> ProfileMeResponse:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
select
|
|
id::text as id,
|
|
username,
|
|
email,
|
|
full_name,
|
|
role,
|
|
coalesce(is_founder, false) as is_founder,
|
|
coalesce(profile_bio, '') as profile_bio,
|
|
avatar_ext,
|
|
avatar_updated_at
|
|
from users where id=$1::uuid
|
|
""",
|
|
user_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
urls = avatar_urls(row["id"], row.get("avatar_ext"), _avatar_cache_ts(row.get("avatar_updated_at")))
|
|
rrole = row["role"] or "user"
|
|
return ProfileMeResponse(
|
|
id=row["id"],
|
|
username=row["username"] or "",
|
|
email=row["email"] or "",
|
|
full_name=row["full_name"] or "",
|
|
role=rrole,
|
|
account_group=account_group_from_role(rrole),
|
|
is_founder=bool(row.get("is_founder")),
|
|
profile_bio=str(row.get("profile_bio") or ""),
|
|
avatar_ext=row.get("avatar_ext"),
|
|
**urls,
|
|
)
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(_: dict | None = None) -> dict[str, str]:
|
|
return {"message": "Logged out"}
|