Merge pull request 'AURORA-1690: пользователь и роли по клику на логотип' (#186) from feat/184-user-role into main
This commit is contained in:
commit
019a1d8d1b
@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from src.api.dependencies.auth import Principal, current_user
|
from src.api.dependencies.auth import Principal, current_user
|
||||||
from src.schemas.directories import RegionalOfficeBrief
|
from src.schemas.directories import RegionalOfficeBrief
|
||||||
from src.schemas.me import MeResponse
|
from src.schemas.me import MeResponse, RoleBrief
|
||||||
|
|
||||||
router = APIRouter(tags=["me"])
|
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),
|
regional_office=RegionalOfficeBrief.model_validate(office),
|
||||||
business_segment=user.business_segment,
|
business_segment=user.business_segment,
|
||||||
roles=sorted(principal.roles),
|
roles=sorted(principal.roles),
|
||||||
|
role_details=[RoleBrief(code=code, name=name) for code, name in principal.role_names],
|
||||||
permissions=sorted(principal.permissions),
|
permissions=sorted(principal.permissions),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -38,6 +38,8 @@ class UserWithAccess:
|
|||||||
regional_office: RegionalOffice
|
regional_office: RegionalOffice
|
||||||
roles: tuple[str, ...]
|
roles: tuple[str, ...]
|
||||||
permissions: frozenset[str]
|
permissions: frozenset[str]
|
||||||
|
# (код, название) активных ролей, по коду; название — `role.name`, для показа пользователю
|
||||||
|
role_names: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
async def load_principal_data(session: AsyncSession, email: str) -> UserWithAccess | None:
|
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)
|
user = aliased(AppUser, matched)
|
||||||
statement = (
|
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)
|
.join(RegionalOffice, RegionalOffice.id == user.regional_office_id)
|
||||||
.outerjoin(UserRole, UserRole.user_id == user.id)
|
.outerjoin(UserRole, UserRole.user_id == user.id)
|
||||||
.outerjoin(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True)))
|
.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)
|
raise AmbiguousUserError(email)
|
||||||
roles = {row[2] for row in rows if row[2] is not None}
|
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)
|
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(
|
return UserWithAccess(
|
||||||
user=rows[0][0],
|
user=rows[0][0],
|
||||||
regional_office=rows[0][1],
|
regional_office=rows[0][1],
|
||||||
roles=tuple(sorted(roles)),
|
roles=tuple(sorted(roles)),
|
||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
|
role_names=tuple(sorted(role_names.items())),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,15 @@ from src.models.enums import BusinessSegment
|
|||||||
from src.schemas.directories import RegionalOfficeBrief
|
from src.schemas.directories import RegionalOfficeBrief
|
||||||
|
|
||||||
|
|
||||||
|
class RoleBrief(BaseModel):
|
||||||
|
"""Роль пользователя: код (на нём завязан фронт) и название для показа."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
"""Текущий пользователь. `roles` и `permissions` — коды, отсортированные по алфавиту."""
|
"""Текущий пользователь. `roles` и `permissions` — коды, по алфавиту; `role_details` — те же роли с названиями."""
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
@ -19,4 +26,5 @@ class MeResponse(BaseModel):
|
|||||||
regional_office: RegionalOfficeBrief
|
regional_office: RegionalOfficeBrief
|
||||||
business_segment: BusinessSegment | None
|
business_segment: BusinessSegment | None
|
||||||
roles: list[str]
|
roles: list[str]
|
||||||
|
role_details: list[RoleBrief]
|
||||||
permissions: list[str]
|
permissions: list[str]
|
||||||
|
|||||||
@ -495,6 +495,7 @@ def test_me_response_schema_serializes_enum_and_null_segment() -> None:
|
|||||||
"full_name": "А",
|
"full_name": "А",
|
||||||
"regional_office": office,
|
"regional_office": office,
|
||||||
"roles": ["KA_GO"],
|
"roles": ["KA_GO"],
|
||||||
|
"role_details": [{"code": "KA_GO", "name": "КА ГО"}],
|
||||||
"permissions": ["KFO_VIEW"],
|
"permissions": ["KFO_VIEW"],
|
||||||
}
|
}
|
||||||
empty = MeResponse(business_segment=None, **fields)
|
empty = MeResponse(business_segment=None, **fields)
|
||||||
|
|||||||
@ -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
|
assert response.status_code == HTTP_OK, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["roles"] == ["KA_RF"]
|
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"] == KA_RF_PERMISSIONS
|
||||||
assert body["permissions"] == sorted(body["permissions"])
|
assert body["permissions"] == sorted(body["permissions"])
|
||||||
user = (await seeded.execute(select(AppUser).where(AppUser.email == "ka_rf@armka.local"))).scalar_one()
|
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["id"] == str(user.id)
|
||||||
assert body["email"] == "ka_rf@armka.local"
|
assert body["email"] == "ka_rf@armka.local"
|
||||||
assert body["full_name"] == user.full_name
|
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")))
|
response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local")))
|
||||||
assert response.status_code == HTTP_OK
|
assert response.status_code == HTTP_OK
|
||||||
assert response.json()["roles"] == []
|
assert response.json()["roles"] == []
|
||||||
|
assert response.json()["role_details"] == []
|
||||||
assert response.json()["permissions"] == []
|
assert response.json()["permissions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -47,12 +47,13 @@ claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_us
|
|||||||
|
|
||||||
### Requirement: `GET /api/v1/me`
|
### Requirement: `GET /api/v1/me`
|
||||||
Маршрут SHALL возвращать текущего пользователя: `id`, `email`, `full_name`, `regional_office {id, name, is_head_office}`,
|
Маршрут 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`.
|
Среза 0, доступный любому аутентифицированному пользователю без `require`.
|
||||||
|
|
||||||
#### Scenario: Ответ для dev-пользователя
|
#### Scenario: Ответ для dev-пользователя
|
||||||
- **WHEN** `ka_rf@armka.local` (роль `KA_RF`) запрашивает `/api/v1/me`
|
- **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-эмитент токенов только в режиме разработки
|
### Requirement: Dev-эмитент токенов только в режиме разработки
|
||||||
При `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` ядро SHALL предоставлять `POST /api/v1/dev/token` с телом `{"email"}`,
|
При `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` ядро SHALL предоставлять `POST /api/v1/dev/token` с телом `{"email"}`,
|
||||||
|
|||||||
@ -257,3 +257,16 @@ SHALL быть неактивной с подсказкой «Возьмите
|
|||||||
#### Scenario: Ничего не введено
|
#### Scenario: Ничего не введено
|
||||||
- **WHEN** модалка «Изменить статус» открыта, этап не выбран, комментарий пуст, аналитик закрывает вкладку
|
- **WHEN** модалка «Изменить статус» открыта, этап не выбран, комментарий пуст, аналитик закрывает вкладку
|
||||||
- **THEN** вкладка закрывается без диалога
|
- **THEN** вкладка закрывается без диалога
|
||||||
|
|
||||||
|
### Requirement: Текущий пользователь и его роли в боковом меню
|
||||||
|
Клик по логотипу «АРМ КА» в раскрытом боковом меню SHALL раскрывать блок с данными из `GET /me`: ФИО, логин (email),
|
||||||
|
роли (названия из `role_details[].name` через запятую; если названия нет — код роли; без ролей — «не назначена») и филиал.
|
||||||
|
Повторный клик или клик вне блока SHALL закрывать его. Названия — только для показа, логика фронта по ним не решается.
|
||||||
|
|
||||||
|
#### Scenario: Пользователь с несколькими ролями
|
||||||
|
- **WHEN** пользователь с ролями KA_RF и RKA кликает по логотипу «АРМ КА»
|
||||||
|
- **THEN** раскрывается блок с его ФИО, email, подписью «Роли» и названиями обеих ролей (не кодами) и филиалом
|
||||||
|
|
||||||
|
#### Scenario: Название роли недоступно
|
||||||
|
- **WHEN** в `role_details` нет названия для роли пользователя
|
||||||
|
- **THEN** в блоке показан код этой роли
|
||||||
|
|||||||
@ -34,9 +34,17 @@ export interface Me {
|
|||||||
regional_office: RegionalOfficeBrief;
|
regional_office: RegionalOfficeBrief;
|
||||||
business_segment: BusinessSegment | null;
|
business_segment: BusinessSegment | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
/** Те же роли с названиями (`role.name`) для показа; на коды завязана логика, на названия — только интерфейс. */
|
||||||
|
role_details: RoleBrief[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Роль в `GET /me`: код и название. */
|
||||||
|
export interface RoleBrief {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */
|
/** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */
|
||||||
export interface DevTokenResponse {
|
export interface DevTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
|
|||||||
import { type ReactNode, memo, useState } from 'react';
|
import { type ReactNode, memo, useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { USERS_ADMIN } from '../../auth/permissions';
|
import { USERS_ADMIN } from '../../auth/permissions';
|
||||||
import { Logo } from '../../components/Logo';
|
|
||||||
import { createConsolidation } from '../../services/api/consolidations';
|
import { createConsolidation } from '../../services/api/consolidations';
|
||||||
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
||||||
import { useAppDispatch } from '../../store/store';
|
import { useAppDispatch } from '../../store/store';
|
||||||
import { quarterLabel } from '../../utils/labels';
|
import { quarterLabel } from '../../utils/labels';
|
||||||
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
||||||
|
import { UserMenu } from '../UserMenu/UserMenu';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
type CollapsedIconProps = {
|
type CollapsedIconProps = {
|
||||||
@ -164,7 +164,7 @@ export function Sidebar() {
|
|||||||
trigger={null}>
|
trigger={null}>
|
||||||
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
||||||
<div className={styles.topbar}>
|
<div className={styles.topbar}>
|
||||||
{!isCollapsed && <Logo />}
|
{!isCollapsed && <UserMenu />}
|
||||||
<button className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
<button className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
26
web/src/widgets/UserMenu/UserMenu.tsx
Normal file
26
web/src/widgets/UserMenu/UserMenu.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useCurrentUser } from '../../auth/MeContext';
|
||||||
|
import { Logo } from '../../components/Logo';
|
||||||
|
import { userInfoRows } from './userInfo';
|
||||||
|
|
||||||
|
/** Логотип «АРМ КА»; по клику раскрывается блок с текущим пользователем и его ролями (`GET /me`). */
|
||||||
|
export function UserMenu({ compact = false }: { compact?: boolean }) {
|
||||||
|
const me = useCurrentUser();
|
||||||
|
const content = (
|
||||||
|
<dl style={{ margin: 0, minWidth: 220 }} data-testid='user-info'>
|
||||||
|
{userInfoRows(me).map((r) => (
|
||||||
|
<div key={r.label} style={{ display: 'flex', gap: 8, padding: '2px 0' }}>
|
||||||
|
<dt style={{ color: 'rgba(55, 65, 81, 0.6)', width: 110, flexShrink: 0 }}>{r.label}</dt>
|
||||||
|
<dd style={{ margin: 0, fontWeight: 500, wordBreak: 'break-word' }}>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover trigger='click' placement='bottomLeft' content={content}>
|
||||||
|
<button type='button' title='Текущий пользователь' style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}>
|
||||||
|
<Logo compact={compact} />
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
web/src/widgets/UserMenu/userInfo.test.ts
Normal file
48
web/src/widgets/UserMenu/userInfo.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Me } from '../../api/types';
|
||||||
|
import { roleLabels, userInfoRows } from './userInfo';
|
||||||
|
|
||||||
|
const base: Me = {
|
||||||
|
id: '1',
|
||||||
|
email: 'analyst@rshb.ru',
|
||||||
|
full_name: 'Иванов Иван',
|
||||||
|
regional_office: { id: 'o1', name: 'Центральный офис', is_head_office: true },
|
||||||
|
business_segment: null,
|
||||||
|
roles: ['KA_RF'],
|
||||||
|
role_details: [{ code: 'KA_RF', name: 'Кредитный аналитик РФ' }],
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('userInfoRows', () => {
|
||||||
|
it('показывает ФИО, логин, название роли и филиал', () => {
|
||||||
|
expect(userInfoRows(base)).toEqual([
|
||||||
|
{ label: 'Пользователь', value: 'Иванов Иван' },
|
||||||
|
{ label: 'Логин', value: 'analyst@rshb.ru' },
|
||||||
|
{ label: 'Роль', value: 'Кредитный аналитик РФ' },
|
||||||
|
{ label: 'Филиал', value: 'Центральный офис' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('несколько ролей — названия через запятую, подпись во множественном числе', () => {
|
||||||
|
const me: Me = {
|
||||||
|
...base,
|
||||||
|
roles: ['KA_RF', 'RKA'],
|
||||||
|
role_details: [
|
||||||
|
{ code: 'KA_RF', name: 'Кредитный аналитик РФ' },
|
||||||
|
{ code: 'RKA', name: 'Руководитель КА' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const row = userInfoRows(me).find((r) => r.label === 'Роли');
|
||||||
|
expect(row?.value).toBe('Кредитный аналитик РФ, Руководитель КА');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без названия показывает код роли', () => {
|
||||||
|
expect(roleLabels({ ...base, role_details: [] })).toEqual(['KA_RF']);
|
||||||
|
expect(roleLabels({ ...base, role_details: [{ code: 'KA_RF', name: '' }] })).toEqual(['KA_RF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без ролей пишет «не назначена»', () => {
|
||||||
|
const row = userInfoRows({ ...base, roles: [], role_details: [] }).find((r) => r.label === 'Роль');
|
||||||
|
expect(row?.value).toBe('не назначена');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
web/src/widgets/UserMenu/userInfo.ts
Normal file
21
web/src/widgets/UserMenu/userInfo.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { Me } from '../../api/types';
|
||||||
|
|
||||||
|
export type UserInfoRow = { label: string; value: string };
|
||||||
|
|
||||||
|
/** Названия ролей для показа: `role_details[].name`, а если названия нет (или старый бэк без `role_details`) — код роли. */
|
||||||
|
export function roleLabels(me: Me): string[] {
|
||||||
|
const names = new Map((me.role_details ?? []).map((r) => [r.code, r.name]));
|
||||||
|
return me.roles.map((code) => names.get(code) || code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки блока «Текущий пользователь»: ФИО, email, роли (названия), филиал. Пустые значения не показываем. */
|
||||||
|
export function userInfoRows(me: Me): UserInfoRow[] {
|
||||||
|
const roles = roleLabels(me);
|
||||||
|
const rows: UserInfoRow[] = [
|
||||||
|
{ label: 'Пользователь', value: me.full_name },
|
||||||
|
{ label: 'Логин', value: me.email },
|
||||||
|
{ label: roles.length > 1 ? 'Роли' : 'Роль', value: roles.length > 0 ? roles.join(', ') : 'не назначена' },
|
||||||
|
{ label: 'Филиал', value: me.regional_office?.name ?? '' },
|
||||||
|
];
|
||||||
|
return rows.filter((r) => r.value !== '');
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user