diff --git a/CLAUDE.md b/CLAUDE.md index 7ccd648..0b1901c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ - **Python SDK**: `gnexus-gauth` (репо gnexus-auth-client-py, зависимость только httpx): полный OAuth2 Authorization Code + PKCE, exchange/refresh/revoke, `fetch_user()` → `AuthenticatedUser`. Для SPA у gnexus-auth есть свой JS-клиент (`packages/auth-client/`, доки 18–20). - **Роли**: системные — `superadmin > admin > user` (глобальные), плюс клиентские (per-сервис slug'и, пока не используем). **Доступ к админке = `system_role in ("admin", "superadmin")`** — аналог `SystemRole::canAccessAdminArea()` на сервере auth. - **userinfo-ответ**: `sub`, `email`, `system_role`, `profile`, `client.roles/permissions` (scopes: `openid email profile roles permissions`). Access-токен живёт ~30 мин, есть refresh-ротация с детекцией переиспользования. -- **OAuth-поток на сервере** (`app/api/auth_routes.py`, клиент gnexus-gauth): `GET /auth/login` (state+PKCE в Redis, redirect на authorize) → `GET /auth/callback` (обмен кода, проверка роли **до выдачи токена**: не-админу access-токен сразу revoke) → SPA получает токены в URL-фрагменте `#`. Тихое обновление — `POST /auth/refresh`, логаут — `POST /auth/revoke` (revoke access-токена). +- **OAuth-поток на сервере** (`app/api/auth_routes.py`, клиент gnexus-gauth): `GET /auth/login` (state+PKCE в Redis, redirect на authorize) → `GET /auth/callback` (обмен кода; токен выдаётся любому аутентифицированному, роль решает только раздел) → SPA получает токены в URL-фрагменте `#`. Тихое обновление — `POST /auth/refresh`, логаут — `POST /auth/revoke` (revoke access-токена). - Токены — opaque; каждый запрос API валидируется через userinfo. Креды клиента (GAUTH_CLIENT_ID/SECRET) — только в `.env`, в UI авторизация никак не конфигурируется. - TLS gnexus-auth в LAN self-signed: `GAUTH_VERIFY_TLS=false` по умолчанию; в проде с нормальным сертификатом — `true`. - **Single sign-out** (правило владельца): выход из gnexus-auth обязан завершать сессию в Synapse. Прямой logout в gnexus-auth НЕ отзывает OAuth-токены (access до 30 мин, refresh 30 дней переживают logout) — поэтому callback сохраняет выданные токены в Redis по user_id (`store_login`), а вебхук `auth.logout`/`auth.global_logout`/`session.revoked` отзывает их все. SPA-poll `/api/v1/admin/me` каждые 45 c: 401 → уход на /login. Выход из Synapse (`POST /auth/logout`) тоже отзывает входы пользователя; завершение самой SSO-сессии (RP-initiated logout) — TODO в gnexus-auth, зафиксировано в #32. @@ -60,7 +60,8 @@ - **FastAPI** для API (не Django) — обоснование описать в задаче #32 при проектировании API. - **Админ-панель** — SPA на Vue 3 с Gnexus UI Kit; раздаётся как статика контейнером FastAPI. Всё конфигурирование проекта — через админку, UI первичен для настроек. Минимум своего, максимум из кита. -- **Доступ к админке** — роли gnexus-auth (SSO): только `admin` и выше. Пользователя с ролью ниже — отсекать явной ошибкой (403 «недостаточно прав»), как на уровне API, так и в UI (гейт после логина). Своих паролей Synapse не хранит. +- **Доступ к админке** — роли gnexus-auth (SSO): настраивать Synapse — только `admin` и выше (админ-API под 403). Пользователь роли `user` **входит в приложение** с ограниченным личным разделом «Мои события» (`/api/v1/me`; в админку не пускается ни сервером, ни роутером SPA). Своих паролей Synapse не хранит. +- **Многопользовательность (2026-10-03)**: «о ком событие» — конвенция `payload.user_id` (= `sub` gnexus-auth; контракт v1 цел, получателей в конверте нет). Что видит пользователь — решают правила: новое действие-канал `user` (личный лог, адресат из `payload.user_id`; правило без него — событие «только для админа»). Позже тот же канал даст push (#29). В OAuth-колбэке токен выдаётся любому аутентифицированному (revoke не-админу больше не нужен). ## Инфраструктура diff --git a/alembic/versions/20261003_0004_user_channel.py b/alembic/versions/20261003_0004_user_channel.py new file mode 100644 index 0000000..5c41930 --- /dev/null +++ b/alembic/versions/20261003_0004_user_channel.py @@ -0,0 +1,40 @@ +""" Канал user — личный лог пользователя: recipient_user_id у доставок, ck_channel с 'user' + +Revision ID: c8e4f2b77d10 +Revises: b7d2f4a19e60 +Create Date: 2026-10-03 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "c8e4f2b77d10" +down_revision = "b7d2f4a19e60" +branch_labels = None +depends_on = None + +CHANNEL_WITH_USER = "channel IN ('telegram', 'email', 's2s', 'internal_log', 'user')" +CHANNEL_OLD = "channel IN ('telegram', 'email', 's2s', 'internal_log')" + +CHECK_TABLES = ("routing_rule_actions", "channel_targets", "deliveries") + + +def upgrade() -> None: + # ck_channel есть на трёх таблицах (см. 0001) — обновить каждую, иначе + # воркер упадёт на первом user-действии. + for table in CHECK_TABLES: + op.drop_constraint("ck_channel", table, type_="check") + for table in CHECK_TABLES: + op.create_check_constraint("ck_channel", table, CHANNEL_WITH_USER) + op.add_column("deliveries", sa.Column("recipient_user_id", sa.String(64), nullable=True)) + op.create_index("ix_deliveries_recipient", "deliveries", ["recipient_user_id", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_deliveries_recipient", table_name="deliveries") + op.drop_column("deliveries", "recipient_user_id") + for table in CHECK_TABLES: + op.drop_constraint("ck_channel", table, type_="check") + for table in CHECK_TABLES: + op.create_check_constraint("ck_channel", table, CHANNEL_OLD) \ No newline at end of file diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index afbe127..5d36468 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -361,6 +361,11 @@ target = db.get(ChannelTarget, act.target_id) if act.target_id else "ok" if not target: raise HTTPException(status_code=422, detail=f"Цель id={act.target_id} не найдена") + if act.channel == "user" and act.target_id: + raise HTTPException( + status_code=422, + detail="Канал user адресует конкретного пользователя через payload.user_id — цель не задаётся", + ) data = payload.model_dump() actions = data.pop("actions") rule = RoutingRule(**data) @@ -390,6 +395,11 @@ target = db.get(ChannelTarget, act.get("target_id")) if act.get("target_id") else "ok" if not target: raise HTTPException(status_code=422, detail=f"Цель id={act['target_id']} не найдена") + if act.get("channel") == "user" and act.get("target_id"): + raise HTTPException( + status_code=422, + detail="Канал user адресует конкретного пользователя через payload.user_id — цель не задаётся", + ) db.execute(delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id)) for act in actions: db.add(RoutingRuleAction(rule_id=rule.id, **act)) @@ -434,6 +444,7 @@ DeliveryOut( id=d.id, event_id=d.event_id, rule_id=d.rule_id, channel=d.channel, target_id=d.channel_target_id, target_name=names.get(d.channel_target_id), + recipient_user_id=d.recipient_user_id, status=d.status, attempts=d.attempts, last_error=d.last_error, next_retry_at=d.next_retry_at, rendered_message=d.rendered_message, created_at=d.created_at, delivered_at=d.delivered_at, @@ -507,6 +518,7 @@ DeliveryOut( id=d.id, event_id=d.event_id, rule_id=d.rule_id, channel=d.channel, target_id=d.channel_target_id, target_name=names.get(d.channel_target_id), + recipient_user_id=d.recipient_user_id, status=d.status, attempts=d.attempts, last_error=d.last_error, next_retry_at=d.next_retry_at, rendered_message=d.rendered_message, created_at=d.created_at, delivered_at=d.delivered_at, diff --git a/app/api/admin_schemas.py b/app/api/admin_schemas.py index cb01c4c..ad779be 100644 --- a/app/api/admin_schemas.py +++ b/app/api/admin_schemas.py @@ -88,7 +88,7 @@ # --- rules --- class RuleActionIn(BaseModel): - channel: str = Field(pattern=r"^(telegram|email|s2s|internal_log)$") + channel: str = Field(pattern=r"^(telegram|email|s2s|internal_log|user)$") target_id: int | None = None template: str | None = None @@ -133,6 +133,8 @@ channel: str target_id: int | None target_name: str | None = None + # Канал user — кому из пользователей доставлено (sub gnexus-auth). + recipient_user_id: str | None = None status: str attempts: int last_error: str | None diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py index bd08c8a..2c5059c 100644 --- a/app/api/auth_routes.py +++ b/app/api/auth_routes.py @@ -14,7 +14,6 @@ from pydantic import BaseModel from app.auth.client import GAUTH_SCOPES, get_gauth_client -from app.auth.deps import ADMIN_ROLES from app.auth.stores import revoke_logins_for_user, store_login from app.config import Settings, get_settings @@ -50,7 +49,9 @@ error: str | None = None, settings=Depends(get_settings), ) -> RedirectResponse: - """Код от gnexus-auth -> TokenSet. Пользователю без роли admin токен не выдаётся.""" + """Код от gnexus-auth -> TokenSet. Токен выдаётся любому аутентифицированному + пользователю: роль решает только раздел (админ-часть отсечена require_admin, + у user — личный раздел «Мои события»).""" if error or not code or not state: return _spa_redirect(settings, "/login", {"error": error or "missing_code_or_state"}) @@ -65,14 +66,6 @@ except Exception: return _spa_redirect(settings, "/login", {"error": "userinfo_failed"}) - if (user.system_role or "") not in ADMIN_ROLES: - # Отзываем access-токен сразу: он валиден ~30 минут, не оставляем его пользователю. - try: - client.revoke_token(tokens.access_token) - except Exception: - pass - return _spa_redirect(settings, "/denied", {}) - # Учёт входа: при выходе пользователя из gnexus-auth вебхук найдёт # этот токен и отзовёт его (см. webhooks()). store_login(user.user_id, state, tokens.access_token, tokens.refresh_token) diff --git a/app/api/me_routes.py b/app/api/me_routes.py new file mode 100644 index 0000000..02d42f9 --- /dev/null +++ b/app/api/me_routes.py @@ -0,0 +1,105 @@ +"""Личный кабинет любого аутентифицированного пользователя gnexus-auth. + +Роли user (не admin) в админ-часть не пускаются (require_admin), но личное +им доступно: /me — профиль; /me/events — события, которые правила +маршрутизации отправили в канал user (личный лог) с адресатом этим +пользователем (payload.user_id == sub, конвенция docs/05-ingestion-api.md). + +Без ролевого гейта: get_current_user — только аутентификация call-home. +""" + +import uuid +from datetime import datetime + +from fastapi import Depends, Query +from fastapi import APIRouter +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.config import get_settings +from app.database import get_db +from app.api.routes import AdminMe +from app.models import Delivery, Event, Source + +router = APIRouter(prefix="/api/v1", tags=["me"]) + + +class MeEventOut(BaseModel): + id: uuid.UUID + source_name: str + subject: str + action: str + priority: str + payload: dict + created_at: datetime + delivered_at: datetime | None + rendered_message: str | None + + +@router.get("/me", response_model=AdminMe) +def me(user=Depends(get_current_user)) -> AdminMe: + """Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role.""" + return AdminMe( + sub=str(user.user_id), + email=user.email, + system_role=user.system_role or "unknown", + username=user.profile.get("username"), + display_name=user.profile.get("display_name"), + avatar_url=user.profile.get("avatar_url"), + account_url=f"{get_settings().gauth_base_url.rstrip('/')}/account/profile", + ) + + +@router.get("/me/events", response_model=list[MeEventOut]) +def my_events( + user=Depends(get_current_user), + db: Session = Depends(get_db), + limit: int = Query(50, ge=1, le=200), +) -> list[MeEventOut]: + """События, направленные лично этому пользователю. + + Только доставки канала user его адресата: событие попадает сюда + исключительно через правило маршрутизации. Строгий фильтр по sub — + чужое не видно никогда. Дедуп по событию (одно событие могут направить + два правила — показываем один раз). + """ + sub = str(user.user_id) + rows = ( + db.execute( + select(Delivery, Event, Source.name) + .join(Event, Event.id == Delivery.event_id) + .join(Source, Source.id == Event.source_id) + .where( + Delivery.channel == "user", + Delivery.recipient_user_id == sub, + Delivery.status == "delivered", + ) + .order_by(Delivery.created_at.desc(), Delivery.id.desc()) + .limit(limit * 2) # запас на дубли двух правил + ) + .all() + ) + seen: set[uuid.UUID] = set() + out: list[MeEventOut] = [] + for delivery, event, source_name in rows: + if event.id in seen: + continue + seen.add(event.id) + out.append( + MeEventOut( + id=event.id, + source_name=source_name, + subject=event.subject, + action=event.action, + priority=event.priority, + payload=dict(event.payload or {}), + created_at=event.created_at, + delivered_at=delivery.delivered_at, + rendered_message=delivery.rendered_message, + ) + ) + if len(out) >= limit: + break + return out \ No newline at end of file diff --git a/app/main.py b/app/main.py index 26c84a9..45f302b 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import FileResponse from app.api import events_routes from app.api import admin_routes +from app.api import me_routes from app.api.auth_routes import router as auth_router from app.api.routes import router as api_router from app.config import get_settings @@ -30,6 +31,7 @@ app.include_router(auth_router) app.include_router(events_routes.router) app.include_router(admin_routes.router) + app.include_router(me_routes.router) # Собранная SPA: в Docker-образе кладётся в spa_static/. Если собранной # статики нет (локальный запуск api без фронта) — просто пропускаем. diff --git a/app/models/dicts.py b/app/models/dicts.py index e18f19b..ac623eb 100644 --- a/app/models/dicts.py +++ b/app/models/dicts.py @@ -23,8 +23,9 @@ from app.database import Base # Каналы доставки — те же значения в channel_targets и routing_rule_actions. -# internal_log не имеет внешних кредов (это БД); push (web-push) пока не включаем. -CHANNELS = ("telegram", "email", "s2s", "internal_log") +# internal_log не имеет внешних кредов (это БД); user — личный лог пользователя +# (адресат из payload.user_id, цель не задаётся); push (web-push) пока не включаем. +CHANNELS = ("telegram", "email", "s2s", "internal_log", "user") PRIORITIES = ("low", "normal", "high", "critical") diff --git a/app/models/events.py b/app/models/events.py index a14a85c..2584720 100644 --- a/app/models/events.py +++ b/app/models/events.py @@ -66,11 +66,14 @@ class Delivery(Base): __tablename__ = "deliveries" __table_args__ = ( - CheckConstraint("channel IN ('telegram','email','s2s','internal_log')", name="ck_channel"), + CheckConstraint( + "channel IN ('telegram','email','s2s','internal_log','user')", name="ck_channel" + ), CheckConstraint( "status IN ('pending','delivered','failed','skipped')", name="ck_delivery_status" ), Index("ix_deliveries_retry", "status", "next_retry_at"), + Index("ix_deliveries_recipient", "recipient_user_id", "created_at"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) @@ -86,6 +89,9 @@ channel_target_id: Mapped[int | None] = mapped_column( ForeignKey("channel_targets.id", ondelete="SET NULL") ) + # Канал user — личный лог: кому доставлено (sub gnexus-auth). + # Null у остальных каналов; CHECK не вешаем — гарантирует код маршрутизации. + recipient_user_id: Mapped[str | None] = mapped_column(String(64)) status: Mapped[str] = mapped_column(String(16), default="pending") attempts: Mapped[int] = mapped_column(default=0) diff --git a/app/worker/tasks.py b/app/worker/tasks.py index 9ae0869..d35d4df 100644 --- a/app/worker/tasks.py +++ b/app/worker/tasks.py @@ -3,7 +3,8 @@ Приём (api) кладёт только конверт в БД; здесь живёт Routing Engine: - подбор правил по условиям (source/subjects/actions/priority_min/payload); - шумодав правила (throttle_seconds, critical проходит всегда); -- отправка: internal_log сразу (delivery и есть запись лога), s2s — подписано +- отправка: internal_log сразу (delivery и есть запись лога), user — личный + лог пользователя (payload.user_id, см. docs/05), s2s — подписано (senders.py, docs/05); Telegram/Email — заглушки под #29; провалы — ретрай-цикл через beat (synapse.retry_due). """ @@ -110,19 +111,25 @@ return template_text -def _throttled(db, rule: RoutingRule, channel: str, target_id: int | None, now: datetime) -> bool: +def _throttled( + db, rule: RoutingRule, channel: str, target_id: int | None, now: datetime, + recipient: str | None = None, +) -> bool: """Шумодав: была ли создана доставка в ту же цель в окне rule.throttle_seconds. Считаются pending/delivered (в полёте тоже держат канал занятым); skipped окно не продлевает — иначе непрерывный шторм никогда не проходит. critical не доходит до этого вызова (обход на месте вызова). + Канал user — цель это recipient_user_id. """ - if not rule.throttle_seconds or target_id is None: + if not rule.throttle_seconds or (target_id is None and recipient is None): return False recent = ( db.execute( select(Delivery.id).where( Delivery.channel == channel, + # Канал user адресует пользователя, остальные — ChannelTarget. + Delivery.recipient_user_id == recipient if recipient is not None else Delivery.channel_target_id == target_id, Delivery.status.in_(("pending", "delivered")), Delivery.created_at > now - timedelta(seconds=rule.throttle_seconds), @@ -181,9 +188,16 @@ select(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id) ).scalars().all() for act in actions: + # Канал user адресует пользователя из payload.user_id + # (конвенция, docs/05); без него доставить некому. + recipient = None + if act.channel == "user": + rid = str((event.payload or {}).get("user_id") or "").strip() + recipient = rid[:64] or None + # шумодав: срезаем (с аудит-записью), critical проходит if event.priority != "critical" and _throttled( - db, rule, act.channel, act.target_id, now + db, rule, act.channel, act.target_id, now, recipient=recipient ): db.add( Delivery( @@ -191,6 +205,7 @@ rule_id=rule.id, channel=act.channel, channel_target_id=act.target_id, + recipient_user_id=recipient, status="skipped", attempts=0, rendered_message=( @@ -221,6 +236,42 @@ ) continue + if act.channel == "user": + # Личный лог пользователя: также исполняется сразу. + if recipient is None: + # Аудит: правило направило, но адресовать некому — + # админ увидит причину в /admin/deliveries. + db.add( + Delivery( + event_id=event.id, + rule_id=rule.id, + channel=act.channel, + status="skipped", + attempts=0, + rendered_message=_render( + act.template or rule.template or "", event, source.name + ), + last_error="payload.user_id не задан — некому адресовать", + ) + ) + n_skipped += 1 + continue + db.add( + Delivery( + event_id=event.id, + rule_id=rule.id, + channel=act.channel, + recipient_user_id=recipient, + status="delivered", + attempts=1, + rendered_message=_render( + act.template or rule.template or "", event, source.name + ), + delivered_at=now, + ) + ) + continue + # Фактическая отправка (internal_log уже ушёл выше): первая # попытка сразу на маршрутизации; провал → ретрай-цикл. delivery = Delivery( diff --git a/docs/05-ingestion-api.md b/docs/05-ingestion-api.md index 25482f8..a8c005b 100644 --- a/docs/05-ingestion-api.md +++ b/docs/05-ingestion-api.md @@ -44,6 +44,10 @@ Не существует и не появится в клиентском контракте: `recipients`, `topics`, `channel` — получатели/каналы/подписки живут только внутри Synapse (правила в админке). `tags` из обсуждений **убраны** из v1; вернутся, когда появится реальное правило, требующее тега (решение обсуждалось 2026-10-03). +### Конвенция `payload.user_id` (соглашение 2026-10-03) + +Если событие связано с конкретным пользователем, источник кладёт его id gnexus-auth (`sub`) в `payload.user_id` — это часть «что случилось», а не получатель: конверт по-прежнему ничего не знает о получателях. Что Synapse делает с этой привязкой — решают правила: событие попадает в **личный лог пользователя** (`/api/v1/me/events`) только если подошло правило маршрутизации с действием канала `user` (цель не задаётся); правила без такого действия — «событие только для админа». Позже тот же механизм направит push-каналы (#29) конкретному пользователю. + ## Ответы | Код | Когда | @@ -95,6 +99,8 @@ Действия — по одному на строку: канал + цель (`telegram:infra-alerts`, `internal_log`, позднее `email`, `s2s:navi-rei`) + опциональный шаблон (Jinja2, `{{ payload.x }}`; шаблон действия переопределяет шаблон правила). Шаблон обязан быть устойчив к отсутствию полей payload. +Каналы без цели: `internal_log` (журнал Synapse) и `user` (личный лог пользователя — адресат из `payload.user_id`; событие без этого поля пишется как `skipped` с причиной, админ видит её в `/admin/deliveries`). Событие попадает пользователю в личный лог (`/api/v1/me/events`) только через правило с действием `user`. + ### Сколько правил применяется Задаёт настройка `ROUTING_MATCH_MODE`: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a6eb932..affcb95 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,8 +3,9 @@ // Страницы входа/отсечения идут без каркаса (meta.bare в роутере). // // Футер дровер — карточка профиля (GnUserCard) с кнопкой выхода; здесь же живёт -// сессия: /api/v1/admin/me каждые 45 c (single sign-out: вебхук отзывает токены, -// heartbeat ловит 401 и уводит на /login). +// сессия: GET /api/v1/me каждые 45 c (single sign-out: вебхук отзывает токены, +// heartbeat ловит 401 и уводит на /login). Роль из того же ответа решает, +// какая навигация — админская или личный раздел. // // :key="route.path" — обход ограничения vue-адаптера кита: GnNavList читает // route.path ($route) один раз при первом рендере, а vue-router отдаёт $route @@ -14,14 +15,15 @@ import { computed, onUnmounted, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { GnIdentity, GnIconButton, GnNavigationShell, GnToastProvider } from "gnexus-ui-kit/vue"; -import { adminMe, clearToken, logout } from "./api.js"; +import { clearToken, logout, me } from "./api.js"; const route = useRoute(); const router = useRouter(); -// Секции админки; иконки — только Phosphor (правила кита). -// pulse/shapes в шрифте Phosphor из кита отсутствуют — берём activity/squares-four. -const items = [ +// Навигация по роли. Администратор — вся админка; обычный пользователь — +// только личный раздел (роль из GET /api/v1/me, гейт дублирует require_admin). +// Иконки — только Phosphor (правила кита); pulse/shapes в шрифте кита отсутствуют. +const adminItems = [ { to: "/dashboard", label: "Обзор", icon: "ph-activity" }, { to: "/sources", label: "Источники", icon: "ph-broadcast" }, { to: "/types", label: "Типы", icon: "ph-squares-four" }, @@ -29,7 +31,7 @@ { to: "/rules", label: "Правила", icon: "ph-funnel" }, { to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" }, ]; -const currents = { +const adminCurrents = { "/dashboard": "Обзор", "/sources": "Источники", "/types": "Типы", @@ -37,26 +39,35 @@ "/rules": "Правила", "/deliveries": "Доставки", }; +const items = computed(() => + profile.value?.system_role === "user" + ? [{ to: "/my-events", label: "Мои события", icon: "ph-user" }] + : adminItems, +); // Префиксно: /sources/7 тоже подписывается «Источники». -const current = computed( - () => - Object.entries(currents) +const current = computed(() => { + const paths = + profile.value?.system_role === "user" ? { "/my-events": "Мои события" } : adminCurrents; + return ( + Object.entries(paths) .filter(([p]) => route.path === p || route.path.startsWith(p + "/")) .sort((a, b) => b[0].length - a[0].length) - .map(([, v]) => v)[0] || "", -); + .map(([, v]) => v)[0] || "" + ); +}); const bare = computed(() => Boolean(route.meta.bare)); -const me = ref(null); +// Профиль/роль (не сессия): heartbeat ниже проверяет токен каждые 45 c. +const profile = ref(null); let heartbeatTimer = null; // Имя для карточки: display_name -> username -> email (userinfo profile scope). const displayName = computed( - () => me.value?.display_name || me.value?.username || me.value?.email || "", + () => profile.value?.display_name || profile.value?.username || profile.value?.email || "", ); const initials = computed(() => { - const src = displayName.value || me.value?.email || ""; + const src = displayName.value || profile.value?.email || ""; return src .split(/[.\s@_]+/) .filter(Boolean) @@ -70,8 +81,8 @@ // с толку (пользователь, глядящий на страницу, и так в сети). const identityAvatar = computed(() => ({ size: "sm", - ...(me.value?.avatar_url - ? { src: me.value.avatar_url, alt: displayName.value } + ...(profile.value?.avatar_url + ? { src: profile.value.avatar_url, alt: displayName.value } : { initials: initials.value }), })); @@ -84,6 +95,7 @@ function authFailure(ex) { stopHeartbeat(); + // 403 теперь почти недостижим (/me без ролевого гейта) — страховка. if (ex?.status === 403) router.push("/denied"); else if (ex?.status === 401) { clearToken(); @@ -93,8 +105,9 @@ async function loadProfile() { try { - me.value = await adminMe(); + profile.value = await me(); } catch (ex) { + profile.value = null; authFailure(ex); } } @@ -103,7 +116,7 @@ stopHeartbeat(); heartbeatTimer = setInterval(async () => { try { - me.value = await adminMe(); + profile.value = await me(); } catch (ex) { authFailure(ex); } @@ -114,7 +127,7 @@ bare, (isBare) => { if (isBare) { - me.value = null; + profile.value = null; stopHeartbeat(); } else { loadProfile(); @@ -128,7 +141,7 @@ async function signOut() { stopHeartbeat(); - me.value = null; + profile.value = null; await logout(); router.push("/login"); } @@ -148,15 +161,15 @@ diff --git a/frontend/src/api.js b/frontend/src/api.js index 4dddea0..3726a5a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -37,6 +37,7 @@ } export function clearToken() { + dropSession(); try { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); @@ -109,6 +110,36 @@ return apiFetch("/api/v1/admin/me"); } +// --- Личный кабинет (app/api/me_routes.py): любой аутентифицированный. --- + +/** Профиль: без ролевого гейта, role в ответе (system_role) — с этого гейт роутера. */ +export function me() { + return apiFetch("/api/v1/me"); +} + +// --- Сессия для роут-гейта: один общий промис, кешированный по access-токену. +// Тихое обновление токена меняет ключ кеша -> рефетч; параллельные вызовы +// сходятся в один полёт. Ошибка не кешируется навечно — гейт уведёт на /login. --- +let meCache = null; +let meCacheToken = ""; + +export function getSession() { + const token = getToken(); + if (!token) return Promise.resolve(null); + if (meCache && meCacheToken === token) return meCache; + meCacheToken = token; + meCache = me().catch((err) => { + meCache = null; + throw err; + }); + return meCache; +} + +function dropSession() { + meCache = null; + meCacheToken = ""; +} + // --- Admin API (app/api/admin_routes.py). Все эндпоинты под require_admin. --- export const api = { @@ -142,6 +173,9 @@ // Поток (только чтение) listEvents: (query = {}) => apiFetch(`/api/v1/admin/events?${new URLSearchParams(query)}`), listDeliveries: (query = {}) => apiFetch(`/api/v1/admin/deliveries?${new URLSearchParams(query)}`), + + // Личный кабинет + myEvents: (query = {}) => apiFetch(`/api/v1/me/events?${new URLSearchParams(query)}`), }; /** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */ diff --git a/frontend/src/components/RuleForm.vue b/frontend/src/components/RuleForm.vue index 264d46d..3956354 100644 --- a/frontend/src/components/RuleForm.vue +++ b/frontend/src/components/RuleForm.vue @@ -71,7 +71,7 @@ { value: "high", label: "high" }, { value: "critical", label: "critical" }, ]; -const CHANNELS = ["telegram", "email", "s2s", "internal_log"].map((c) => ({ value: c, label: c })); +const CHANNELS = ["telegram", "email", "s2s", "internal_log", "user"].map((c) => ({ value: c, label: c })); const WEIGHT_TIP = "Вес определяет порядок применения правил в режиме «первое подошедшее» (ROUTING_MATCH_MODE=first): меньше важнее, срабатывает раньше. В режиме «все» на результат не влияет."; @@ -164,7 +164,8 @@
Действия

Что делать, когда правило совпало: создать доставку в каждом канале ниже. internal_log без цели — запись в - журнал Synapse (аудит); s2s — подписанная отправка в сервис; telegram/email — каналы #29, пока не работают. + журнал Synapse (аудит); user — личный лог пользователя из payload.user_id; s2s — подписанная отправка в + сервис; telegram/email — каналы #29, пока не работают.

diff --git a/frontend/src/router.js b/frontend/src/router.js index ce556ba..92818ce 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -1,8 +1,9 @@ import { createRouter, createWebHistory } from "vue-router"; -import { clearToken, getToken } from "./api.js"; +import { clearToken, getToken, getSession } from "./api.js"; import LoginView from "./views/LoginView.vue"; import DashboardView from "./views/DashboardView.vue"; import DeniedView from "./views/DeniedView.vue"; +import MyEventsView from "./views/MyEventsView.vue"; import OauthCallbackView from "./views/OauthCallbackView.vue"; import SourcesView from "./views/SourcesView.vue"; import SourceDetailView from "./views/SourceDetailView.vue"; @@ -13,37 +14,55 @@ import RuleDetailView from "./views/RuleDetailView.vue"; import DeliveriesView from "./views/DeliveriesView.vue"; -// Гейт ролей: /dashboard и далее — только system_role admin и выше. -// Проверка роли происходит на сервере (require_admin), фронт лишь заранее -// разводит потоки 401 (нет токена -> login) и 403 (роль ниже admin -> denied). +// Гейт ролей. Сервер отсекает админ-API (require_admin -> 403); фронт заранее +// разводит путь: роль из GET /api/v1/me (см. getSession в api.js). Админ — +// вся админка; пользователь — только личный раздел «Мои события»; вход в +// приложения — любой аутентифицированный gnexus-auth. const router = createRouter({ history: createWebHistory(), routes: [ - { path: "/", redirect: "/dashboard" }, + // Личный раздел доступен любой роли ("/" и незнакомые пути разводятся + // в beforeEach по system_role: админ — в админку, user — в личный раздел). // bare — страницы без каркаса GnNavigationShell (см. App.vue) { path: "/login", component: LoginView, meta: { bare: true } }, { path: "/denied", component: DeniedView, meta: { bare: true } }, { path: "/oauth/callback", component: OauthCallbackView, meta: { bare: true } }, + // Личный раздел — любая роль; админские пути — requiresAdmin (гейт ниже). + { path: "/my-events", component: MyEventsView, meta: { userOk: true } }, { path: "/dashboard", component: DashboardView, - meta: { requiresAuth: true }, + meta: { requiresAdmin: true }, }, - { path: "/sources", component: SourcesView, meta: { requiresAuth: true } }, - { path: "/sources/:id", component: SourceDetailView, meta: { requiresAuth: true } }, - { path: "/types", component: TypesView, meta: { requiresAuth: true } }, - { path: "/targets", component: TargetsView, meta: { requiresAuth: true } }, - { path: "/targets/:id", component: TargetDetailView, meta: { requiresAuth: true } }, - { path: "/rules", component: RulesView, meta: { requiresAuth: true } }, - { path: "/rules/:id", component: RuleDetailView, meta: { requiresAuth: true } }, - { path: "/deliveries", component: DeliveriesView, meta: { requiresAuth: true } }, + { path: "/sources", component: SourcesView, meta: { requiresAdmin: true } }, + { path: "/sources/:id", component: SourceDetailView, meta: { requiresAdmin: true } }, + { path: "/types", component: TypesView, meta: { requiresAdmin: true } }, + { path: "/targets", component: TargetsView, meta: { requiresAdmin: true } }, + { path: "/targets/:id", component: TargetDetailView, meta: { requiresAdmin: true } }, + { path: "/rules", component: RulesView, meta: { requiresAdmin: true } }, + { path: "/rules/:id", component: RuleDetailView, meta: { requiresAdmin: true } }, + { path: "/deliveries", component: DeliveriesView, meta: { requiresAdmin: true } }, ], }); -router.beforeEach((to) => { - if (to.meta.requiresAuth && !getToken()) { +router.beforeEach(async (to) => { + if (to.meta.bare) return true; + if (!getToken()) return { path: "/login", query: { next: to.fullPath } }; + let session; + try { + session = await getSession(); + } catch { + // 401 (не валидируется/истёк даже после refresh) — сессии нет. + clearToken(); return { path: "/login", query: { next: to.fullPath } }; } + const isAdmin = + (session?.system_role || "") === "admin" || (session?.system_role || "") === "superadmin"; + if (to.path === "/") return isAdmin ? "/dashboard" : "/my-events"; + // Админские пути — admin+; роли user путь подменяется на личный раздел + // (сервер всё равно держит админ-API под 403, это двойная защита). + if (!to.meta.userOk && !isAdmin) return "/my-events"; + return true; }); export { router }; \ No newline at end of file diff --git a/frontend/src/ui.js b/frontend/src/ui.js index b3c6dde..c4cd2ff 100644 --- a/frontend/src/ui.js +++ b/frontend/src/ui.js @@ -1,4 +1,5 @@ -/** Общие мелочи для админ-разделов: бейджи статусов, формат времени, сокращение. */ +/** Общие мелочи для разметки (админка и личный раздел): бейджи статусов, приоритетов, + * каналов; формат времени, сокращение, подсветка JSON. */ import { h } from "vue"; import { GnTooltip } from "gnexus-ui-kit/vue"; @@ -19,9 +20,19 @@ return { label: status, variant: statusVariant[status] || "secondary" }; } -/** Вариант бейджа для канала: служебный internal_log нейтрален. */ +/** Вариант бейджа для канала: служебные internal_log и user — нейтральнее каналов доставки. */ export function badgeChannel(channel) { - return channel === "internal_log" ? "secondary" : "accent"; + return channel === "internal_log" + ? "secondary" + : channel === "user" + ? "info" + : "accent"; +} + +/** Вариант бейджа приоритета события (конверт: low/normal/high/critical). */ +export const priorityVariantMap = { low: "secondary", normal: "info", high: "warning", critical: "danger" }; +export function priorityVariant(priority) { + return priorityVariantMap[priority] || "secondary"; } // Время первым, как просил владелец: «03:44:35, 3 октября» (год — только не текущий). diff --git a/frontend/src/views/DeniedView.vue b/frontend/src/views/DeniedView.vue index e8274cf..ab210ce 100644 --- a/frontend/src/views/DeniedView.vue +++ b/frontend/src/views/DeniedView.vue @@ -17,7 +17,7 @@ variant="error" icon="ph-shield-warning" title="Доступ запрещён" - text="Админ-панель Synapse доступна только пользователям gnexus-auth с ролью admin и выше." + text="Настройки Synapse доступны роли admin и выше; личный раздел — по ссылке «Мои события». Обычно сюда попадают только по устаревшей ссылке на админ-часть." /> На страницу входа
diff --git a/frontend/src/views/MyEventsView.vue b/frontend/src/views/MyEventsView.vue new file mode 100644 index 0000000..7b1052c --- /dev/null +++ b/frontend/src/views/MyEventsView.vue @@ -0,0 +1,84 @@ + + + + + + \ No newline at end of file