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:
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
34
backend/alembic/versions/008_add_user_ai_rate_limit.py
Normal file
34
backend/alembic/versions/008_add_user_ai_rate_limit.py
Normal 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')")
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
74
backend/app/services/usage_service.py
Normal file
74
backend/app/services/usage_service.py
Normal 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(),
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Произошла ошибка",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
71
frontend/src/pages/admin/usage.tsx
Normal file
71
frontend/src/pages/admin/usage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
42
plans/phase-10-rate-limits.md
Normal file
42
plans/phase-10-rate-limits.md
Normal 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
44
plans/phase-9-oauth.md
Normal 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**
|
||||||
Reference in New Issue
Block a user