Phase 10: Per-User Rate Limits — messages + tokens, quota UI, admin usage

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:44:51 +03:00
parent bb53eeee8e
commit d86d53f473
17 changed files with 390 additions and 17 deletions

View File

@@ -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 - Summary: OAuth (Google, GitHub), account switching UI, multiple stored sessions
### Phase 10: Per-User Rate Limits ### Phase 10: Per-User Rate Limits
- **Status**: NOT STARTED - **Status**: COMPLETED
- [ ] Subplan created (`plans/phase-10-rate-limits.md`) - [x] Subplan created (`plans/phase-10-rate-limits.md`)
- [ ] Phase completed - [x] Phase completed
- Summary: Per-user AI message rate limits, admin-configurable limits, usage tracking - Summary: Per-user AI message + token rate limits, admin-configurable defaults, usage tracking dashboard
--- ---

View File

@@ -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')")

View File

@@ -218,3 +218,32 @@ async def preview_pdf_template(
): ):
html = await pdf_template_service.render_preview(data.html_content) html = await pdf_template_service.render_preview(data.html_content)
return PreviewResponse(html=html) 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}

View File

@@ -19,10 +19,19 @@ from app.schemas.chat import (
) )
from app.services import chat_service, skill_service from app.services import chat_service, skill_service
from app.services.ai_service import stream_ai_response 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 = 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) @router.post("/", response_model=ChatResponse, status_code=status.HTTP_201_CREATED)
async def create_chat( async def create_chat(
data: CreateChatRequest, data: CreateChatRequest,
@@ -96,6 +105,13 @@ async def send_message(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)], 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( return StreamingResponse(
stream_ai_response(db, chat_id, user.id, data.content), stream_ai_response(db, chat_id, user.id, data.content),
media_type="text/event-stream", media_type="text/event-stream",

View File

@@ -16,6 +16,8 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
max_chats: Mapped[int] = mapped_column(Integer, nullable=False, default=10) 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: Mapped[str | None] = mapped_column(String(50), nullable=True)
oauth_provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True) oauth_provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
telegram_chat_id: Mapped[int | None] = mapped_column(nullable=True) telegram_chat_id: Mapped[int | None] = mapped_column(nullable=True)

View File

@@ -14,6 +14,8 @@ class AdminUserResponse(BaseModel):
role: str role: str
is_active: bool is_active: bool
max_chats: int max_chats: int
max_ai_messages_per_day: int | None
max_ai_tokens_per_day: int | None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -26,12 +28,16 @@ class AdminUserCreateRequest(BaseModel):
full_name: str | None = None full_name: str | None = None
role: Literal["user", "admin"] = "user" role: Literal["user", "admin"] = "user"
max_chats: int = 10 max_chats: int = 10
max_ai_messages_per_day: int | None = None
max_ai_tokens_per_day: int | None = None
class AdminUserUpdateRequest(BaseModel): class AdminUserUpdateRequest(BaseModel):
role: Literal["user", "admin"] | None = None role: Literal["user", "admin"] | None = None
is_active: bool | None = None is_active: bool | None = None
max_chats: int | 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 full_name: str | None = None

View File

@@ -49,7 +49,7 @@ async def create_user(
async def update_user(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> User: async def update_user(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> User:
user = await get_user(db, user_id) 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(): for key, value in kwargs.items():
if key in allowed and value is not None: if key in allowed and value is not None:
setattr(user, key, value) setattr(user, key, value)

View File

@@ -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(),
}

View File

@@ -39,7 +39,8 @@
"skills": "Skills", "skills": "Skills",
"personal_context": "My Context", "personal_context": "My Context",
"pdf": "PDF Reports", "pdf": "PDF Reports",
"pdf_templates": "Templates" "pdf_templates": "Templates",
"usage": "Usage"
}, },
"dashboard": { "dashboard": {
"welcome": "Welcome, {{name}}", "welcome": "Welcome, {{name}}",
@@ -56,7 +57,9 @@
"unarchive": "Unarchive", "unarchive": "Unarchive",
"delete_confirm": "Are you sure you want to delete this chat?", "delete_confirm": "Are you sure you want to delete this chat?",
"limit_reached": "Chat limit reached", "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": { "admin": {
"context_editor": "Primary Context Editor", "context_editor": "Primary Context Editor",
@@ -197,6 +200,13 @@
"default_max_chats": "Default Max Chats", "default_max_chats": "Default Max Chats",
"default_max_chats_desc": "Default chat limit for new users" "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": { "common": {
"loading": "Loading...", "loading": "Loading...",
"error": "An error occurred", "error": "An error occurred",

View File

@@ -39,7 +39,8 @@
"skills": "Навыки", "skills": "Навыки",
"personal_context": "Мой контекст", "personal_context": "Мой контекст",
"pdf": "PDF отчёты", "pdf": "PDF отчёты",
"pdf_templates": "Шаблоны" "pdf_templates": "Шаблоны",
"usage": "Использование"
}, },
"dashboard": { "dashboard": {
"welcome": "Добро пожаловать, {{name}}", "welcome": "Добро пожаловать, {{name}}",
@@ -56,7 +57,9 @@
"unarchive": "Разархивировать", "unarchive": "Разархивировать",
"delete_confirm": "Вы уверены, что хотите удалить этот чат?", "delete_confirm": "Вы уверены, что хотите удалить этот чат?",
"limit_reached": "Достигнут лимит чатов", "limit_reached": "Достигнут лимит чатов",
"streaming": "ИИ думает..." "streaming": "ИИ думает...",
"quota_messages": "{{used}}/{{limit}} сообщений",
"quota_tokens": "{{used}}K/{{limit}}K токенов"
}, },
"admin": { "admin": {
"context_editor": "Редактор основного контекста", "context_editor": "Редактор основного контекста",
@@ -197,6 +200,13 @@
"default_max_chats": "Лимит чатов по умолчанию", "default_max_chats": "Лимит чатов по умолчанию",
"default_max_chats_desc": "Лимит чатов для новых пользователей" "default_max_chats_desc": "Лимит чатов для новых пользователей"
}, },
"admin_usage": {
"title": "Статистика использования",
"user": "Пользователь",
"messages": "Сообщения сегодня",
"tokens": "Токены сегодня",
"no_data": "Данных пока нет."
},
"common": { "common": {
"loading": "Загрузка...", "loading": "Загрузка...",
"error": "Произошла ошибка", "error": "Произошла ошибка",

View File

@@ -56,6 +56,21 @@ export async function deleteChat(chatId: string): Promise<void> {
await api.delete(`/chats/${chatId}`); 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<QuotaInfo> {
const { data } = await api.get<QuotaInfo>("/chats/quota");
return data;
}
export async function getMessages( export async function getMessages(
chatId: string, chatId: string,
limit = 50, limit = 50,

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { MessageBubble } from "./message-bubble"; import { MessageBubble } from "./message-bubble";
import { MessageInput } from "./message-input"; import { MessageInput } from "./message-input";
import { SkillSelector } from "./skill-selector"; import { SkillSelector } from "./skill-selector";
import { useChatStore } from "@/stores/chat-store"; import { useChatStore } from "@/stores/chat-store";
import { getQuota } from "@/api/chats";
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
interface ChatWindowProps { interface ChatWindowProps {
@@ -16,6 +18,11 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha
const { t } = useTranslation(); const { t } = useTranslation();
const { messages, isStreaming, streamingContent, currentChatId } = useChatStore(); const { messages, isStreaming, streamingContent, currentChatId } = useChatStore();
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const { data: quota } = useQuery({
queryKey: ["chat-quota"],
queryFn: getQuota,
refetchInterval: 30000,
});
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -32,15 +39,25 @@ export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: Cha
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{onChangeSkill && ( {(onChangeSkill || quota) && (
<div className="flex items-center gap-2 border-b px-4 py-2"> <div className="flex items-center gap-2 border-b px-4 py-2">
<div className="w-48"> {quota && (
<SkillSelector <span className={`text-xs ${quota.messages_exceeded || quota.tokens_exceeded ? "text-destructive" : "text-muted-foreground"}`}>
value={currentSkillId ?? null} {t("chat.quota_messages", { used: quota.messages_used, limit: quota.message_limit })}
onChange={onChangeSkill} {" · "}
disabled={isStreaming} {t("chat.quota_tokens", { used: Math.round(quota.tokens_used / 1000), limit: Math.round(quota.token_limit / 1000) })}
/> </span>
</div> )}
<div className="flex-1" />
{onChangeSkill && (
<div className="w-48">
<SkillSelector
value={currentSkillId ?? null}
onChange={onChangeSkill}
disabled={isStreaming}
/>
</div>
)}
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">

View File

@@ -32,6 +32,7 @@ const adminItems = [
{ key: "admin_users", to: "/admin/users", label: "layout.users" }, { key: "admin_users", to: "/admin/users", label: "layout.users" },
{ key: "admin_settings", to: "/admin/settings", label: "layout.settings" }, { key: "admin_settings", to: "/admin/settings", label: "layout.settings" },
{ key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" }, { key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" },
{ key: "admin_usage", to: "/admin/usage", label: "layout.usage" },
]; ];
export function Sidebar() { export function Sidebar() {

View File

@@ -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 <Navigate to="/" replace />;
const users = data || [];
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">{t("admin_usage.title")}</h1>
<div className="rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("admin_usage.user")}</th>
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.messages")}</th>
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.tokens")}</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.user_id} className="border-b last:border-0">
<td className="px-4 py-3">{u.username}</td>
<td className="px-4 py-3 text-right">
<span className={cn(u.messages_today >= u.message_limit && "text-destructive font-medium")}>
{u.messages_today} / {u.message_limit}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className={cn(u.tokens_today >= u.token_limit && "text-destructive font-medium")}>
{Math.round(u.tokens_today / 1000)}K / {Math.round(u.token_limit / 1000)}K
</span>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{t("admin_usage.no_data")}</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import { PdfPage } from "@/pages/pdf";
import { AdminUsersPage } from "@/pages/admin/users"; import { AdminUsersPage } from "@/pages/admin/users";
import { AdminSettingsPage } from "@/pages/admin/settings"; import { AdminSettingsPage } from "@/pages/admin/settings";
import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates"; import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates";
import { AdminUsagePage } from "@/pages/admin/usage";
import { NotFoundPage } from "@/pages/not-found"; import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
@@ -47,6 +48,7 @@ export const router = createBrowserRouter([
{ path: "admin/users", element: <AdminUsersPage /> }, { path: "admin/users", element: <AdminUsersPage /> },
{ path: "admin/settings", element: <AdminSettingsPage /> }, { path: "admin/settings", element: <AdminSettingsPage /> },
{ path: "admin/pdf-templates", element: <AdminPdfTemplatesPage /> }, { path: "admin/pdf-templates", element: <AdminPdfTemplatesPage /> },
{ path: "admin/usage", element: <AdminUsagePage /> },
], ],
}, },
], ],

View File

@@ -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**

44
plans/phase-9-oauth.md Normal file
View File

@@ -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**