diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index a548e9e6..affe6c4e 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -44,6 +44,11 @@ class UserScope(TypedDict): # #657 white-label: brand slug, привязанный к аккаунту. None = generic UI. # Фронт (useBrand.ts) применяет бренд автоматически на login (без ?brand=). brand: str | None + # #2046 real profile fields для TopNav (фамилия/орг/email). None = фронт + # фолбэкается на username/role (как раньше) — фолбэк остаётся на фронте. + display_name: str | None + org: str | None + email: str | None # --------------------------------------------------------------------------- @@ -65,6 +70,26 @@ def get_brand_for_user(username: str) -> str | None: return _USERNAME_BRAND.get(username) +# --------------------------------------------------------------------------- +# Account → profile mapping (#2046 real profile fields for TopNav) +# --------------------------------------------------------------------------- +# +# Реальные имя/организация/email пилотов для TopNav-меню (замена фабрикованного +# "username as name"). Известные данные — только display_name для kopylov +# (фамилия «Копылов» из комментария в caddy/users.caddy.snippet). org/email для +# него не задокументированы нигде — умышленно НЕ выдумываем, оставляем None, +# фронт держит текущий фолбэк (username / brand ?? role / ""). +_USERNAME_PROFILE: dict[str, dict[str, str]] = { + "kopylov": {"display_name": "Копылов"}, +} + + +def get_profile_for_user(username: str) -> tuple[str | None, str | None, str | None]: + """Return (display_name, org, email) bound to *username*, or all-None.""" + profile = _USERNAME_PROFILE.get(username, {}) + return profile.get("display_name"), profile.get("org"), profile.get("email") + + # --------------------------------------------------------------------------- # YAML loading # --------------------------------------------------------------------------- @@ -193,12 +218,16 @@ def get_user_scope(username: str) -> UserScope: config = _load_roles_config() role = get_role(username) role_def = config["roles"][role] + display_name, org, email = get_profile_for_user(username) return UserScope( username=username, role=role, allowed_paths=list(role_def.get("paths", []) or []), deny_paths=list(role_def.get("deny", []) or []), brand=get_brand_for_user(username), + display_name=display_name, + org=org, + email=email, ) diff --git a/tradein-mvp/backend/tests/test_me_brand.py b/tradein-mvp/backend/tests/test_me_brand.py index 1c932bd9..a4a536b2 100644 --- a/tradein-mvp/backend/tests/test_me_brand.py +++ b/tradein-mvp/backend/tests/test_me_brand.py @@ -23,7 +23,7 @@ sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 -from app.core.auth import get_brand_for_user, get_user_scope # noqa: E402 +from app.core.auth import get_brand_for_user, get_profile_for_user, get_user_scope # noqa: E402 @pytest.mark.parametrize( @@ -59,3 +59,41 @@ def test_user_scope_brand_none_for_praktika() -> None: def test_user_scope_brand_none_for_generic_user() -> None: scope = get_user_scope("user1") assert scope["brand"] is None + + +# --------------------------------------------------------------------------- +# #2046 profile fields (display_name/org/email) — TopNav real identity. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("username", "expected"), + [ + # Единственный известный display_name — фамилия Копылова (см. комментарий + # в caddy/users.caddy.snippet). org/email для него не задокументированы — + # намеренно None, не выдумываем. + ("kopylov", ("Копылов", None, None)), + ("praktika", (None, None, None)), + ("admin", (None, None, None)), + ("user1", (None, None, None)), + ("unknown_user", (None, None, None)), + ], +) +def test_get_profile_for_user( + username: str, expected: tuple[str | None, str | None, str | None] +) -> None: + assert get_profile_for_user(username) == expected + + +def test_user_scope_display_name_kopylov() -> None: + scope = get_user_scope("kopylov") + assert scope["display_name"] == "Копылов" + assert scope["org"] is None + assert scope["email"] is None + + +def test_user_scope_profile_none_for_generic_user() -> None: + scope = get_user_scope("user1") + assert scope["display_name"] is None + assert scope["org"] is None + assert scope["email"] is None diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index cd14c8f9..df39dcf6 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -439,9 +439,9 @@ export default function TradeInV2Page() { const history = useEstimateHistory(); const quota = useQuota(); // Real logged-in identity for the TopNav user menu (replaces the design - // fixture). /me returns {username, role, brand} — no display name / email, so - // name falls back to username, org to brand ?? role, email to "" (renders - // blank, never invented). undefined while loading → TopNav «Гость» fallback. + // fixture). /me returns {username, role, brand, display_name, org, email} + // (#2046) — known profile fields fall back to username / brand ?? role / "" + // when absent (never invented). undefined while loading → TopNav «Гость». const me = useMe(); // Dashboard sub-hooks — each resolves independently; failure degrades its @@ -557,14 +557,23 @@ export default function TradeInV2Page() { // Real user for the TopNav menu. undefined until /me resolves → TopNav shows // its neutral «Гость» fallback (never the old "Андрей Петров / Брусника"). + // #2046: display_name/org/email — реальный профиль, если backend его знает + // (см. app.core.auth._USERNAME_PROFILE); иначе фолбэк на username/brand?role/"". const topNavUser = useMemo(() => { const scope = me.data; if (!scope) return undefined; + const nameParts = scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? []; + const initials = + nameParts.length >= 2 + ? (nameParts[0][0] + nameParts[1][0]).toUpperCase() + : nameParts.length === 1 + ? nameParts[0].slice(0, 2).toUpperCase() + : scope.username.slice(0, 2).toUpperCase(); return { - name: scope.username, - org: scope.brand ?? scope.role, - email: "", - initials: scope.username.slice(0, 2).toUpperCase(), + name: scope.display_name ?? scope.username, + org: scope.org ?? scope.brand ?? scope.role, + email: scope.email ?? "", + initials, }; }, [me.data]); diff --git a/tradein-mvp/frontend/src/lib/useMe.ts b/tradein-mvp/frontend/src/lib/useMe.ts index 6e33bef5..06ef99e6 100644 --- a/tradein-mvp/frontend/src/lib/useMe.ts +++ b/tradein-mvp/frontend/src/lib/useMe.ts @@ -24,6 +24,11 @@ export interface UserScope { // #657 brand-by-account: slug бренда, привязанный к аккаунту (praktika→"praktika"). // null/undefined = generic UI. useBrand применяет его автоматически на login. brand?: string | null; + // #2046 real profile fields для TopNav. undefined/null = данных нет, фронт + // фолбэкается на username / brand ?? role / "" (как раньше). + display_name?: string | null; + org?: string | null; + email?: string | null; } export const ME_QUERY_KEY = ["auth", "me"] as const;