diff --git a/backend/alembic/versions/f1a2b3c4d5e6_user_name.py b/backend/alembic/versions/f1a2b3c4d5e6_user_name.py new file mode 100644 index 0000000..06b6dc2 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_user_name.py @@ -0,0 +1,23 @@ +"""users.name — отображаемое имя из SSO (ТЗ 3.16). + +Revision ID: f1a2b3c4d5e6 +Revises: f9a0b1c2d3e4 +Create Date: 2026-09-22 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "f1a2b3c4d5e6" +down_revision = "f9a0b1c2d3e4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("name", sa.String(120), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "name") \ No newline at end of file diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 1d6ce26..38f8667 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -16,6 +16,7 @@ from app.auth.client import get_gauth_client from app.config import get_settings from app.dependencies import DbDep +from app.models import User from app.services.users import claim_global_settings, claim_orphan_data, upsert_user router = APIRouter(prefix="/auth", tags=["auth"]) @@ -54,6 +55,8 @@ session_user = { "user_id": user.user_id, "email": user.email, + # display_name из профиля SSO; без него — username + "name": user.profile.get("display_name") or user.profile.get("username") or "", "avatar_url": user.avatar_url, # Язык UI: SSO отдаёт profile.locale (default 'ru'); '' в поле — нет значения "locale": user.profile.get("locale") or "", @@ -64,7 +67,14 @@ # Профиль в БД + claim: бесхозные данные (user_id NULL) забирает вошедший # (ТЗ 1.2) — идемпотентно, при повторных логинах уже ничего не находит - upsert_user(db, user.user_id, user.email, user.avatar_url, session_user["locale"]) + upsert_user( + db, + user.user_id, + user.email, + user.avatar_url, + session_user["locale"], + name=session_user["name"] or None, + ) claim_orphan_data(db, user.user_id) claim_global_settings(db, user.user_id) @@ -79,10 +89,21 @@ @router.get("/me") -async def me(request: Request) -> dict[str, Any]: - user = request.session.get("user") - if not user: +async def me(request: Request, db: DbDep) -> dict[str, Any]: + session_user = request.session.get("user") + if not session_user: raise HTTPException(status_code=401, detail="Not authenticated") + # Профиль берём из БД, а не из cookie-сессии: сессия stateless, и изменения + # от webhook SSO (webhooks.py) иначе видны только после следующего логина. + user = db.get(User, session_user["user_id"]) + if user is not None: + session_user = { + "user_id": user.id, + "email": user.email, + "name": user.name or "", + "avatar_url": user.avatar_url, + "locale": user.locale or "", + } # Раздел профиля в системе авторизации (SSO): {base}/account account_url = get_settings().gauth_base_url.rstrip("/") + "/account" - return {"user": user, "account_url": account_url} + return {"user": session_user, "account_url": account_url} diff --git a/backend/app/auth/webhooks.py b/backend/app/auth/webhooks.py index 4ff56b3..de3876e 100644 --- a/backend/app/auth/webhooks.py +++ b/backend/app/auth/webhooks.py @@ -7,7 +7,8 @@ Не задан — приём webhook выключен (503). Обрабатываем подмножество, значимое для gntodo: -- user.email_changed / user.profile_updated — синхронизация профиля User; +- user.email_changed / user.profile_updated — синхронизация профиля User + (email, display_name, аватар, локаль); - user.blocked / user.archived / user.deleted — отзыв всех MCP-токенов (аккаунт более недоступен в SSO — агент не должен действовать от его имени). Ограничение: cookie-сессии stateless, серверно их не отозвать — доступ @@ -78,6 +79,11 @@ if event.event_type == "user.profile_updated": profile = data.get("profile") or {} if user is not None: + # Контракт: profile — актуальное состояние после изменения; ключа нет — + # поле не менялось, не трогаем. username меняет логин, не имя — игнорируем + # (отдельный user.username_changed имя не несёт). + if "display_name" in profile: + user.name = str(profile.get("display_name") or "") or None if "locale" in profile: user.locale = profile.get("locale") or "" if "avatar_url" in profile: diff --git a/backend/app/models.py b/backend/app/models.py index 55c1315..bede3cc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,6 +41,8 @@ id: Mapped[str] = mapped_column(String(64), primary_key=True) email: Mapped[str] = mapped_column(String(255), default="") + # Отображаемое имя из SSO (profile.display_name); None — пользователь его не задал + name: Mapped[str | None] = mapped_column(String(120), nullable=True, default=None) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None) locale: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/users.py b/backend/app/services/users.py index 25813ab..ed96449 100644 --- a/backend/app/services/users.py +++ b/backend/app/services/users.py @@ -22,15 +22,22 @@ def upsert_user( - db: Session, user_id: str, email: str, avatar_url: str | None, locale: str | None + db: Session, + user_id: str, + email: str, + avatar_url: str | None, + locale: str | None, + name: str | None = None, ) -> User: - """Создать или обновить профиль по данным SSO (email/аватар/локаль могут меняться).""" + """Создать или обновить профиль по данным SSO (email/имя/аватар/локаль меняются).""" user = db.get(User, user_id) if user is None: - user = User(id=user_id, email=email, avatar_url=avatar_url, locale=locale) + user = User(id=user_id, email=email, name=name, avatar_url=avatar_url, locale=locale) db.add(user) else: user.email = email + if name: + user.name = name user.avatar_url = avatar_url user.locale = locale # flush сразу: claim_* (Core UPDATE) не триггерит autoflush, и FK diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8f272c2..b906854 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -37,9 +37,7 @@ assert _safe_return_to("") == "/" -def test_callback_upserts_user_and_claims_orphans( - client: TestClient, monkeypatch: Any -) -> None: +def test_callback_upserts_user_and_claims_orphans(client: TestClient, monkeypatch: Any) -> None: """Регрессия 500 на первом логине: upsert_user держал INSERT pending, а claim_orphan_data (Core UPDATE) не триггерит autoflush — FK projects.user_id → users падала на 'Key (user_id)=(x) is not present'.""" @@ -60,7 +58,10 @@ token_set = SimpleNamespace(access_token="at", refresh_token="rt") sso_user = SimpleNamespace( - user_id="9", email="nine@example.com", avatar_url=None, profile={"locale": "ru"} + user_id="9", + email="nine@example.com", + avatar_url=None, + profile={"locale": "ru", "display_name": "Девятый"}, ) stub = SimpleNamespace( exchange_authorization_code=lambda code, state: token_set, @@ -68,15 +69,15 @@ ) monkeypatch.setattr(auth_routes, "get_gauth_client", lambda: stub) - resp = client.get( - "/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False - ) + resp = client.get("/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False) assert resp.status_code in (302, 307) assert resp.headers["location"] == "/" check = _test_session_factory() try: - assert check.get(User, "9") is not None + user9 = check.get(User, "9") + assert user9 is not None + assert user9.name == "Девятый" assert check.scalars(select(Project).where(Project.user_id.is_(None))).all() == [] claimed = check.scalars(select(Project).where(Project.user_id == "9")).all() assert [p.name for p in claimed] == ["Наследие"] @@ -84,6 +85,46 @@ check.close() +def test_me_serves_fresh_profile_from_db(client: TestClient, monkeypatch: Any) -> None: + """/auth/me читает профиль из БД: изменения от webhook SSO видны без релогина.""" + from types import SimpleNamespace + + from app.auth import routes as auth_routes + from app.models import User + from tests.conftest import _test_session_factory + + token_set = SimpleNamespace(access_token="at", refresh_token="rt") + sso_user = SimpleNamespace( + user_id="7", + email="seven@example.com", + avatar_url=None, + profile={"locale": "ru", "display_name": "Седьмой"}, + ) + stub = SimpleNamespace( + exchange_authorization_code=lambda code, state: token_set, + fetch_user=lambda token: sso_user, + ) + monkeypatch.setattr(auth_routes, "get_gauth_client", lambda: stub) + assert client.get( + "/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False + ).status_code in (302, 307) + + # webhook меняет профиль в БД напрямую (сессия-кука остаётся старой) + session = _test_session_factory() + try: + user = session.get(User, "7") + user.name = "Новое Имя" + user.avatar_url = "https://sso/new.jpg" + session.commit() + finally: + session.close() + + me = client.get("/auth/me").json()["user"] + assert me["name"] == "Новое Имя" + assert me["avatar_url"] == "https://sso/new.jpg" + assert me["email"] == "seven@example.com" + + def test_default_session_secret_rejected() -> None: """Забытый SESSION_SECRET не должен поднимать приложение молча.""" import pytest as _pytest diff --git a/backend/tests/test_auth_webhooks.py b/backend/tests/test_auth_webhooks.py index 3ad4d4c..ad00069 100644 --- a/backend/tests/test_auth_webhooks.py +++ b/backend/tests/test_auth_webhooks.py @@ -150,7 +150,7 @@ session.close() -def test_profile_updated_syncs_locale_and_avatar(client: Any, monkeypatch: Any) -> None: +def test_profile_updated_syncs_name_locale_avatar(client: Any, monkeypatch: Any) -> None: _enable_webhook(monkeypatch) session = _test_session_factory() try: @@ -163,7 +163,11 @@ "type": "user.profile_updated", "data": { "user": {"id": "u1"}, - "profile": {"locale": "uk", "avatar_url": "https://sso/avatar.jpg"}, + "profile": { + "display_name": "Юзер Юзерович", + "locale": "uk", + "avatar_url": "https://sso/avatar.jpg", + }, }, }, "user.profile_updated", @@ -171,12 +175,37 @@ assert resp.status_code == 200 session.expire_all() user = session.get(User, "u1") + assert user.name == "Юзер Юзерович" assert user.locale == "uk" assert user.avatar_url == "https://sso/avatar.jpg" finally: session.close() +def test_profile_updated_without_keys_leaves_fields_alone(client: Any, monkeypatch: Any) -> None: + """Ключа нет в profile — поле не менялось, не трогаем.""" + _enable_webhook(monkeypatch) + session = _test_session_factory() + try: + session.add( + User(id="u1", email="u@example.com", name="Было", avatar_url="http://a", locale="ru") + ) + session.commit() + + resp = _post( + client, + {"type": "user.profile_updated", "data": {"user": {"id": "u1"}, "profile": {}}}, + "user.profile_updated", + ) + assert resp.status_code == 200 + session.expire_all() + user = session.get(User, "u1") + assert user.name == "Было" + assert user.avatar_url == "http://a" + finally: + session.close() + + def test_unknown_event_type_is_acked(client: Any, monkeypatch: Any) -> None: """Незнакомый тип SSO подтверждаем 200 — без retry-шторма с его стороны.""" _enable_webhook(monkeypatch) diff --git a/backend/tests/test_multiuser.py b/backend/tests/test_multiuser.py index 4ed7b6c..8e33c18 100644 --- a/backend/tests/test_multiuser.py +++ b/backend/tests/test_multiuser.py @@ -173,13 +173,16 @@ session = _test_session_factory() try: - user = upsert_user(session, "u1", "a@example.com", None, "ru") + user = upsert_user(session, "u1", "a@example.com", None, "ru", name="У первого") session.commit() assert user.email == "a@example.com" + assert user.name == "У первого" + # name без значения (SSO не отдал display_name) — прежнее имя сохраняется updated = upsert_user(session, "u1", "b@example.com", "http://x/ava.png", "en") session.commit() assert updated.email == "b@example.com" + assert updated.name == "У первого" assert updated.avatar_url == "http://x/ava.png" assert updated.locale == "en" finally: diff --git a/docs/TZ.md b/docs/TZ.md index d50856e..906fae2 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.35 | +| Версия ТЗ | 0.36 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -416,7 +416,7 @@ - **Dev-режим**: SW не включён (`devOptions` выключен) — PWA проверяется на прод-сборке (`dist`), не на dev-сервере. -### 3.16. Вебхуки системы авторизации (0.35) +### 3.16. Вебхуки системы авторизации (0.35, профиль 0.36) - gnexus-auth асинхронно доставляет подписанные события на `POST /auth/webhook` (контракт — docs/04-events-and-webhooks.md в репозитории gnexus-auth): HMAC-SHA256 @@ -424,9 +424,13 @@ админке SSO, `client.webhook/rotate-secret`); секрет не задан — приём выключен (503). - Обрабатываемые события: - `user.email_changed`, `user.profile_updated` — синхронизация профиля - `User` (email, locale, avatar_url) без повторного логина; + `User` (email, name ← `profile.display_name`, locale, avatar_url) без + повторного логина; ключа нет в `profile` — поле не менялось, не трогаем; - `user.blocked`, `user.archived`, `user.deleted` — отзыв всех MCP-токенов пользователя (агент теряет доступ вместе с аккаунтом). +- Профиль в БД — источник истины: `/auth/me` отдаёт свежие данные из БД, + а не из cookie-сессии, поэтому изменения профиля от webhook видны сразу + (без повторного логина). - Незнакомые типы событий (включая `webhook.test` и будущие) подтверждаются 200 без действий — чтобы SSO не гонял retry'и по незнакомому типу; при недействительной подписи/битом payload — 400. @@ -464,8 +468,8 @@ см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. ``` -User — пользователь SSO: id (= user_id от SSO), email, avatar_url, - locale, created_at +User — пользователь SSO: id (= user_id от SSO), email, name (display_name), + avatar_url, locale, created_at McpToken — персональный MCP-токен (3.10): user_id, token_hash (sha256, уникален), label, created_at; plaintext показывается один раз diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 93f3c9f..b3c06b5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -110,8 +110,8 @@ - {{ user.email }} - {{ t('nav.account') }} + {{ user.name || user.email }} + {{ user.name ? user.email : t('nav.account') }} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d8a5535..c3835c4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -30,6 +30,8 @@ export interface UserInfo { user_id: string email: string + /** Отображаемое имя из SSO (display_name); '' — не задано */ + name?: string avatar_url: string | null /** Язык из SSO profile.locale ('' если нет значения) */ locale?: string