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