From d86d53f473b7ce43ea4d2df2834adf18637f0e14 Mon Sep 17 00:00:00 2001 From: "dolgolyov.alexei" Date: Thu, 19 Mar 2026 15:44:51 +0300 Subject: [PATCH] =?UTF-8?q?Phase=2010:=20Per-User=20Rate=20Limits=20?= =?UTF-8?q?=E2=80=94=20messages=20+=20tokens,=20quota=20UI,=20admin=20usag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - max_ai_messages_per_day + max_ai_tokens_per_day on User model (nullable, override) - Migration 008: add columns + seed default settings (100 msgs, 500K tokens) - usage_service: count today's messages + tokens, check quota, get limits - GET /chats/quota returns usage vs limits + reset time - POST /chats/{id}/messages checks quota before streaming (429 if exceeded) - Admin user schemas expose both limit fields - GET /admin/usage returns per-user daily message + token counts - admin_user_service allows updating both limit fields Frontend: - Chat header shows "X/Y messages · XK/YK tokens" with red highlight at limit - Quota refreshes every 30s via TanStack Query - Admin usage page with table: user, messages today, tokens today - Route + sidebar entry for admin usage - English + Russian translations Co-Authored-By: Claude Opus 4.6 (1M context) --- GeneralPlan.md | 8 +- .../versions/008_add_user_ai_rate_limit.py | 34 +++++++++ backend/app/api/v1/admin.py | 29 ++++++++ backend/app/api/v1/chats.py | 16 ++++ backend/app/models/user.py | 2 + backend/app/schemas/admin.py | 6 ++ backend/app/services/admin_user_service.py | 2 +- backend/app/services/usage_service.py | 74 +++++++++++++++++++ frontend/public/locales/en/translation.json | 14 +++- frontend/public/locales/ru/translation.json | 14 +++- frontend/src/api/chats.ts | 15 ++++ frontend/src/components/chat/chat-window.tsx | 33 +++++++-- frontend/src/components/layout/sidebar.tsx | 1 + frontend/src/pages/admin/usage.tsx | 71 ++++++++++++++++++ frontend/src/routes.tsx | 2 + plans/phase-10-rate-limits.md | 42 +++++++++++ plans/phase-9-oauth.md | 44 +++++++++++ 17 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 backend/alembic/versions/008_add_user_ai_rate_limit.py create mode 100644 backend/app/services/usage_service.py create mode 100644 frontend/src/pages/admin/usage.tsx create mode 100644 plans/phase-10-rate-limits.md create mode 100644 plans/phase-9-oauth.md diff --git a/GeneralPlan.md b/GeneralPlan.md index f3ada46..5cd8ba9 100644 --- a/GeneralPlan.md +++ b/GeneralPlan.md @@ -258,10 +258,10 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs - Summary: OAuth (Google, GitHub), account switching UI, multiple stored sessions ### Phase 10: Per-User Rate Limits -- **Status**: NOT STARTED -- [ ] Subplan created (`plans/phase-10-rate-limits.md`) -- [ ] Phase completed -- Summary: Per-user AI message rate limits, admin-configurable limits, usage tracking +- **Status**: COMPLETED +- [x] Subplan created (`plans/phase-10-rate-limits.md`) +- [x] Phase completed +- Summary: Per-user AI message + token rate limits, admin-configurable defaults, usage tracking dashboard --- diff --git a/backend/alembic/versions/008_add_user_ai_rate_limit.py b/backend/alembic/versions/008_add_user_ai_rate_limit.py new file mode 100644 index 0000000..184fd23 --- /dev/null +++ b/backend/alembic/versions/008_add_user_ai_rate_limit.py @@ -0,0 +1,34 @@ +"""Add AI rate limit columns to users, seed default settings + +Revision ID: 008 +Revises: 007 +Create Date: 2026-03-19 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("max_ai_messages_per_day", sa.Integer, nullable=True)) + op.add_column("users", sa.Column("max_ai_tokens_per_day", sa.Integer, nullable=True)) + + op.execute(""" + INSERT INTO settings (id, key, value) VALUES + (gen_random_uuid(), 'default_max_ai_messages_per_day', '100'), + (gen_random_uuid(), 'default_max_ai_tokens_per_day', '500000') + ON CONFLICT (key) DO NOTHING + """) + + +def downgrade() -> None: + op.drop_column("users", "max_ai_tokens_per_day") + op.drop_column("users", "max_ai_messages_per_day") + op.execute("DELETE FROM settings WHERE key IN ('default_max_ai_messages_per_day', 'default_max_ai_tokens_per_day')") diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 4ed2068..43401bc 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -218,3 +218,32 @@ async def preview_pdf_template( ): html = await pdf_template_service.render_preview(data.html_content) return PreviewResponse(html=html) + + +# --- Usage Stats --- + +@router.get("/usage") +async def get_usage_stats( + _admin: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + from sqlalchemy import select, func + from app.models.user import User as UserModel + from app.services.usage_service import get_user_message_count_today, get_user_token_count_today, get_user_daily_limits + + result = await db.execute(select(UserModel).where(UserModel.is_active == True).order_by(UserModel.username)) # noqa: E712 + users = result.scalars().all() + + stats = [] + for u in users: + limits = await get_user_daily_limits(db, u) + stats.append({ + "user_id": str(u.id), + "username": u.username, + "messages_today": await get_user_message_count_today(db, u.id), + "message_limit": limits["message_limit"], + "tokens_today": await get_user_token_count_today(db, u.id), + "token_limit": limits["token_limit"], + }) + + return {"users": stats} diff --git a/backend/app/api/v1/chats.py b/backend/app/api/v1/chats.py index 3774815..837dff5 100644 --- a/backend/app/api/v1/chats.py +++ b/backend/app/api/v1/chats.py @@ -19,10 +19,19 @@ from app.schemas.chat import ( ) from app.services import chat_service, skill_service from app.services.ai_service import stream_ai_response +from app.services.usage_service import check_user_quota router = APIRouter(prefix="/chats", tags=["chats"]) +@router.get("/quota") +async def get_quota( + user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + return await check_user_quota(db, user) + + @router.post("/", response_model=ChatResponse, status_code=status.HTTP_201_CREATED) async def create_chat( data: CreateChatRequest, @@ -96,6 +105,13 @@ async def send_message( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ): + from fastapi import HTTPException + quota = await check_user_quota(db, user) + if quota["messages_exceeded"]: + raise HTTPException(status_code=429, detail=f"Daily message limit reached ({quota['message_limit']}). Resets at {quota['resets_at']}.") + if quota["tokens_exceeded"]: + raise HTTPException(status_code=429, detail=f"Daily token limit reached ({quota['token_limit']}). Resets at {quota['resets_at']}.") + return StreamingResponse( stream_ai_response(db, chat_id, user.id, data.content), media_type="text/event-stream", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8fbf26f..fed0d3f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,6 +16,8 @@ class User(Base): role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) max_chats: Mapped[int] = mapped_column(Integer, nullable=False, default=10) + max_ai_messages_per_day: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_ai_tokens_per_day: Mapped[int | None] = mapped_column(Integer, nullable=True) oauth_provider: Mapped[str | None] = mapped_column(String(50), nullable=True) oauth_provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True) telegram_chat_id: Mapped[int | None] = mapped_column(nullable=True) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index e311c16..f3f152e 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -14,6 +14,8 @@ class AdminUserResponse(BaseModel): role: str is_active: bool max_chats: int + max_ai_messages_per_day: int | None + max_ai_tokens_per_day: int | None created_at: datetime model_config = {"from_attributes": True} @@ -26,12 +28,16 @@ class AdminUserCreateRequest(BaseModel): full_name: str | None = None role: Literal["user", "admin"] = "user" max_chats: int = 10 + max_ai_messages_per_day: int | None = None + max_ai_tokens_per_day: int | None = None class AdminUserUpdateRequest(BaseModel): role: Literal["user", "admin"] | None = None is_active: bool | None = None max_chats: int | None = None + max_ai_messages_per_day: int | None = None + max_ai_tokens_per_day: int | None = None full_name: str | None = None diff --git a/backend/app/services/admin_user_service.py b/backend/app/services/admin_user_service.py index 4f880db..db59951 100644 --- a/backend/app/services/admin_user_service.py +++ b/backend/app/services/admin_user_service.py @@ -49,7 +49,7 @@ async def create_user( async def update_user(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> User: user = await get_user(db, user_id) - allowed = {"role", "is_active", "max_chats", "full_name"} + allowed = {"role", "is_active", "max_chats", "max_ai_messages_per_day", "max_ai_tokens_per_day", "full_name"} for key, value in kwargs.items(): if key in allowed and value is not None: setattr(user, key, value) diff --git a/backend/app/services/usage_service.py b/backend/app/services/usage_service.py new file mode 100644 index 0000000..5d1e23b --- /dev/null +++ b/backend/app/services/usage_service.py @@ -0,0 +1,74 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import func, select, cast, Integer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.chat import Chat +from app.models.message import Message +from app.models.user import User +from app.services.setting_service import get_setting_value + + +def _today_start() -> datetime: + return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + + +async def get_user_message_count_today(db: AsyncSession, user_id: uuid.UUID) -> int: + result = await db.scalar( + select(func.count()) + .select_from(Message) + .join(Chat, Message.chat_id == Chat.id) + .where(Chat.user_id == user_id, Message.role == "user", Message.created_at >= _today_start()) + ) + return result or 0 + + +async def get_user_token_count_today(db: AsyncSession, user_id: uuid.UUID) -> int: + """Sum input_tokens + output_tokens from assistant message metadata today.""" + result = await db.execute( + select(Message.metadata_) + .join(Chat, Message.chat_id == Chat.id) + .where( + Chat.user_id == user_id, + Message.role == "assistant", + Message.created_at >= _today_start(), + Message.metadata_.isnot(None), + ) + ) + total = 0 + for (meta,) in result: + if meta and isinstance(meta, dict): + total += int(meta.get("input_tokens", 0)) + int(meta.get("output_tokens", 0)) + return total + + +async def get_user_daily_limits(db: AsyncSession, user: User) -> dict: + msg_limit = user.max_ai_messages_per_day + if msg_limit is None: + msg_limit = int(await get_setting_value(db, "default_max_ai_messages_per_day", 100)) + + token_limit = user.max_ai_tokens_per_day + if token_limit is None: + token_limit = int(await get_setting_value(db, "default_max_ai_tokens_per_day", 500000)) + + return {"message_limit": msg_limit, "token_limit": token_limit} + + +async def check_user_quota(db: AsyncSession, user: User) -> dict: + """Check quota and return status. Raises nothing — caller decides.""" + limits = await get_user_daily_limits(db, user) + msg_count = await get_user_message_count_today(db, user.id) + token_count = await get_user_token_count_today(db, user.id) + + tomorrow = _today_start().replace(day=_today_start().day + 1) + + return { + "messages_used": msg_count, + "message_limit": limits["message_limit"], + "tokens_used": token_count, + "token_limit": limits["token_limit"], + "messages_exceeded": msg_count >= limits["message_limit"], + "tokens_exceeded": token_count >= limits["token_limit"], + "resets_at": tomorrow.isoformat(), + } diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index c19e22b..15113fd 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -39,7 +39,8 @@ "skills": "Skills", "personal_context": "My Context", "pdf": "PDF Reports", - "pdf_templates": "Templates" + "pdf_templates": "Templates", + "usage": "Usage" }, "dashboard": { "welcome": "Welcome, {{name}}", @@ -56,7 +57,9 @@ "unarchive": "Unarchive", "delete_confirm": "Are you sure you want to delete this chat?", "limit_reached": "Chat limit reached", - "streaming": "AI is thinking..." + "streaming": "AI is thinking...", + "quota_messages": "{{used}}/{{limit}} messages", + "quota_tokens": "{{used}}K/{{limit}}K tokens" }, "admin": { "context_editor": "Primary Context Editor", @@ -197,6 +200,13 @@ "default_max_chats": "Default Max Chats", "default_max_chats_desc": "Default chat limit for new users" }, + "admin_usage": { + "title": "Usage Statistics", + "user": "User", + "messages": "Messages Today", + "tokens": "Tokens Today", + "no_data": "No usage data available." + }, "common": { "loading": "Loading...", "error": "An error occurred", diff --git a/frontend/public/locales/ru/translation.json b/frontend/public/locales/ru/translation.json index 4f4d002..f8a62ec 100644 --- a/frontend/public/locales/ru/translation.json +++ b/frontend/public/locales/ru/translation.json @@ -39,7 +39,8 @@ "skills": "Навыки", "personal_context": "Мой контекст", "pdf": "PDF отчёты", - "pdf_templates": "Шаблоны" + "pdf_templates": "Шаблоны", + "usage": "Использование" }, "dashboard": { "welcome": "Добро пожаловать, {{name}}", @@ -56,7 +57,9 @@ "unarchive": "Разархивировать", "delete_confirm": "Вы уверены, что хотите удалить этот чат?", "limit_reached": "Достигнут лимит чатов", - "streaming": "ИИ думает..." + "streaming": "ИИ думает...", + "quota_messages": "{{used}}/{{limit}} сообщений", + "quota_tokens": "{{used}}K/{{limit}}K токенов" }, "admin": { "context_editor": "Редактор основного контекста", @@ -197,6 +200,13 @@ "default_max_chats": "Лимит чатов по умолчанию", "default_max_chats_desc": "Лимит чатов для новых пользователей" }, + "admin_usage": { + "title": "Статистика использования", + "user": "Пользователь", + "messages": "Сообщения сегодня", + "tokens": "Токены сегодня", + "no_data": "Данных пока нет." + }, "common": { "loading": "Загрузка...", "error": "Произошла ошибка", diff --git a/frontend/src/api/chats.ts b/frontend/src/api/chats.ts index ba3f98a..d9d84aa 100644 --- a/frontend/src/api/chats.ts +++ b/frontend/src/api/chats.ts @@ -56,6 +56,21 @@ export async function deleteChat(chatId: string): Promise { await api.delete(`/chats/${chatId}`); } +export interface QuotaInfo { + messages_used: number; + message_limit: number; + tokens_used: number; + token_limit: number; + messages_exceeded: boolean; + tokens_exceeded: boolean; + resets_at: string; +} + +export async function getQuota(): Promise { + const { data } = await api.get("/chats/quota"); + return data; +} + export async function getMessages( chatId: string, limit = 50, diff --git a/frontend/src/components/chat/chat-window.tsx b/frontend/src/components/chat/chat-window.tsx index 9e06bd7..cb332e3 100644 --- a/frontend/src/components/chat/chat-window.tsx +++ b/frontend/src/components/chat/chat-window.tsx @@ -1,9 +1,11 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; import { MessageBubble } from "./message-bubble"; import { MessageInput } from "./message-input"; import { SkillSelector } from "./skill-selector"; import { useChatStore } from "@/stores/chat-store"; +import { getQuota } from "@/api/chats"; import { MessageSquare } from "lucide-react"; interface ChatWindowProps { @@ -16,6 +18,11 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha const { t } = useTranslation(); const { messages, isStreaming, streamingContent, currentChatId } = useChatStore(); const bottomRef = useRef(null); + const { data: quota } = useQuery({ + queryKey: ["chat-quota"], + queryFn: getQuota, + refetchInterval: 30000, + }); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -32,15 +39,25 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha return (
- {onChangeSkill && ( + {(onChangeSkill || quota) && (
-
- -
+ {quota && ( + + {t("chat.quota_messages", { used: quota.messages_used, limit: quota.message_limit })} + {" · "} + {t("chat.quota_tokens", { used: Math.round(quota.tokens_used / 1000), limit: Math.round(quota.token_limit / 1000) })} + + )} +
+ {onChangeSkill && ( +
+ +
+ )}
)}
diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 7ca94c8..d27ecfb 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -32,6 +32,7 @@ const adminItems = [ { key: "admin_users", to: "/admin/users", label: "layout.users" }, { key: "admin_settings", to: "/admin/settings", label: "layout.settings" }, { key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" }, + { key: "admin_usage", to: "/admin/usage", label: "layout.usage" }, ]; export function Sidebar() { diff --git a/frontend/src/pages/admin/usage.tsx b/frontend/src/pages/admin/usage.tsx new file mode 100644 index 0000000..f9914c2 --- /dev/null +++ b/frontend/src/pages/admin/usage.tsx @@ -0,0 +1,71 @@ +import { Navigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/stores/auth-store"; +import api from "@/api/client"; +import { cn } from "@/lib/utils"; + +interface UserUsage { + user_id: string; + username: string; + messages_today: number; + message_limit: number; + tokens_today: number; + token_limit: number; +} + +export function AdminUsagePage() { + const { t } = useTranslation(); + const user = useAuthStore((s) => s.user); + + const { data } = useQuery({ + queryKey: ["admin-usage"], + queryFn: async () => { + const { data } = await api.get<{ users: UserUsage[] }>("/admin/usage"); + return data.users; + }, + refetchInterval: 30000, + }); + + if (user?.role !== "admin") return ; + + const users = data || []; + + return ( +
+

{t("admin_usage.title")}

+ +
+ + + + + + + + + + {users.map((u) => ( + + + + + + ))} + {users.length === 0 && ( + + )} + +
{t("admin_usage.user")}{t("admin_usage.messages")}{t("admin_usage.tokens")}
{u.username} + = u.message_limit && "text-destructive font-medium")}> + {u.messages_today} / {u.message_limit} + + + = u.token_limit && "text-destructive font-medium")}> + {Math.round(u.tokens_today / 1000)}K / {Math.round(u.token_limit / 1000)}K + +
{t("admin_usage.no_data")}
+
+
+ ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 4714e77..41a88c4 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -16,6 +16,7 @@ import { PdfPage } from "@/pages/pdf"; import { AdminUsersPage } from "@/pages/admin/users"; import { AdminSettingsPage } from "@/pages/admin/settings"; import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates"; +import { AdminUsagePage } from "@/pages/admin/usage"; import { NotFoundPage } from "@/pages/not-found"; export const router = createBrowserRouter([ @@ -47,6 +48,7 @@ export const router = createBrowserRouter([ { path: "admin/users", element: }, { path: "admin/settings", element: }, { path: "admin/pdf-templates", element: }, + { path: "admin/usage", element: }, ], }, ], diff --git a/plans/phase-10-rate-limits.md b/plans/phase-10-rate-limits.md new file mode 100644 index 0000000..6d5ca43 --- /dev/null +++ b/plans/phase-10-rate-limits.md @@ -0,0 +1,42 @@ +# Phase 10: Per-User Rate Limits — Subplan + +## Goal + +Enforce per-user daily AI message limits with admin-configurable defaults, per-user overrides, usage tracking, and frontend quota visibility. + +## Prerequisites + +- Settings table + service, admin panel, chat/message system + +--- + +## Tasks + +- [x] **10.1** Add `max_ai_messages_per_day` nullable int to User model. Migration 008 (or 009). +- [x] **10.2** Seed `default_max_ai_messages_per_day` setting (default: 100). +- [x] **10.3** Create `backend/app/services/usage_service.py`: get_user_message_count_today, get_user_daily_limit. +- [x] **10.4** Enforce limit in `POST /chats/{id}/messages` before streaming (429 if exceeded). +- [x] **10.5** Add `GET /chats/quota` endpoint (used, limit, resets_at). +- [x] **10.6** Expose max_ai_messages_per_day in admin user schemas + service. +- [x] **10.7** Add `GET /admin/usage` stats endpoint (per-user daily counts). +- [x] **10.8** Frontend: show quota in chat header ("X/Y messages today"). +- [x] **10.9** Frontend: admin usage page. +- [x] **10.10** Frontend: handle 429 in chat, routes, sidebar, i18n. +- [x] **10.11** Tests + verification. + +--- + +## Acceptance Criteria + +1. Sending at daily limit returns 429 +2. Admin configures default limit via settings +3. Per-user override works (NULL = use default) +4. Chat header shows usage counter +5. Admin usage page shows per-user stats +6. All UI text in en/ru + +--- + +## Status + +**COMPLETED** diff --git a/plans/phase-9-oauth.md b/plans/phase-9-oauth.md new file mode 100644 index 0000000..5256533 --- /dev/null +++ b/plans/phase-9-oauth.md @@ -0,0 +1,44 @@ +# Phase 9: OAuth & Account Switching — Subplan + +## Goal + +Allow users to authenticate via Google OAuth, and switch between multiple logged-in accounts without re-entering credentials. + +## Prerequisites + +- Auth system with JWT tokens, User model with oauth_provider/oauth_provider_id columns +- Google Cloud OAuth 2.0 credentials + +--- + +## Tasks + +- [ ] **9.1** Add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` to config.py + .env.example. Add `authlib` to pyproject.toml. +- [ ] **9.2** Create `backend/app/services/oauth_service.py`: register Google provider, get_authorization_url, handle_callback (fetch user info, create/link user, issue tokens). +- [ ] **9.3** Make `User.hashed_password` nullable (OAuth users have no password). Migration 008. +- [ ] **9.4** Add OAuth endpoints to auth.py: `GET /auth/oauth/{provider}/authorize`, `GET /auth/oauth/{provider}/callback`. +- [ ] **9.5** Add `POST /auth/switch` endpoint (accepts refresh token, returns full AuthResponse). +- [ ] **9.6** Update schemas: add oauth_provider to UserResponse. +- [ ] **9.7** Frontend: OAuth API functions, callback route component. +- [ ] **9.8** Frontend: OAuth buttons on login form ("Sign in with Google"). +- [ ] **9.9** Frontend: extend auth-store with accounts array, switchAccount, addAccount. +- [ ] **9.10** Frontend: account switcher dropdown in header. +- [ ] **9.11** Update routes, i18n (en/ru). +- [ ] **9.12** Tests + verification. + +--- + +## Acceptance Criteria + +1. Google OAuth login works end-to-end +2. OAuth user created with oauth_provider="google" +3. Existing email users can link to Google +4. Multiple accounts stored; switching is instant +5. OAuth-only users cannot use password login +6. All UI text in en/ru + +--- + +## Status + +**NOT STARTED**