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