diff --git a/api/src/api/endpoints/me.py b/api/src/api/endpoints/me.py index 8ca157a..6826408 100644 --- a/api/src/api/endpoints/me.py +++ b/api/src/api/endpoints/me.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends from src.api.dependencies.auth import Principal, current_user from src.schemas.directories import RegionalOfficeBrief -from src.schemas.me import MeResponse +from src.schemas.me import MeResponse, RoleBrief router = APIRouter(tags=["me"]) @@ -28,5 +28,6 @@ async def get_me(principal: Annotated[Principal, Depends(current_user)]) -> MeRe regional_office=RegionalOfficeBrief.model_validate(office), business_segment=user.business_segment, roles=sorted(principal.roles), + role_details=[RoleBrief(code=code, name=name) for code, name in principal.role_names], permissions=sorted(principal.permissions), ) diff --git a/api/src/repositories/users.py b/api/src/repositories/users.py index aa07668..7577c4a 100644 --- a/api/src/repositories/users.py +++ b/api/src/repositories/users.py @@ -38,6 +38,8 @@ class UserWithAccess: regional_office: RegionalOffice roles: tuple[str, ...] permissions: frozenset[str] + # (код, название) активных ролей, по коду; название — `role.name`, для показа пользователю + role_names: tuple[tuple[str, str], ...] = () async def load_principal_data(session: AsyncSession, email: str) -> UserWithAccess | None: @@ -56,7 +58,7 @@ async def load_principal_data(session: AsyncSession, email: str) -> UserWithAcce ) user = aliased(AppUser, matched) statement = ( - select(user, RegionalOffice, Role.code, Permission.code) + select(user, RegionalOffice, Role.code, Permission.code, Role.name) .join(RegionalOffice, RegionalOffice.id == user.regional_office_id) .outerjoin(UserRole, UserRole.user_id == user.id) .outerjoin(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True))) @@ -71,11 +73,13 @@ async def load_principal_data(session: AsyncSession, email: str) -> UserWithAcce raise AmbiguousUserError(email) roles = {row[2] for row in rows if row[2] is not None} permissions = frozenset(row[3] for row in rows if row[3] is not None) + role_names = {row[2]: row[4] for row in rows if row[2] is not None} return UserWithAccess( user=rows[0][0], regional_office=rows[0][1], roles=tuple(sorted(roles)), permissions=permissions, + role_names=tuple(sorted(role_names.items())), ) diff --git a/api/src/schemas/me.py b/api/src/schemas/me.py index 2365003..6c32513 100644 --- a/api/src/schemas/me.py +++ b/api/src/schemas/me.py @@ -8,8 +8,15 @@ from src.models.enums import BusinessSegment from src.schemas.directories import RegionalOfficeBrief +class RoleBrief(BaseModel): + """Роль пользователя: код (на нём завязан фронт) и название для показа.""" + + code: str + name: str + + class MeResponse(BaseModel): - """Текущий пользователь. `roles` и `permissions` — коды, отсортированные по алфавиту.""" + """Текущий пользователь. `roles` и `permissions` — коды, по алфавиту; `role_details` — те же роли с названиями.""" model_config = ConfigDict(use_enum_values=True) @@ -19,4 +26,5 @@ class MeResponse(BaseModel): regional_office: RegionalOfficeBrief business_segment: BusinessSegment | None roles: list[str] + role_details: list[RoleBrief] permissions: list[str] diff --git a/api/tests/test_auth_deps.py b/api/tests/test_auth_deps.py index 1e0da35..7ed0a08 100644 --- a/api/tests/test_auth_deps.py +++ b/api/tests/test_auth_deps.py @@ -495,6 +495,7 @@ def test_me_response_schema_serializes_enum_and_null_segment() -> None: "full_name": "А", "regional_office": office, "roles": ["KA_GO"], + "role_details": [{"code": "KA_GO", "name": "КА ГО"}], "permissions": ["KFO_VIEW"], } empty = MeResponse(business_segment=None, **fields) diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index ccb9ed2..2d9fc7f 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -108,10 +108,22 @@ async def test_me_for_ka_rf_matches_matrix(app: FastAPI, seeded: AsyncSession) - assert response.status_code == HTTP_OK, response.text body = response.json() assert body["roles"] == ["KA_RF"] + # названия ролей для показа: код + непустое имя из role.name + assert [r["code"] for r in body["role_details"]] == ["KA_RF"] + assert body["role_details"][0]["name"] assert body["permissions"] == KA_RF_PERMISSIONS assert body["permissions"] == sorted(body["permissions"]) user = (await seeded.execute(select(AppUser).where(AppUser.email == "ka_rf@armka.local"))).scalar_one() - assert set(body) == {"id", "email", "full_name", "regional_office", "business_segment", "roles", "permissions"} + assert set(body) == { + "id", + "email", + "full_name", + "regional_office", + "business_segment", + "roles", + "role_details", + "permissions", + } assert body["id"] == str(user.id) assert body["email"] == "ka_rf@armka.local" assert body["full_name"] == user.full_name @@ -185,6 +197,7 @@ async def test_me_user_without_roles_has_empty_lists(app: FastAPI, seeded: Async response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local"))) assert response.status_code == HTTP_OK assert response.json()["roles"] == [] + assert response.json()["role_details"] == [] assert response.json()["permissions"] == [] diff --git a/openspec/specs/authn-authz/spec.md b/openspec/specs/authn-authz/spec.md index 74b1267..e6b37a8 100644 --- a/openspec/specs/authn-authz/spec.md +++ b/openspec/specs/authn-authz/spec.md @@ -47,12 +47,13 @@ claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_us ### Requirement: `GET /api/v1/me` Маршрут SHALL возвращать текущего пользователя: `id`, `email`, `full_name`, `regional_office {id, name, is_head_office}`, -`business_segment`, `roles` (список `code`), `permissions` (отсортированный список `code`). Это единственный маршрут +`business_segment`, `roles` (список `code`), `role_details` (те же активные роли списком `{code, name}`, `name` — `role.name`, +порядок как в `roles`), `permissions` (отсортированный список `code`). Это единственный маршрут Среза 0, доступный любому аутентифицированному пользователю без `require`. #### Scenario: Ответ для dev-пользователя - **WHEN** `ka_rf@armka.local` (роль `KA_RF`) запрашивает `/api/v1/me` -- **THEN** ответ 200 содержит `roles = ["KA_RF"]` и `permissions`, равные правам `KA_RF` из матрицы §10 +- **THEN** ответ 200 содержит `roles = ["KA_RF"]`, `role_details` с одним элементом `{code: "KA_RF", name: <непустое название>}` и `permissions`, равные правам `KA_RF` из матрицы §10 ### Requirement: Dev-эмитент токенов только в режиме разработки При `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` ядро SHALL предоставлять `POST /api/v1/dev/token` с телом `{"email"}`, diff --git a/openspec/specs/consolidations-ui/spec.md b/openspec/specs/consolidations-ui/spec.md index 40a3561..dae7501 100644 --- a/openspec/specs/consolidations-ui/spec.md +++ b/openspec/specs/consolidations-ui/spec.md @@ -257,3 +257,16 @@ SHALL быть неактивной с подсказкой «Возьмите #### Scenario: Ничего не введено - **WHEN** модалка «Изменить статус» открыта, этап не выбран, комментарий пуст, аналитик закрывает вкладку - **THEN** вкладка закрывается без диалога + +### Requirement: Текущий пользователь и его роли в боковом меню +Клик по логотипу «АРМ КА» в раскрытом боковом меню SHALL раскрывать блок с данными из `GET /me`: ФИО, логин (email), +роли (названия из `role_details[].name` через запятую; если названия нет — код роли; без ролей — «не назначена») и филиал. +Повторный клик или клик вне блока SHALL закрывать его. Названия — только для показа, логика фронта по ним не решается. + +#### Scenario: Пользователь с несколькими ролями +- **WHEN** пользователь с ролями KA_RF и RKA кликает по логотипу «АРМ КА» +- **THEN** раскрывается блок с его ФИО, email, подписью «Роли» и названиями обеих ролей (не кодами) и филиалом + +#### Scenario: Название роли недоступно +- **WHEN** в `role_details` нет названия для роли пользователя +- **THEN** в блоке показан код этой роли diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d153092..d6166a0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -34,9 +34,17 @@ export interface Me { regional_office: RegionalOfficeBrief; business_segment: BusinessSegment | null; roles: string[]; + /** Те же роли с названиями (`role.name`) для показа; на коды завязана логика, на названия — только интерфейс. */ + role_details: RoleBrief[]; permissions: string[]; } +/** Роль в `GET /me`: код и название. */ +export interface RoleBrief { + code: string; + name: string; +} + /** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */ export interface DevTokenResponse { access_token: string; diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index 5f8a7f0..db12ff7 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -4,12 +4,12 @@ import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react'; import { type ReactNode, memo, useState } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { USERS_ADMIN } from '../../auth/permissions'; -import { Logo } from '../../components/Logo'; import { createConsolidation } from '../../services/api/consolidations'; import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks'; import { useAppDispatch } from '../../store/store'; import { quarterLabel } from '../../utils/labels'; import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal'; +import { UserMenu } from '../UserMenu/UserMenu'; import styles from './styles.module.css'; type CollapsedIconProps = { @@ -164,7 +164,7 @@ export function Sidebar() { trigger={null}>