diff --git a/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md b/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md index b7dd6c9ec..f0e1f3ca0 100644 --- a/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md +++ b/_bmad-output/implementation-artifacts/5-2-stripe-payment-integration.md @@ -13,6 +13,8 @@ so that tôi có thể điền thông tin thẻ tín dụng mà không sợ bị 1. Khi User bấm thanh toán, BE gọi API Stripe lấy `sessionId` với **`mode='subscription'`** (recurring billing, không phải one-time payment). 2. Hệ thống redirect User an toàn qua cổng Stripe Hosted Checkout. 3. Sau thanh toán thành công, user được redirect về app với subscription activated. +4. **[Admin-approval mode]** Khi `STRIPE_SECRET_KEY` chưa được cấu hình, endpoint trả về `{ checkout_url: "", admin_approval_mode: true }` thay vì gọi Stripe — frontend hiển thị toast "Subscription request submitted! An admin will approve it shortly." và không redirect. +5. **[Admin-approval mode]** Nếu user đã có request đang pending, endpoint trả về 409 Conflict để tránh duplicate. ## As-Is (Code hiện tại) @@ -40,6 +42,15 @@ so that tôi có thể điền thông tin thẻ tín dụng mà không sợ bị - [x] Subtask 2.4: Gọi `stripe.checkout.sessions.create(mode='subscription', customer=stripe_customer_id, ...)`. - [x] Subtask 2.5: Trả về `{ "checkout_url": "https://checkout.stripe.com/..." }`. +- [x] Task 2b: Admin-approval fallback khi Stripe chưa cấu hình + - [x] Subtask 2b.1: Kiểm tra `config.STRIPE_SECRET_KEY` ở đầu checkout endpoint — nếu falsy, bỏ qua toàn bộ Stripe logic. + - [x] Subtask 2b.2: Guard active subscription: nếu `user.subscription_status == ACTIVE` → 409. + - [x] Subtask 2b.3: Guard duplicate pending request: query `SubscriptionRequest` table — nếu đã có pending → 409. + - [x] Subtask 2b.4: Tạo `SubscriptionRequest(user_id, plan_id)` row và commit. + - [x] Subtask 2b.5: Trả về `CreateSubscriptionCheckoutResponse(checkout_url="", admin_approval_mode=True)`. + - [x] Subtask 2b.6: Thêm `admin_approval_mode: bool = False` vào `CreateSubscriptionCheckoutResponse` schema. + - [x] Subtask 2b.7: Frontend `handleUpgradePro()` — nếu `data.admin_approval_mode` là `true`, hiển thị toast thành công và return (không redirect). + - [x] Task 3: Kết nối Frontend với Endpoint mới - [x] Subtask 3.1: `pricing-section.tsx` đã gọi endpoint với `plan_id` — done trong Story 5.1. - [x] Subtask 3.2: Redirect đến `checkout_url` — done trong Story 5.1. @@ -79,9 +90,11 @@ Sau checkout, Stripe sẽ gửi `checkout.session.completed` → webhook handler ### File List - `surfsense_backend/app/config/__init__.py` — added `STRIPE_PRO_MONTHLY_PRICE_ID`, `STRIPE_PRO_YEARLY_PRICE_ID` -- `surfsense_backend/app/schemas/stripe.py` — added `PlanId` enum, `CreateSubscriptionCheckoutRequest`, `CreateSubscriptionCheckoutResponse` -- `surfsense_backend/app/routes/stripe_routes.py` — added `_get_subscription_success_url`, `_get_price_id_for_plan`, `get_or_create_stripe_customer`, `POST /create-subscription-checkout` +- `surfsense_backend/app/schemas/stripe.py` — added `PlanId` enum, `CreateSubscriptionCheckoutRequest`, `CreateSubscriptionCheckoutResponse` (including `admin_approval_mode: bool = False`) +- `surfsense_backend/app/routes/stripe_routes.py` — added `_get_subscription_success_url`, `_get_price_id_for_plan`, `get_or_create_stripe_customer`, `POST /create-subscription-checkout` + admin-approval fallback branch - `surfsense_web/app/subscription-success/page.tsx` — new success page with toast + user query invalidation +- `surfsense_web/components/pricing/pricing-section.tsx` — added `admin_approval_mode` toast handling in `handleUpgradePro()` +- *(See Story 5.5 for admin-approval infrastructure: migrations 126/127, `SubscriptionRequest` model, admin routes, admin UI page)* ### Review Findings @@ -96,3 +109,4 @@ Sau checkout, Stripe sẽ gửi `checkout.session.completed` → webhook handler ### Change Log - 2026-04-14: Implement subscription checkout endpoint with Stripe customer creation and success page. +- 2026-04-15: Add admin-approval fallback mode — when `STRIPE_SECRET_KEY` is not configured, checkout endpoint creates a `SubscriptionRequest` row instead of calling Stripe (see Story 5.5). diff --git a/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md b/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md index b70f99f7d..4d3b55de6 100644 --- a/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md +++ b/_bmad-output/implementation-artifacts/5-3-stripe-webhook-sync.md @@ -116,3 +116,7 @@ Webhook endpoint **PHẢI** parse raw body bằng `await request.body()` TRƯỚ ### Change Log - 2026-04-15: Implement Stripe webhook subscription event handlers — subscription lifecycle, invoice payment reset, checkout activation. - 2026-04-15: Code review patches applied — 12 fixes: pages_limit upgrade/downgrade, period_end guards, token reset billing reasons, idempotency plan_id check, UTC date fix, unrecognized price ID warning. + +## Review Findings (2026-04-15) + +- [x] [Review][Patch] Unrecognized Stripe price ID silently downgrades active subscription to "free" [stripe_routes.py] — **Fixed**: Added early return when plan_id=="free" but subscription status is "active" and price ID was unrecognized diff --git a/_bmad-output/implementation-artifacts/5-4-usage-tracking-rate-limit-enforcement.md b/_bmad-output/implementation-artifacts/5-4-usage-tracking-rate-limit-enforcement.md index 3571ec267..d2957fe47 100644 --- a/_bmad-output/implementation-artifacts/5-4-usage-tracking-rate-limit-enforcement.md +++ b/_bmad-output/implementation-artifacts/5-4-usage-tracking-rate-limit-enforcement.md @@ -1,6 +1,6 @@ # Story 5.4: Hệ thống Khóa Tác vụ dựa trên Hạn Mức (Usage Tracking & Rate Limit Enforcement) -Status: ready-for-dev +Status: done ## Story @@ -35,35 +35,28 @@ so that mô hình kinh doanh không bị lỗ do chi phí LLM và Storage, áp d ## Tasks / Subtasks -- [ ] Task 1: Thêm Page Quota Pre-check vào Document Upload Route (Backend) - - [ ] Subtask 1.1: Tìm document upload route (search trong `surfsense_backend/app/routes/`). - - [ ] Subtask 1.2: Inject `PageLimitService`, gọi `estimate_pages_from_metadata(filename, file_size)` rồi `check_page_limit(user_id, estimated_pages)`. - - [ ] Subtask 1.3: Nếu `PageLimitExceededError` → raise `HTTPException(402, detail={"error": "page_quota_exceeded", "pages_used": X, "pages_limit": Y, "message": "..."})`. - - [ ] Subtask 1.4: Giữ nguyên enforcement trong Celery tasks (double-check layer cho trường hợp estimate sai). +- [x] Task 1: Thêm Page Quota Pre-check vào Document Upload Route (Backend) + - [x] Subtask 1.1: Tìm document upload route — `surfsense_backend/app/routes/documents_routes.py` + - [x] Subtask 1.2: Inject `PageLimitService`, gọi `estimate_pages_from_metadata(filename, file_size)` rồi `check_page_limit(user_id, estimated_pages)`. + - [x] Subtask 1.3: `PageLimitExceededError` → `HTTPException(402)` với message mô tả quota. + - [x] Subtask 1.4: Giữ nguyên enforcement trong Celery tasks (double-check layer). + - [x] Subtask 1.5: Thêm pre-check cho `create_documents` endpoint (extension/YouTube connector). -- [ ] Task 2: Plan-based Limits (Backend) - - [ ] Subtask 2.1: Tạo config mapping `plan_id` → limits (liên kết với Story 5.3): - ```python - PLAN_LIMITS = { - "free": {"pages_limit": 500, "monthly_token_limit": 50_000, "max_docs": 10}, - "pro": {"pages_limit": 5000, "monthly_token_limit": 1_000_000, "max_docs": 100}, - } - ``` - - [ ] Subtask 2.2: Khi subscription activate/update (webhook Story 5.3), update `pages_limit` và `monthly_token_limit` theo plan mới. +- [x] Task 2: Plan-based Limits (Backend) + - [x] Subtask 2.1: `PLAN_LIMITS` config đã có trong `config/__init__.py:314`. + - [x] Subtask 2.2: Webhook Story 5.3 + admin approval đã update limits khi subscription change. -- [ ] Task 3: Frontend — Bắt lỗi Quota Exceeded - - [ ] Subtask 3.1: Document upload component — bắt HTTP 402 response, phân biệt `page_quota_exceeded` vs `token_quota_exceeded`. - - [ ] Subtask 3.2: Hiển thị toast/modal: "Bạn đã dùng X/Y pages. Nâng cấp lên Pro để tiếp tục." với link đến `/pricing`. - - [ ] Subtask 3.3: Chat component — bắt HTTP 402 từ SSE stream (tương tự Story 3.5 Task 6). +- [x] Task 3: Frontend — Bắt lỗi Quota Exceeded + - [x] Subtask 3.1: Document upload — `DocumentUploadTab.tsx` bắt HTTP 402 (check `error.status`) cho cả file và folder upload. + - [x] Subtask 3.2: Toast error với "Upgrade" action button → navigate to `/pricing`. + - [x] Subtask 3.3: Chat SSE — đã có `QuotaExceededError` pattern từ Story 3.5 (`page.tsx`). -- [ ] Task 4: Frontend — Quota Indicator (Dashboard) - - [ ] Subtask 4.1: Expose `pages_used`, `pages_limit`, `tokens_used_this_month`, `monthly_token_limit` qua user endpoint hoặc Zero sync. - - [ ] Subtask 4.2: Tạo component `QuotaIndicator` — hiển thị 2 progress bars (Pages, Tokens) trong sidebar/header. - - [ ] Subtask 4.3: Warning state (amber) khi usage > 80%, critical state (red) khi > 95%. +- [x] Task 4: Frontend — Quota Indicator (Dashboard) + - [x] Subtask 4.1: Extend `UserRead` schema + Zod type với `monthly_token_limit`, `tokens_used_this_month`, `plan_id`, `subscription_status`. + - [x] Subtask 4.2: Extend `PageUsageDisplay` — hiển thị 2 progress bars (Pages, Tokens) trong sidebar. + - [x] Subtask 4.3: Warning state (amber) khi usage > 80%, critical state (red) khi > 95%. -- [ ] Task 5: Anti-spam Rate Limiting (Optional — nếu cần) - - [ ] Subtask 5.1: Thêm rate limit cho chat endpoint (e.g. max 60 requests/hour cho free tier). - - [ ] Subtask 5.2: Dùng Redis counter hoặc FastAPI dependency. +- [ ] Task 5: Anti-spam Rate Limiting (Optional — skipped per spec) ## Dev Notes @@ -86,5 +79,34 @@ PageLimitService.update_page_usage() [COMMIT — tăng pages_used] ### References - `surfsense_backend/app/services/page_limit_service.py` — page quota (đọc, ít sửa) - `surfsense_backend/app/tasks/celery_tasks/document_tasks.py` — enforcement pattern hiện tại -- `surfsense_backend/app/routes/` — document upload route (cần tìm) -- Frontend upload component (cần tìm) +- `surfsense_backend/app/routes/documents_routes.py` — document upload route +- `surfsense_web/components/sources/DocumentUploadTab.tsx` — frontend upload component + +## Dev Agent Record + +### Implementation (2026-04-15) + +**Backend:** +- `documents_routes.py`: Added page quota pre-check to both `create_documents` and `create_documents_file_upload` endpoints. Uses `PageLimitService.estimate_pages_from_metadata()` for fast estimation before any I/O, raises HTTP 402 on exceeded quota. +- `schemas/users.py`: Extended `UserRead` with `monthly_token_limit`, `tokens_used_this_month`, `plan_id`, `subscription_status` — exposed via `/api/v1/users/me`. + +**Frontend:** +- `DocumentUploadTab.tsx`: 402 handling in both file upload (`onError`) and folder upload (`catch`) paths — shows toast with "Upgrade" action linking to `/pricing`. +- `user.types.ts`: Zod schema extended with token quota fields. +- `PageUsageDisplay.tsx`: Extended with token usage bar, color-coded warning (amber >80%, red >95%). +- `layout.types.ts`: `PageUsage` interface extended with `tokensUsed`/`tokensLimit`. +- `LayoutDataProvider.tsx`: Passes token data to sidebar. + +### File List +- `surfsense_backend/app/routes/documents_routes.py` — page quota pre-check in upload endpoints +- `surfsense_backend/app/schemas/users.py` — extended UserRead +- `surfsense_web/components/sources/DocumentUploadTab.tsx` — 402 error handling +- `surfsense_web/contracts/types/user.types.ts` — quota fields in Zod schema +- `surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx` — token usage bar + color states +- `surfsense_web/components/layout/types/layout.types.ts` — extended PageUsage interface +- `surfsense_web/components/layout/providers/LayoutDataProvider.tsx` — pass token data +- `surfsense_web/components/layout/ui/sidebar/Sidebar.tsx` — pass token props to PageUsageDisplay + +## Review Findings (2026-04-15) + +- [x] [Review][Patch] PAST_DUE users retain pro-tier limits and can continue consuming resources [page_limit_service.py, token_quota_service.py] — **Fixed**: Both services now check subscription_status and apply free-tier limits when PAST_DUE diff --git a/_bmad-output/implementation-artifacts/5-5-admin-seed-and-approval-flow.md b/_bmad-output/implementation-artifacts/5-5-admin-seed-and-approval-flow.md new file mode 100644 index 000000000..39099a847 --- /dev/null +++ b/_bmad-output/implementation-artifacts/5-5-admin-seed-and-approval-flow.md @@ -0,0 +1,125 @@ +# Story 5.5: Admin Seed Account & Admin-Approval Subscription Flow + +Status: done + +## Story + +As a Kỹ sư / Admin, +I want hệ thống tự tạo sẵn một tài khoản admin với đầy đủ quyền khi khởi động lần đầu, và khi Stripe chưa được cấu hình thì vẫn có thể test toàn bộ luồng Pro subscription thông qua giao diện duyệt thủ công của admin, +so that development và testing không bị chặn bởi việc thiếu Stripe credentials. + +## Acceptance Criteria + +1. Khi chạy `alembic upgrade head` trên database trống, hệ thống tự seed một user admin với thông tin mặc định (`admin@surfsense.local` / `Admin@SurfSense1`), overridable qua env vars `ADMIN_EMAIL` / `ADMIN_PASSWORD`. +2. Admin được seed với `is_superuser=TRUE`, `subscription_status='active'`, `plan_id='pro_yearly'`, `monthly_token_limit=1_000_000`, `pages_limit=5000` và có đủ search space, roles, membership, và default prompts. +3. Migration seed là idempotent: nếu đã có bất kỳ user nào trong DB thì bỏ qua, không insert lại. +4. Superuser có thể xem danh sách pending subscription requests tại `GET /api/v1/admin/subscription-requests`. +5. Superuser có thể approve request: `POST /api/v1/admin/subscription-requests/{id}/approve` → user được activate Pro plan ngay lập tức (không cần Stripe). +6. Superuser có thể reject request: `POST /api/v1/admin/subscription-requests/{id}/reject` → request bị đánh dấu rejected. +7. Non-superuser bị từ chối với HTTP 403 khi truy cập các endpoint admin. +8. Frontend tại `/admin/subscription-requests` hiển thị bảng pending requests với nút Approve / Reject; chuyển hướng về `/login` nếu chưa đăng nhập, hiển thị "Access denied" nếu không có quyền superuser. + +## As-Is (Code trước Story này) + +| Component | Hiện trạng | +|-----------|-----------| +| Admin user | Không có — DB trống sau fresh install | +| Subscription flow khi không có Stripe | Trả về HTTP 503 (xem Story 5.2) | +| Admin routes | Không có | +| `subscription_requests` table | Không có | +| `SubscriptionRequest` model | Không có | + +## Tasks / Subtasks + +- [x] Task 1: Admin Seed Migration + - [x] Subtask 1.1: Tạo migration `126_seed_admin_user.py` — chỉ insert khi `SELECT 1 FROM "user" LIMIT 1` trả về empty. + - [x] Subtask 1.2: Hash password bằng `argon2-cffi` (đã cài sẵn qua fastapi-users) bên trong migration function. + - [x] Subtask 1.3: Insert admin user với tất cả subscription fields đầy đủ. + - [x] Subtask 1.4: Insert default search space (với `citations_enabled=TRUE`), Owner/Editor/Viewer roles, owner membership. + - [x] Subtask 1.5: Insert 8 default prompts (`fix-grammar`, `make-shorter`, `translate`, `rewrite`, `summarize`, `explain`, `ask-knowledge-base`, `look-up-web`) với `ON CONFLICT DO NOTHING`. + - [x] Subtask 1.6: Downgrade là no-op (không xóa users). + +- [x] Task 2: Subscription Requests Table Migration + - [x] Subtask 2.1: Tạo migration `127_add_subscription_requests_table.py` — dùng raw SQL để tránh SQLAlchemy enum auto-create conflict. + - [x] Subtask 2.2: `DROP TYPE IF EXISTS subscriptionrequeststatus` trước khi `CREATE TYPE ... AS ENUM ('pending', 'approved', 'rejected')`. + - [x] Subtask 2.3: Tạo bảng `subscription_requests` với columns: `id` (UUID PK), `user_id` (FK → user CASCADE), `plan_id` (VARCHAR 50), `status` (subscriptionrequeststatus DEFAULT 'pending'), `created_at`, `approved_at` (nullable), `approved_by` (FK → user nullable). + - [x] Subtask 2.4: Tạo index trên `user_id`. + +- [x] Task 3: SubscriptionRequest Model & ORM + - [x] Subtask 3.1: Thêm `SubscriptionRequestStatus(StrEnum)` enum vào `db.py` — values: `PENDING="pending"`, `APPROVED="approved"`, `REJECTED="rejected"`. + - [x] Subtask 3.2: Thêm `SubscriptionRequest(Base)` model sau class `PagePurchase` trong `db.py`. + - [x] Subtask 3.3: Thêm `values_callable=lambda x: [e.value for e in x]` vào tất cả `SQLAlchemyEnum(SubscriptionStatus)` và `SQLAlchemyEnum(SubscriptionRequestStatus)` — bắt buộc để ORM map DB lowercase values thay vì enum member names uppercase. + - [x] Subtask 3.4: Thêm relationship `subscription_requests` vào cả hai nhánh User model (LOCAL và Google OAuth). + +- [x] Task 4: Admin Routes Backend + - [x] Subtask 4.1: Tạo `surfsense_backend/app/routes/admin_routes.py` với `APIRouter(prefix="/admin")`. + - [x] Subtask 4.2: Dùng `fastapi_users.current_user(active=True, superuser=True)` làm dependency — tự động trả 403 cho non-superuser. + - [x] Subtask 4.3: `GET /admin/subscription-requests` — query tất cả PENDING requests, JOIN lấy user email, trả về `List[SubscriptionRequestItem]`. + - [x] Subtask 4.4: `POST /admin/subscription-requests/{id}/approve` — set `status=APPROVED`, `approved_at=now()`, `approved_by=current_user.id`; activate user subscription dùng cùng logic với `_activate_subscription_from_checkout` (Story 5.3): set `subscription_status=ACTIVE`, `plan_id`, `monthly_token_limit`, `pages_limit=max(pages_used, plan_limit)`, `tokens_used_this_month=0`, `token_reset_date=today`. + - [x] Subtask 4.5: `POST /admin/subscription-requests/{id}/reject` — set `status=REJECTED`. + - [x] Subtask 4.6: Đăng ký router trong `surfsense_backend/app/routes/__init__.py` và `app.py`. + +- [x] Task 5: Admin Frontend Page + - [x] Subtask 5.1: Tạo `surfsense_web/app/admin/subscription-requests/page.tsx` — client component. + - [x] Subtask 5.2: Auth guard: gọi `isAuthenticated()` — nếu false redirect `/login`; nếu API trả 403 hiển thị "Access denied. Superuser privileges required." + - [x] Subtask 5.3: Hiển thị bảng: User email | Plan | Requested At | Actions (Approve / Reject). + - [x] Subtask 5.4: Approve/Reject gọi endpoint tương ứng; sau khi thành công xóa row khỏi danh sách local. + +## Dev Notes + +### Tại sao cần Admin Seed? +Fresh install không có user nào → không thể login để test. Admin seed giải quyết cold-start problem, đặc biệt cho CI/CD và development. + +### Tại sao dùng raw SQL trong migration 127? +`op.create_table` với `SQLAlchemy.Enum(create_type=True/False)` vẫn trigger `_on_table_create` event tự động tạo enum type. Dùng raw SQL tránh `DuplicateObjectError` khi enum đã tồn tại từ `Base.metadata.create_all()`. + +### ORM `values_callable` là bắt buộc +SQLAlchemy `Enum` mặc định dùng member **names** (uppercase: FREE, ACTIVE) để map vào DB, nhưng migration tạo enum với **values** lowercase (free, active). Không có `values_callable` → `LookupError: 'active' is not among enum values; Possible values: FREE, ACTIVE`. Fix: `values_callable=lambda x: [e.value for e in x]`. + +### Admin Approval Activation Logic +Reuse `PLAN_LIMITS` config từ `config/__init__.py`. `pages_limit = max(user.pages_used, PLAN_LIMITS[plan_id]["pages_limit"])` để không lock out user khỏi content đã upload. + +### Luồng test E2E (không có Stripe) +1. Register user → Login → `/pricing` → "Upgrade to Pro" → toast "Subscription request submitted" +2. Login admin (`admin@surfsense.local` / `Admin@SurfSense1`) → `/admin/subscription-requests` → Approve +3. Login lại user → DB: `subscription_status=active`, `plan_id=pro_monthly`, `monthly_token_limit=1_000_000` + +## Dev Agent Record + +### Implementation Notes +- Migration 126: Dùng `argon2-cffi` (`from argon2 import PasswordHasher`) để hash password. Không dùng `bcrypt` vì fastapi-users mặc định dùng argon2 với cấu hình chuẩn. +- Migration 126: Không có cột `created_at`/`updated_at` trên bảng `user` (fastapi-users base không có) — không insert các cột này. +- Migration 126: `searchspaces` cần `citations_enabled=TRUE` vì cột NOT NULL và không có server default. +- Migration 127: Dùng hoàn toàn raw SQL — không dùng `op.create_table`, không dùng `SQLAlchemy.Enum`. +- `SubscriptionRequest` model: `__allow_unmapped__ = True` để tương thích với codebase hiện tại. +- Admin routes: `SubscriptionRequestItem` Pydantic schema thêm field `user_email` (không có trong ORM model, populated thủ công khi query). +- Frontend: dùng `authenticatedFetch` từ `@/lib/auth-utils` và `BACKEND_URL` từ `@/lib/env-config`. + +### Completion Notes +✅ AC 1-3: Migration 126 seed admin user — idempotent, argon2 hashed, full setup. +✅ AC 4-7: Admin routes với superuser guard — list/approve/reject subscription requests. +✅ AC 8: Frontend `/admin/subscription-requests` với auth guard và approve/reject UI. + +### E2E Test Results (2026-04-15) +- Backend restarted với `STRIPE_SECRET_KEY=""` → admin-approval mode active. +- User `epic5user@example.com` click "Upgrade to Pro" → toast hiển thị đúng. +- Login `admin@surfsense.local` → `/admin/subscription-requests` → thấy pending request của epic5user. +- Click Approve → request biến mất khỏi danh sách. +- Query DB xác nhận: `subscription_status=active`, `plan_id=pro_monthly`, `monthly_token_limit=1000000`, `pages_limit=5000`. + +### File List +- `surfsense_backend/alembic/versions/126_seed_admin_user.py` — NEW: admin seed migration (no-op if users exist) +- `surfsense_backend/alembic/versions/127_add_subscription_requests_table.py` — NEW: subscription_requests table (raw SQL) +- `surfsense_backend/app/db.py` — MODIFIED: `SubscriptionRequestStatus` enum, `SubscriptionRequest` model, `subscription_requests` relationship trên User, `values_callable` fix trên tất cả `SubscriptionStatus` enum columns +- `surfsense_backend/app/routes/admin_routes.py` — NEW: GET/approve/reject subscription requests, superuser-only +- `surfsense_backend/app/routes/__init__.py` — MODIFIED: import và include `admin_router` +- `surfsense_web/app/admin/subscription-requests/page.tsx` — NEW: admin UI page + +### Change Log +- 2026-04-15: Implement admin seed migration + admin-approval subscription flow. + +## Review Findings (2026-04-15) + +- [x] [Review][Patch] Hard-coded default admin password should warn operators [126_seed_admin_user.py:53] — **Fixed**: Added runtime print warning when ADMIN_PASSWORD env var is not set +- [x] [Review][Patch] Race condition: concurrent approval lacks row-level lock [admin_routes.py:98] — **Fixed**: Added `.with_for_update()` to SubscriptionRequest and User selects in approve endpoint +- [x] [Review][Patch] N+1 query in list_subscription_requests [admin_routes.py:62] — **Fixed**: Batch-loaded users with `.where(User.id.in_(user_ids))` instead of per-request query diff --git a/_bmad-output/implementation-artifacts/5-6-admin-only-model-config.md b/_bmad-output/implementation-artifacts/5-6-admin-only-model-config.md new file mode 100644 index 000000000..6da5bf4e8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/5-6-admin-only-model-config.md @@ -0,0 +1,161 @@ +# Story 5.6: Admin-only Model Configuration (LLM / Image / Vision) + +Status: done + +## Story + +As a Quản trị viên (Admin), +I want chỉ mình có quyền thêm/sửa/xóa cấu hình LLM, Image Generation, và Vision models, +so that người dùng thông thường chỉ có thể chọn model để sử dụng mà không thể thêm BYOK credentials hay thay đổi cấu hình model. + +## Acceptance Criteria + +1. Chỉ superuser (`is_superuser=TRUE`) mới có thể gọi `POST/PUT/DELETE` cho cả 3 loại model config — non-superuser nhận HTTP 403 Forbidden. +2. Regular user vẫn có thể `GET` (đọc/liệt kê) model configs trong search space của họ. +3. Trong chat interface, nút "Add Model", "Add Image Model", "Add Vision Model" bị ẩn hoàn toàn với regular user. +4. Trong search space settings dialog (LLM/Image/Vision tabs), nút Add/Edit/Delete bị ẩn với regular user — chỉ hiển thị danh sách model read-only. +5. Admin-created configs lưu với `user_id=NULL` (không gắn với user cụ thể) — visible cho tất cả members trong search space. +6. DB migration: `user_id` trên cả 3 bảng config trở thành nullable để cho phép admin configs với `user_id=NULL`. + +## As-Is (Code trước Story này) + +| Component | Hiện trạng | +|-----------|-----------| +| `new_llm_configs.user_id` | NOT NULL — mọi config đều gắn với user cụ thể | +| `image_generation_configs.user_id` | NOT NULL | +| `vision_llm_configs.user_id` | NOT NULL | +| Backend CUD permissions | RBAC per search space (`check_permission`) — bất kỳ member có `llm_configs:create` đều tạo được | +| Frontend Add buttons | Hiển thị dựa theo RBAC permissions — owner và editor đều thấy | + +## Tasks / Subtasks + +- [x] Task 1: DB Migration + - [x] Subtask 1.1: Tạo migration `128_make_model_config_user_id_nullable.py` + - [x] Subtask 1.2: `ALTER TABLE new_llm_configs ALTER COLUMN user_id DROP NOT NULL` + - [x] Subtask 1.3: Tương tự cho `image_generation_configs` và `vision_llm_configs` + - [x] Subtask 1.4: Downgrade: xóa rows có `user_id=NULL` rồi re-add NOT NULL + +- [x] Task 2: Shared Superuser Dependency + - [x] Subtask 2.1: Thêm `current_superuser = fastapi_users.current_user(active=True, superuser=True)` vào `users.py` + - [x] Subtask 2.2: Cập nhật `admin_routes.py` để import `current_superuser` từ `users.py` thay vì định nghĩa lại + +- [x] Task 3: Backend — Gate CUD endpoints với superuser + - [x] Subtask 3.1: `new_llm_config_routes.py` — POST/PUT/DELETE dùng `Depends(current_superuser)`, set `user_id=None`, xóa `check_permission` + - [x] Subtask 3.2: `image_generation_routes.py` — tương tự (POST/PUT/DELETE config endpoints) + - [x] Subtask 3.3: `vision_llm_routes.py` — tương tự + +- [x] Task 4: Frontend — Settings dialog managers + - [x] Subtask 4.1: `model-config-manager.tsx` — import `currentUserAtom`, replace RBAC flags (`canCreate/Update/Delete`) với `currentUser.is_superuser` + - [x] Subtask 4.2: `image-model-manager.tsx` — tương tự + - [x] Subtask 4.3: `vision-model-manager.tsx` — tương tự + +- [x] Task 5: Frontend — Chat interface model selector + - [x] Subtask 5.1: `model-selector.tsx` — thêm `?` vào `onAddNewLLM` prop (optional), wrap "Add Model" button với `{onAddNewLLM && (...)}` + - [x] Subtask 5.2: `chat-header.tsx` — import `currentUserAtom`, compute `isAdmin`, truyền `onAddNew*={isAdmin ? handler : undefined}` cho cả 3 model types + +## Dev Notes + +### Tại sao làm optional thay vì truyền boolean? +`ModelSelector` đã có pattern `{onAddNewImage && (...)}` cho Image và Vision — consistent nhất là làm `onAddNewLLM` optional và dùng cùng pattern, thay vì thêm prop `showAddButton`. + +### Existing model configs (có user_id) +Existing configs của các user trước kia vẫn hoạt động bình thường — migration chỉ làm nullable, không xóa data. Tuy nhiên, vì GET endpoint không filter theo user_id nên tất cả configs (kể cả cũ) đều visible cho mọi member trong search space. + +### Image/Vision edit buttons trong model selector +`onEditImage` và `onEditVision` props không được gated — regular user vẫn có thể click edit nhưng sẽ xem ở mode "view" (dialog opens in view mode for global configs). Việc submit edit sẽ bị 403 từ backend. Đây là acceptable — UX nhất quán đủ dùng. + +## Dev Agent Record + +### Verification Results (2026-04-15) + +**Regular user (epic5user@example.com):** +- Model selector popup: "No models found" — NO Add/Add Image Model/Add Vision Model buttons ✅ +- `POST /api/v1/new-llm-configs` with regular user token → HTTP 403 Forbidden ✅ + +**Admin (admin@surfsense.local):** +- `POST /api/v1/new-llm-configs` → HTTP 422 (schema validation error, NOT 403) → superuser check passed ✅ + +**DB:** +- `new_llm_configs.user_id`: `is_nullable=YES` ✅ +- `image_generation_configs.user_id`: `is_nullable=YES` ✅ +- `vision_llm_configs.user_id`: `is_nullable=YES` ✅ + +### File List +- `surfsense_backend/alembic/versions/128_make_model_config_user_id_nullable.py` — NEW migration +- `surfsense_backend/app/users.py` — added `current_superuser` export +- `surfsense_backend/app/routes/admin_routes.py` — import `current_superuser` from `users.py` instead of defining locally +- `surfsense_backend/app/routes/new_llm_config_routes.py` — superuser gate on POST/PUT/DELETE, `user_id=None` +- `surfsense_backend/app/routes/image_generation_routes.py` — superuser gate on config POST/PUT/DELETE +- `surfsense_backend/app/routes/vision_llm_routes.py` — superuser gate on POST/PUT/DELETE +- `surfsense_web/components/settings/model-config-manager.tsx` — `is_superuser` replaces RBAC flags +- `surfsense_web/components/settings/image-model-manager.tsx` — same +- `surfsense_web/components/settings/vision-model-manager.tsx` — same +- `surfsense_web/components/new-chat/model-selector.tsx` — `onAddNewLLM` made optional, LLM Add button gated +- `surfsense_web/components/new-chat/chat-header.tsx` — `isAdmin` check gates all 3 `onAddNew*` props + +### Change Log +- 2026-04-15: Implement admin-only model configuration — superuser gate on backend CUD, hide Add/Edit/Delete UI for regular users in both chat selector and settings dialog. + +--- + +## Post-Story Bug Fixes & Enhancements (2026-04-15) + +### Bug 1: "No models found" for regular users + +**Root Cause:** Admin configs scoped to `search_space_id=5`. Each user has their own space. GET filtered strictly by space ID → configs invisible to other users. + +**Fix:** +- Migration 129: `search_space_id` nullable in all 3 config tables +- `db.py`: `search_space_id = nullable=True` in all 3 SQLAlchemy models (also fixed `user_id` mismatch) +- Schemas: `search_space_id: int | None = None` in Create/Read for all 3 types +- GET list query: `WHERE search_space_id = :id OR search_space_id IS NULL` +- Re-seeded all configs without `search_space_id` → global (visible to all spaces) + +### Bug 2: Chat error with stale config ID + +**Root Cause:** Frontend kept `agent_llm_id` pointing to a deleted config. + +**Fix:** `model-selector.tsx` — `useEffect` auto-resets `agent_llm_id` to `null` when saved preference ID no longer exists in fetched configs. + +### Bug 3: db.py `nullable=False` mismatch + +**Root Cause:** Migration 128 made DB columns nullable but SQLAlchemy models still said `nullable=False`. + +**Fix:** All 3 models in `db.py` updated to `nullable=True` for both `user_id` and `search_space_id`. + +### Security: api_key exposed in GET list + +**Fix:** GET list response model changed from `*Read` (exposes `api_key`) to `*Public` (hides it) for all 3 config types. + +### New: Image configs (3 global, via v98store) + +| Name | model_name | +|------|-----------| +| DALL-E 3 | `dall-e-3` | +| GPT-Image 1 | `gpt-image-1` | +| Flux Pro | `flux-pro` | + +### New: Vision configs (3 global, via v98store) + +| Name | model_name | +|------|-----------| +| GPT-4o Vision | `gpt-4o` | +| Claude Sonnet 4 Vision | `claude-sonnet-4-20250514` | +| Gemini 2.5 Flash Vision | `gemini-2.5-flash` | + +### Enhancement: Edit buttons admin-only in model selector + +**Fix:** `onEditLLM` prop in `ModelSelectorProps` changed from required → optional. In `chat-header.tsx`, all 3 `onEdit*` props now gated with `isAdmin ? handler : undefined` — consistent với `onAdd*` pattern đã có. Regular users thấy model list read-only, không có edit button trên hover. + +### Additional Files Changed +- `surfsense_backend/alembic/versions/129_make_model_config_search_space_id_nullable.py` +- `surfsense_backend/app/db.py` +- `surfsense_backend/app/schemas/new_llm_config.py`, `image_generation.py`, `vision_llm.py` +- `surfsense_backend/app/routes/new_llm_config_routes.py`, `image_generation_routes.py`, `vision_llm_routes.py` +- `surfsense_web/components/new-chat/model-selector.tsx` +- `surfsense_web/components/new-chat/chat-header.tsx` + +## Review Findings (2026-04-15) + +- [x] [Review][Patch] Superuser config update/delete can modify any config (not just global ones) [new_llm_config_routes.py:274,357, image_generation_routes.py:373,405, vision_llm_routes.py:222,254] — **Fixed**: Added `user_id.is_(None)` filter to all superuser PUT/DELETE endpoints +- [x] [Review][Patch] admin_approval_mode field missing from CreateSubscriptionCheckoutResponse — **Already fixed**: field was already present in schemas/stripe.py with `bool = False` default diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 950fc2298..de5e4d6f0 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -15,7 +15,19 @@ - Subscription lifecycle events (`invoice.paid`, `customer.subscription.updated/deleted`, `invoice.payment_failed`) not handled — scope of Story 5.3. - `_get_or_create_stripe_customer` can create orphaned Stripe customers if `db_session.commit()` fails after `customers.create`. Consider idempotency key in future. +## Deferred from: Story 5.6 post-story bug fixes (2026-04-15) + +- **`api_key` exposed in LLM preferences response** [`surfsense_backend/app/routes/search_space_routes.py`] — `GET/PUT /search-spaces/{id}/llm-preferences` returns full config objects including `api_key` (nested `agent_llm`, `document_summary_llm`, etc. fields). Should return sanitized Public versions (no api_key). Low risk since endpoint requires authentication, but still a credentials leak. + ## Deferred from: code review of story-5.3 (2026-04-15) - Race condition: `checkout.session.completed` and `customer.subscription.deleted` can fire near-simultaneously; if deleted arrives between checkout handlers, subscription can be reactivated. Fix requires Stripe API call to verify subscription status before activation. - `invoice.payment_succeeded` does not update `subscription_current_period_end` — currently relies on `customer.subscription.updated` firing in the same event sequence. If that event is lost, period_end is stale. + +## Deferred from: code review of Epic 5 (2026-04-15) — RESOLVED 2026-04-15 + +- ~~**Migration 124 drops enum type unconditionally**~~ — **Fixed**: Added `CASCADE` to `DROP TYPE IF EXISTS subscriptionstatus CASCADE` in `124_add_subscription_token_quota_columns.py`. +- ~~**`checkout_url` rejects non-HTTPS URLs**~~ — **Closed as invalid**: Original `startsWith("https://")` check is intentionally correct — Stripe always returns HTTPS URLs even in test mode. Relaxing to `http` would weaken security. No change made. +- ~~**`verify-checkout-session` endpoint lacks rate limiting**~~ — **Fixed**: Added in-memory per-user rate limit (20 calls/60s) via `_check_verify_session_rate_limit()` in `stripe_routes.py`. +- ~~**Rejected user can re-submit approval request immediately**~~ — **Fixed**: Added 24h cooldown check using `created_at >= now() - 24h` on REJECTED requests before creating a new SubscriptionRequest. +- ~~**`token_reset_date` not set in `_handle_subscription_event`**~~ — **Fixed**: When `new_status == ACTIVE` and `token_reset_date is None`, now sets `user.token_reset_date = datetime.now(UTC).date()`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 9bd48421f..378659890 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-13T02:50:25+07:00 -last_updated: 2026-04-15T00:14:00+07:00 +last_updated: 2026-04-15T03:00:00+07:00 project: SurfSense project_key: NOKEY tracking_system: file-system @@ -69,5 +69,7 @@ development_status: 5-1-pricing-plan-selection-ui: done 5-2-stripe-payment-integration: done 5-3-stripe-webhook-sync: done - 5-4-usage-tracking-rate-limit-enforcement: ready-for-dev + 5-4-usage-tracking-rate-limit-enforcement: done + 5-5-admin-seed-and-approval-flow: done + 5-6-admin-only-model-config: done epic-5-retrospective: optional diff --git a/surfsense_backend/alembic/versions/124_add_subscription_token_quota_columns.py b/surfsense_backend/alembic/versions/124_add_subscription_token_quota_columns.py index 52ac1ab63..2d9fb6fac 100644 --- a/surfsense_backend/alembic/versions/124_add_subscription_token_quota_columns.py +++ b/surfsense_backend/alembic/versions/124_add_subscription_token_quota_columns.py @@ -24,6 +24,7 @@ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa + from alembic import op revision: str = "124" @@ -33,17 +34,35 @@ depends_on: str | Sequence[str] | None = None # Create the enum type so SQLAlchemy's create_type=False works at runtime subscriptionstatus_enum = sa.Enum( - "free", "active", "canceled", "past_due", + "free", + "active", + "canceled", + "past_due", name="subscriptionstatus", ) def upgrade() -> None: - # Create the PostgreSQL enum type first - subscriptionstatus_enum.create(op.get_bind(), checkfirst=True) + # Drop any pre-existing subscriptionstatus enum (e.g. uppercase version created by + # SQLAlchemy's create_all() during early development) so we can create it with + # the correct lowercase values. Safe to drop here because no column uses it yet. + conn = op.get_bind() + conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionstatus CASCADE")) + # Create the PostgreSQL enum type with lowercase values + subscriptionstatus_enum.create(conn, checkfirst=False) - op.add_column("user", sa.Column("monthly_token_limit", sa.Integer(), nullable=False, server_default="100000")) - op.add_column("user", sa.Column("tokens_used_this_month", sa.Integer(), nullable=False, server_default="0")) + op.add_column( + "user", + sa.Column( + "monthly_token_limit", sa.Integer(), nullable=False, server_default="100000" + ), + ) + op.add_column( + "user", + sa.Column( + "tokens_used_this_month", sa.Integer(), nullable=False, server_default="0" + ), + ) op.add_column("user", sa.Column("token_reset_date", sa.Date(), nullable=True)) op.add_column( "user", @@ -54,12 +73,23 @@ def upgrade() -> None: server_default="free", ), ) - op.add_column("user", sa.Column("plan_id", sa.String(50), nullable=False, server_default="free")) - op.add_column("user", sa.Column("stripe_customer_id", sa.String(255), nullable=True)) - op.add_column("user", sa.Column("stripe_subscription_id", sa.String(255), nullable=True)) + op.add_column( + "user", + sa.Column("plan_id", sa.String(50), nullable=False, server_default="free"), + ) + op.add_column( + "user", sa.Column("stripe_customer_id", sa.String(255), nullable=True) + ) + op.add_column( + "user", sa.Column("stripe_subscription_id", sa.String(255), nullable=True) + ) - op.create_unique_constraint("uq_user_stripe_customer_id", "user", ["stripe_customer_id"]) - op.create_unique_constraint("uq_user_stripe_subscription_id", "user", ["stripe_subscription_id"]) + op.create_unique_constraint( + "uq_user_stripe_customer_id", "user", ["stripe_customer_id"] + ) + op.create_unique_constraint( + "uq_user_stripe_subscription_id", "user", ["stripe_subscription_id"] + ) def downgrade() -> None: diff --git a/surfsense_backend/alembic/versions/126_seed_admin_user.py b/surfsense_backend/alembic/versions/126_seed_admin_user.py new file mode 100644 index 000000000..ba8268392 --- /dev/null +++ b/surfsense_backend/alembic/versions/126_seed_admin_user.py @@ -0,0 +1,207 @@ +"""126_seed_admin_user + +Revision ID: 126 +Revises: 125 +Create Date: 2026-04-15 + +Seeds one admin user on fresh installs (no-op if any user already exists). +Credentials are overridable via env vars: + ADMIN_EMAIL (default: admin@surfsense.local) + ADMIN_PASSWORD (default: Admin@SurfSense1) + +Admin is created with: + - is_superuser = TRUE, is_active = TRUE, is_verified = TRUE + - subscription_status = 'active', plan_id = 'pro_yearly' + - monthly_token_limit = 1_000_000, pages_limit = 5000 + - A default search space, roles, membership, and prompt defaults +""" + +from __future__ import annotations + +import os +import uuid +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "126" +down_revision: str | None = "125" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _hash_password(password: str) -> str: + """Hash password using argon2-cffi (installed as a fastapi-users dependency).""" + from argon2 import PasswordHasher + + ph = PasswordHasher() + return ph.hash(password) + + +def upgrade() -> None: + conn = op.get_bind() + + # Only seed when the database is empty + result = conn.execute(sa.text('SELECT 1 FROM "user" LIMIT 1')) + if result.fetchone() is not None: + return # Users already exist — skip seed + + admin_email = os.environ.get("ADMIN_EMAIL", "admin@surfsense.local") + admin_password = os.environ.get("ADMIN_PASSWORD", "Admin@SurfSense1") + if not os.environ.get("ADMIN_PASSWORD"): + print( + "\n⚠️ WARNING: ADMIN_PASSWORD env var not set. " + "Using default password 'Admin@SurfSense1'. " + "Change this immediately after first login!\n" + ) + hashed_pw = _hash_password(admin_password) + admin_id = str(uuid.uuid4()) + + # 1. Insert admin user + conn.execute( + sa.text( + """ + INSERT INTO "user" ( + id, email, hashed_password, + is_active, is_superuser, is_verified, + subscription_status, plan_id, + monthly_token_limit, pages_limit, pages_used, + tokens_used_this_month + ) VALUES ( + :id, :email, :hashed_password, + TRUE, TRUE, TRUE, + 'active', 'pro_yearly', + 1000000, 5000, 0, + 0 + ) + """ + ), + { + "id": admin_id, + "email": admin_email, + "hashed_password": hashed_pw, + }, + ) + + # 2. Insert default search space for admin (only required columns; defaults handle the rest) + search_space_result = conn.execute( + sa.text( + """ + INSERT INTO searchspaces (name, description, citations_enabled, user_id, created_at) + VALUES ('My Search Space', 'Your personal search space', TRUE, :user_id, now()) + RETURNING id + """ + ), + {"user_id": admin_id}, + ) + search_space_id = search_space_result.fetchone()[0] + + # 3. Insert default roles for the search space + owner_role_result = conn.execute( + sa.text( + """ + INSERT INTO search_space_roles + (name, description, permissions, is_default, is_system_role, search_space_id, created_at) + VALUES ( + 'Owner', 'Full access to all search space resources and settings', + ARRAY['*'], FALSE, TRUE, :ss_id, now() + ) + RETURNING id + """ + ), + {"ss_id": search_space_id}, + ) + owner_role_id = owner_role_result.fetchone()[0] + + conn.execute( + sa.text( + """ + INSERT INTO search_space_roles + (name, description, permissions, is_default, is_system_role, search_space_id, created_at) + VALUES + ( + 'Editor', + 'Can create and update content (no delete, role management, or settings access)', + ARRAY[ + 'documents:create','documents:read','documents:update', + 'chats:create','chats:read','chats:update', + 'comments:create','comments:read', + 'llm_configs:create','llm_configs:read','llm_configs:update', + 'podcasts:create','podcasts:read','podcasts:update', + 'video_presentations:create','video_presentations:read','video_presentations:update', + 'image_generations:create','image_generations:read', + 'vision_configs:create','vision_configs:read', + 'connectors:create','connectors:read','connectors:update', + 'logs:read', 'members:invite' + ], + TRUE, TRUE, :ss_id, now() + ), + ( + 'Viewer', 'Read-only access to search space resources', + ARRAY[ + 'documents:read','chats:read','comments:read', + 'llm_configs:read','podcasts:read','video_presentations:read', + 'image_generations:read','vision_configs:read','connectors:read','logs:read' + ], + FALSE, TRUE, :ss_id, now() + ) + """ + ), + {"ss_id": search_space_id}, + ) + + # 4. Insert owner membership + conn.execute( + sa.text( + """ + INSERT INTO search_space_memberships + (user_id, search_space_id, role_id, is_owner, joined_at, created_at) + VALUES (:user_id, :ss_id, :role_id, TRUE, now(), now()) + """ + ), + {"user_id": admin_id, "ss_id": search_space_id, "role_id": owner_role_id}, + ) + + # 5. Insert default prompts (same as migration 114 but just for admin) + conn.execute( + sa.text( + """ + INSERT INTO prompts + (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at) + VALUES + (:uid, 'fix-grammar', 'Fix grammar', + 'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}', + 'transform'::prompt_mode, 1, false, now()), + (:uid, 'make-shorter', 'Make shorter', + 'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}', + 'transform'::prompt_mode, 1, false, now()), + (:uid, 'translate', 'Translate', + 'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}', + 'transform'::prompt_mode, 1, false, now()), + (:uid, 'rewrite', 'Rewrite', + 'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}', + 'transform'::prompt_mode, 1, false, now()), + (:uid, 'summarize', 'Summarize', + 'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}', + 'transform'::prompt_mode, 1, false, now()), + (:uid, 'explain', 'Explain', + 'Explain the following text in simple terms:\n\n{selection}', + 'explore'::prompt_mode, 1, false, now()), + (:uid, 'ask-knowledge-base', 'Ask my knowledge base', + 'Search my knowledge base for information related to:\n\n{selection}', + 'explore'::prompt_mode, 1, false, now()), + (:uid, 'look-up-web', 'Look up on the web', + 'Search the web for information about:\n\n{selection}', + 'explore'::prompt_mode, 1, false, now()) + ON CONFLICT (user_id, default_prompt_slug) DO NOTHING + """ + ), + {"uid": admin_id}, + ) + + +def downgrade() -> None: + # Intentional no-op: never delete users on downgrade + pass diff --git a/surfsense_backend/alembic/versions/127_add_subscription_requests_table.py b/surfsense_backend/alembic/versions/127_add_subscription_requests_table.py new file mode 100644 index 000000000..ccb1dc8a0 --- /dev/null +++ b/surfsense_backend/alembic/versions/127_add_subscription_requests_table.py @@ -0,0 +1,59 @@ +"""127_add_subscription_requests_table + +Revision ID: 127 +Revises: 126 +Create Date: 2026-04-15 + +Adds the subscription_requests table for admin-approval flow when Stripe +is not configured. Users submit a subscription request; superusers can +approve/reject it from the admin panel. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "127" +down_revision: str | None = "126" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + # Drop any pre-existing enum (e.g. uppercase version from old create_all()) + conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionrequeststatus")) + conn.execute( + sa.text( + "CREATE TYPE subscriptionrequeststatus AS ENUM ('pending', 'approved', 'rejected')" + ) + ) + conn.execute( + sa.text( + """ + CREATE TABLE subscription_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + plan_id VARCHAR(50) NOT NULL, + status subscriptionrequeststatus NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + approved_at TIMESTAMPTZ, + approved_by UUID REFERENCES "user"(id) + ) + """ + ) + ) + conn.execute( + sa.text( + "CREATE INDEX ix_subscription_requests_user_id ON subscription_requests (user_id)" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP TABLE IF EXISTS subscription_requests")) + conn.execute(sa.text("DROP TYPE IF EXISTS subscriptionrequeststatus")) diff --git a/surfsense_backend/alembic/versions/128_make_model_config_user_id_nullable.py b/surfsense_backend/alembic/versions/128_make_model_config_user_id_nullable.py new file mode 100644 index 000000000..567ade6d6 --- /dev/null +++ b/surfsense_backend/alembic/versions/128_make_model_config_user_id_nullable.py @@ -0,0 +1,46 @@ +"""128_make_model_config_user_id_nullable + +Revision ID: 128 +Revises: 127 +Create Date: 2026-04-15 + +Makes user_id nullable on the three model-config tables so that admin-created +(superuser-owned) configurations have user_id = NULL and are visible to all +members of the search space. + +Tables affected: + - new_llm_configs + - image_generation_configs + - vision_llm_configs +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "128" +down_revision: str | None = "127" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column("new_llm_configs", "user_id", existing_type=sa.UUID(), nullable=True) + op.alter_column("image_generation_configs", "user_id", existing_type=sa.UUID(), nullable=True) + op.alter_column("vision_llm_configs", "user_id", existing_type=sa.UUID(), nullable=True) + + +def downgrade() -> None: + conn = op.get_bind() + + # Null out orphaned rows before re-adding NOT NULL (safety guard) + for table in ("new_llm_configs", "image_generation_configs", "vision_llm_configs"): + # If any rows have user_id=NULL we cannot restore NOT NULL — delete them + conn.execute(sa.text(f'DELETE FROM "{table}" WHERE user_id IS NULL')) + + op.alter_column("new_llm_configs", "user_id", existing_type=sa.UUID(), nullable=False) + op.alter_column("image_generation_configs", "user_id", existing_type=sa.UUID(), nullable=False) + op.alter_column("vision_llm_configs", "user_id", existing_type=sa.UUID(), nullable=False) diff --git a/surfsense_backend/alembic/versions/129_make_model_config_search_space_id_nullable.py b/surfsense_backend/alembic/versions/129_make_model_config_search_space_id_nullable.py new file mode 100644 index 000000000..52da7e314 --- /dev/null +++ b/surfsense_backend/alembic/versions/129_make_model_config_search_space_id_nullable.py @@ -0,0 +1,69 @@ +"""129_make_model_config_search_space_id_nullable + +Revision ID: 129 +Revises: 128 +Create Date: 2026-04-15 + +Makes search_space_id nullable on the three model-config tables so that +admin-created (superuser-owned) configurations have search_space_id = NULL +and are visible to ALL users across ALL search spaces. + +Tables affected: + - new_llm_configs + - image_generation_configs + - vision_llm_configs +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "129" +down_revision: str | None = "128" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column( + "new_llm_configs", "search_space_id", existing_type=sa.Integer(), nullable=True + ) + op.alter_column( + "image_generation_configs", + "search_space_id", + existing_type=sa.Integer(), + nullable=True, + ) + op.alter_column( + "vision_llm_configs", + "search_space_id", + existing_type=sa.Integer(), + nullable=True, + ) + + +def downgrade() -> None: + conn = op.get_bind() + + # Delete global configs (search_space_id IS NULL) before restoring NOT NULL + for table in ("new_llm_configs", "image_generation_configs", "vision_llm_configs"): + conn.execute(sa.text(f'DELETE FROM "{table}" WHERE search_space_id IS NULL')) + + op.alter_column( + "new_llm_configs", "search_space_id", existing_type=sa.Integer(), nullable=False + ) + op.alter_column( + "image_generation_configs", + "search_space_id", + existing_type=sa.Integer(), + nullable=False, + ) + op.alter_column( + "vision_llm_configs", + "search_space_id", + existing_type=sa.Integer(), + nullable=False, + ) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index f2880691e..fb07a21ad 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1197,15 +1197,15 @@ class ImageGenerationConfig(BaseModel, TimestampMixin): # Relationships search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True ) search_space = relationship( "SearchSpace", back_populates="image_generation_configs" ) - # User who created this config + # User who created this config (NULL for admin-created global configs) user_id = Column( - UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False + UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) user = relationship("User", back_populates="image_generation_configs") @@ -1227,12 +1227,13 @@ class VisionLLMConfig(BaseModel, TimestampMixin): litellm_params = Column(JSON, nullable=True, default={}) search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True ) search_space = relationship("SearchSpace", back_populates="vision_llm_configs") + # User who created this config (NULL for admin-created global configs) user_id = Column( - UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False + UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) user = relationship("User", back_populates="vision_llm_configs") @@ -1535,13 +1536,13 @@ class NewLLMConfig(BaseModel, TimestampMixin): # === Relationships === search_space_id = Column( - Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True ) search_space = relationship("SearchSpace", back_populates="new_llm_configs") - # User who created this config + # User who created this config (NULL for admin-created global configs) user_id = Column( - UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False + UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) user = relationship("User", back_populates="new_llm_configs") @@ -1683,6 +1684,56 @@ class PagePurchase(Base, TimestampMixin): user = relationship("User", back_populates="page_purchases") +class SubscriptionRequestStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class SubscriptionRequest(Base): + """Tracks subscription upgrade requests when Stripe is not configured (admin-approval flow).""" + + __tablename__ = "subscription_requests" + __allow_unmapped__ = True + + id = Column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + plan_id = Column(String(50), nullable=False) + status = Column( + SQLAlchemyEnum( + SubscriptionRequestStatus, + name="subscriptionrequeststatus", + create_type=False, + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + default=SubscriptionRequestStatus.PENDING, + server_default="pending", + ) + created_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + server_default=text("now()"), + ) + approved_at = Column(TIMESTAMP(timezone=True), nullable=True) + approved_by = Column( + UUID(as_uuid=True), + ForeignKey("user.id"), + nullable=True, + ) + + user = relationship("User", foreign_keys=[user_id], back_populates="subscription_requests") + + class SearchSpaceRole(BaseModel, TimestampMixin): """ Custom roles that can be defined per search space. @@ -1953,6 +2004,12 @@ if config.AUTH_TYPE == "GOOGLE": back_populates="user", cascade="all, delete-orphan", ) + subscription_requests = relationship( + "SubscriptionRequest", + foreign_keys="SubscriptionRequest.user_id", + back_populates="user", + cascade="all, delete-orphan", + ) # Page usage tracking for ETL services pages_limit = Column( @@ -1968,7 +2025,7 @@ if config.AUTH_TYPE == "GOOGLE": tokens_used_this_month = Column(Integer, nullable=False, default=0, server_default="0") token_reset_date = Column(Date, nullable=True) subscription_status = Column( - SQLAlchemyEnum(SubscriptionStatus, name="subscriptionstatus", create_type=True), + SQLAlchemyEnum(SubscriptionStatus, name="subscriptionstatus", create_type=True, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SubscriptionStatus.FREE, server_default="free", @@ -2082,6 +2139,12 @@ else: back_populates="user", cascade="all, delete-orphan", ) + subscription_requests = relationship( + "SubscriptionRequest", + foreign_keys="SubscriptionRequest.user_id", + back_populates="user", + cascade="all, delete-orphan", + ) # Page usage tracking for ETL services pages_limit = Column( @@ -2097,7 +2160,7 @@ else: tokens_used_this_month = Column(Integer, nullable=False, default=0, server_default="0") token_reset_date = Column(Date, nullable=True) subscription_status = Column( - SQLAlchemyEnum(SubscriptionStatus, name="subscriptionstatus", create_type=True), + SQLAlchemyEnum(SubscriptionStatus, name="subscriptionstatus", create_type=True, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SubscriptionStatus.FREE, server_default="free", diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index de1feee54..f0deb8551 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -48,6 +48,7 @@ from .sandbox_routes import router as sandbox_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router +from .admin_routes import router as admin_router from .stripe_routes import router as stripe_router from .surfsense_docs_routes import router as surfsense_docs_router from .teams_add_connector_route import router as teams_add_connector_router @@ -100,6 +101,7 @@ router.include_router(notifications_router) # Notifications with Zero sync router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages +router.include_router(admin_router) # Superuser admin operations router.include_router(stripe_router) # Stripe checkout for additional page packs router.include_router(youtube_router) # YouTube playlist resolution router.include_router(prompts_router) diff --git a/surfsense_backend/app/routes/admin_routes.py b/surfsense_backend/app/routes/admin_routes.py new file mode 100644 index 000000000..6419b4e64 --- /dev/null +++ b/surfsense_backend/app/routes/admin_routes.py @@ -0,0 +1,212 @@ +"""Admin routes — superuser-only operations.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import ( + SubscriptionRequest, + SubscriptionRequestStatus, + SubscriptionStatus, + User, + get_async_session, +) +from app.users import current_superuser + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + + +class SubscriptionRequestItem(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + user_email: str + plan_id: str + status: str + created_at: datetime + approved_at: datetime | None = None + approved_by: uuid.UUID | None = None + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# List pending subscription requests +# --------------------------------------------------------------------------- + + +@router.get( + "/subscription-requests", + response_model=list[SubscriptionRequestItem], +) +async def list_subscription_requests( + admin: User = Depends(current_superuser), + db_session: AsyncSession = Depends(get_async_session), +) -> list[SubscriptionRequestItem]: + """Return all pending subscription requests.""" + result = await db_session.execute( + select(SubscriptionRequest) + .where(SubscriptionRequest.status == SubscriptionRequestStatus.PENDING) + .order_by(SubscriptionRequest.created_at.asc()) + ) + requests = result.scalars().all() + + # Collect user IDs and batch-load to avoid N+1 + user_ids = [req.user_id for req in requests] + email_map: dict[uuid.UUID, str] = {} + if user_ids: + user_rows = await db_session.execute(select(User).where(User.id.in_(user_ids))) + for u in user_rows.scalars(): + email_map[u.id] = u.email + + items: list[SubscriptionRequestItem] = [ + SubscriptionRequestItem( + id=req.id, + user_id=req.user_id, + user_email=email_map.get(req.user_id, ""), + plan_id=req.plan_id, + status=req.status.value, + created_at=req.created_at, + approved_at=req.approved_at, + approved_by=req.approved_by, + ) + for req in requests + ] + return items + + +# --------------------------------------------------------------------------- +# Approve a subscription request +# --------------------------------------------------------------------------- + + +@router.post( + "/subscription-requests/{request_id}/approve", + response_model=SubscriptionRequestItem, +) +async def approve_subscription_request( + request_id: uuid.UUID, + admin: User = Depends(current_superuser), + db_session: AsyncSession = Depends(get_async_session), +) -> SubscriptionRequestItem: + """Approve a pending subscription request and activate the user's subscription.""" + result = await db_session.execute( + select(SubscriptionRequest) + .where(SubscriptionRequest.id == request_id) + .with_for_update() + ) + req = result.scalar_one_or_none() + if req is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription request not found.", + ) + if req.status != SubscriptionRequestStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Request is already {req.status.value}.", + ) + + user_result = await db_session.execute( + select(User).where(User.id == req.user_id).with_for_update() + ) + req_user = user_result.scalar_one_or_none() + if req_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found." + ) + + # Activate subscription + plan_limits = config.PLAN_LIMITS.get(req.plan_id, config.PLAN_LIMITS["free"]) + req_user.subscription_status = SubscriptionStatus.ACTIVE + req_user.plan_id = req.plan_id + req_user.monthly_token_limit = plan_limits["monthly_token_limit"] + req_user.pages_limit = max(req_user.pages_used or 0, plan_limits["pages_limit"]) + req_user.tokens_used_this_month = 0 + req_user.token_reset_date = datetime.now(UTC).date() + + # Mark request approved + now = datetime.now(UTC) + req.status = SubscriptionRequestStatus.APPROVED + req.approved_at = now + req.approved_by = admin.id + + await db_session.commit() + await db_session.refresh(req) + + user_result2 = await db_session.execute(select(User).where(User.id == req.user_id)) + req_user2 = user_result2.scalar_one_or_none() + email = req_user2.email if req_user2 else "" + + return SubscriptionRequestItem( + id=req.id, + user_id=req.user_id, + user_email=email, + plan_id=req.plan_id, + status=req.status.value, + created_at=req.created_at, + approved_at=req.approved_at, + approved_by=req.approved_by, + ) + + +# --------------------------------------------------------------------------- +# Reject a subscription request +# --------------------------------------------------------------------------- + + +@router.post( + "/subscription-requests/{request_id}/reject", + response_model=SubscriptionRequestItem, +) +async def reject_subscription_request( + request_id: uuid.UUID, + admin: User = Depends(current_superuser), + db_session: AsyncSession = Depends(get_async_session), +) -> SubscriptionRequestItem: + """Reject a pending subscription request.""" + result = await db_session.execute( + select(SubscriptionRequest).where(SubscriptionRequest.id == request_id) + ) + req = result.scalar_one_or_none() + if req is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription request not found.", + ) + if req.status != SubscriptionRequestStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Request is already {req.status.value}.", + ) + + req.status = SubscriptionRequestStatus.REJECTED + + await db_session.commit() + await db_session.refresh(req) + + user_result = await db_session.execute(select(User).where(User.id == req.user_id)) + req_user = user_result.scalar_one_or_none() + email = req_user.email if req_user else "" + + return SubscriptionRequestItem( + id=req.id, + user_id=req.user_id, + user_email=email, + plan_id=req.plan_id, + status=req.status.value, + created_at=req.created_at, + approved_at=req.approved_at, + approved_by=req.approved_by, + ) diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index aa7f98294..62a58b114 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -73,6 +73,24 @@ async def create_documents( "You don't have permission to create documents in this search space", ) + # Page quota pre-check for connector documents + from app.services.page_limit_service import ( + PageLimitExceededError, + PageLimitService, + ) + + estimated_pages = len(request.content) # 1 page per document/URL + try: + page_service = PageLimitService(session) + await page_service.check_page_limit(str(user.id), estimated_pages) + except PageLimitExceededError as e: + raise HTTPException( + status_code=402, + detail=f"Page quota exceeded ({e.pages_used}/{e.pages_limit}). " + f"This request requires ~{estimated_pages} pages. " + f"Upgrade your plan for more pages.", + ) from e + if request.document_type == DocumentType.EXTENSION: from app.tasks.celery_tasks.document_tasks import ( process_extension_document_task, @@ -169,6 +187,30 @@ async def create_documents_file_upload( f"exceeds the {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB per-file limit.", ) + # Page quota pre-check + from app.services.page_limit_service import ( + PageLimitExceededError, + PageLimitService, + ) + + total_estimated_pages = sum( + PageLimitService.estimate_pages_from_metadata( + file.filename or "", file.size or 0 + ) + for file in files + ) + + try: + page_service = PageLimitService(session) + await page_service.check_page_limit(str(user.id), total_estimated_pages) + except PageLimitExceededError as e: + raise HTTPException( + status_code=402, + detail=f"Page quota exceeded ({e.pages_used}/{e.pages_limit}). " + f"This upload requires ~{total_estimated_pages} pages. " + f"Upgrade your plan for more pages.", + ) from e + # ===== Read all files concurrently to avoid blocking the event loop ===== async def _read_and_save(file: UploadFile) -> tuple[str, str, int]: """Read upload content and write to temp file off the event loop.""" diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py index 97a3559b9..79432def8 100644 --- a/surfsense_backend/app/routes/image_generation_routes.py +++ b/surfsense_backend/app/routes/image_generation_routes.py @@ -30,6 +30,7 @@ from app.db import ( from app.schemas import ( GlobalImageGenConfigRead, ImageGenerationConfigCreate, + ImageGenerationConfigPublic, ImageGenerationConfigRead, ImageGenerationConfigUpdate, ImageGenerationCreate, @@ -41,7 +42,7 @@ from app.services.image_gen_router_service import ( ImageGenRouterService, is_image_gen_auto_mode, ) -from app.users import current_active_user +from app.users import current_active_user, current_superuser from app.utils.rbac import check_permission from app.utils.signed_image_urls import verify_image_token @@ -261,19 +262,11 @@ async def get_global_image_gen_configs( async def create_image_gen_config( config_data: ImageGenerationConfigCreate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): - """Create a new image generation config for a search space.""" + """Create a new image generation config for a search space. Superuser only.""" try: - await check_permission( - session, - user, - config_data.search_space_id, - Permission.IMAGE_GENERATIONS_CREATE.value, - "You don't have permission to create image generation configs in this search space", - ) - - db_config = ImageGenerationConfig(**config_data.model_dump(), user_id=user.id) + db_config = ImageGenerationConfig(**config_data.model_dump(), user_id=None) session.add(db_config) await session.commit() await session.refresh(db_config) @@ -289,7 +282,9 @@ async def create_image_gen_config( ) from e -@router.get("/image-generation-configs", response_model=list[ImageGenerationConfigRead]) +@router.get( + "/image-generation-configs", response_model=list[ImageGenerationConfigPublic] +) async def list_image_gen_configs( search_space_id: int, skip: int = 0, @@ -297,7 +292,7 @@ async def list_image_gen_configs( session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): - """List image generation configs for a search space.""" + """List image generation configs for a search space (includes global admin configs).""" try: await check_permission( session, @@ -309,7 +304,10 @@ async def list_image_gen_configs( result = await session.execute( select(ImageGenerationConfig) - .filter(ImageGenerationConfig.search_space_id == search_space_id) + .filter( + (ImageGenerationConfig.search_space_id == search_space_id) + | (ImageGenerationConfig.search_space_id == None) # noqa: E711 + ) .order_by(ImageGenerationConfig.created_at.desc()) .offset(skip) .limit(limit) @@ -367,25 +365,20 @@ async def update_image_gen_config( config_id: int, update_data: ImageGenerationConfigUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): - """Update an existing image generation config.""" + """Update an existing image generation config. Superuser only.""" try: result = await session.execute( - select(ImageGenerationConfig).filter(ImageGenerationConfig.id == config_id) + select(ImageGenerationConfig).filter( + ImageGenerationConfig.id == config_id, + ImageGenerationConfig.user_id.is_(None), + ) ) db_config = result.scalars().first() if not db_config: raise HTTPException(status_code=404, detail="Config not found") - await check_permission( - session, - user, - db_config.search_space_id, - Permission.IMAGE_GENERATIONS_CREATE.value, - "You don't have permission to update image generation configs in this search space", - ) - for key, value in update_data.model_dump(exclude_unset=True).items(): setattr(db_config, key, value) @@ -407,25 +400,20 @@ async def update_image_gen_config( async def delete_image_gen_config( config_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): - """Delete an image generation config.""" + """Delete an image generation config. Superuser only.""" try: result = await session.execute( - select(ImageGenerationConfig).filter(ImageGenerationConfig.id == config_id) + select(ImageGenerationConfig).filter( + ImageGenerationConfig.id == config_id, + ImageGenerationConfig.user_id.is_(None), + ) ) db_config = result.scalars().first() if not db_config: raise HTTPException(status_code=404, detail="Config not found") - await check_permission( - session, - user, - db_config.search_space_id, - Permission.IMAGE_GENERATIONS_DELETE.value, - "You don't have permission to delete image generation configs in this search space", - ) - await session.delete(db_config) await session.commit() return { diff --git a/surfsense_backend/app/routes/new_llm_config_routes.py b/surfsense_backend/app/routes/new_llm_config_routes.py index 78907c719..1ee4a2906 100644 --- a/surfsense_backend/app/routes/new_llm_config_routes.py +++ b/surfsense_backend/app/routes/new_llm_config_routes.py @@ -25,11 +25,12 @@ from app.schemas import ( DefaultSystemInstructionsResponse, GlobalNewLLMConfigRead, NewLLMConfigCreate, + NewLLMConfigPublic, NewLLMConfigRead, NewLLMConfigUpdate, ) from app.services.llm_service import validate_llm_config -from app.users import current_active_user +from app.users import current_active_user, current_superuser from app.utils.rbac import check_permission router = APIRouter() @@ -117,22 +118,13 @@ async def get_global_new_llm_configs( async def create_new_llm_config( config_data: NewLLMConfigCreate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): """ Create a new NewLLMConfig for a search space. - Requires LLM_CONFIGS_CREATE permission. + Superuser only — configs are shared with all search space members. """ try: - # Verify user has permission - await check_permission( - session, - user, - config_data.search_space_id, - Permission.LLM_CONFIGS_CREATE.value, - "You don't have permission to create LLM configurations in this search space", - ) - # Validate the LLM configuration by making a test API call is_valid, error_message = await validate_llm_config( provider=config_data.provider.value, @@ -149,8 +141,8 @@ async def create_new_llm_config( detail=f"Invalid LLM configuration: {error_message}", ) - # Create the config with user association - db_config = NewLLMConfig(**config_data.model_dump(), user_id=user.id) + # Create the config as admin-owned (user_id=None means shared with all space members) + db_config = NewLLMConfig(**config_data.model_dump(), user_id=None) session.add(db_config) await session.commit() await session.refresh(db_config) @@ -167,7 +159,7 @@ async def create_new_llm_config( ) from e -@router.get("/new-llm-configs", response_model=list[NewLLMConfigRead]) +@router.get("/new-llm-configs", response_model=list[NewLLMConfigPublic]) async def list_new_llm_configs( search_space_id: int, skip: int = 0, @@ -176,11 +168,11 @@ async def list_new_llm_configs( user: User = Depends(current_active_user), ): """ - Get all NewLLMConfigs for a search space. + Get all NewLLMConfigs for a search space (includes global admin configs). Requires LLM_CONFIGS_READ permission. """ try: - # Verify user has permission + # Verify user has permission for their space await check_permission( session, user, @@ -191,7 +183,10 @@ async def list_new_llm_configs( result = await session.execute( select(NewLLMConfig) - .filter(NewLLMConfig.search_space_id == search_space_id) + .filter( + (NewLLMConfig.search_space_id == search_space_id) + | (NewLLMConfig.search_space_id == None) # noqa: E711 + ) .order_by(NewLLMConfig.created_at.desc()) .offset(skip) .limit(limit) @@ -268,30 +263,23 @@ async def update_new_llm_config( config_id: int, update_data: NewLLMConfigUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): """ Update an existing NewLLMConfig. - Requires LLM_CONFIGS_UPDATE permission. + Superuser only. """ try: result = await session.execute( - select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + select(NewLLMConfig).filter( + NewLLMConfig.id == config_id, NewLLMConfig.user_id.is_(None) + ) ) config = result.scalars().first() if not config: raise HTTPException(status_code=404, detail="Configuration not found") - # Verify user has permission - await check_permission( - session, - user, - config.search_space_id, - Permission.LLM_CONFIGS_UPDATE.value, - "You don't have permission to update LLM configurations in this search space", - ) - update_dict = update_data.model_dump(exclude_unset=True) # If updating LLM settings, validate them @@ -360,30 +348,23 @@ async def update_new_llm_config( async def delete_new_llm_config( config_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): """ Delete a NewLLMConfig. - Requires LLM_CONFIGS_DELETE permission. + Superuser only. """ try: result = await session.execute( - select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + select(NewLLMConfig).filter( + NewLLMConfig.id == config_id, NewLLMConfig.user_id.is_(None) + ) ) config = result.scalars().first() if not config: raise HTTPException(status_code=404, detail="Configuration not found") - # Verify user has permission - await check_permission( - session, - user, - config.search_space_id, - Permission.LLM_CONFIGS_DELETE.value, - "You don't have permission to delete LLM configurations in this search space", - ) - await session.delete(config) await session.commit() diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py index 96eefef23..3371b1be6 100644 --- a/surfsense_backend/app/routes/stripe_routes.py +++ b/surfsense_backend/app/routes/stripe_routes.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging import uuid -from datetime import UTC, datetime +from collections import defaultdict +from datetime import UTC, datetime, timedelta from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -13,7 +14,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from stripe import SignatureVerificationError, StripeClient, StripeError from app.config import config -from app.db import PagePurchase, PagePurchaseStatus, SubscriptionStatus, User, get_async_session +from app.db import ( + PagePurchase, + PagePurchaseStatus, + SubscriptionRequest, + SubscriptionRequestStatus, + SubscriptionStatus, + User, + get_async_session, +) from app.schemas.stripe import ( CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, @@ -30,6 +39,28 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/stripe", tags=["stripe"]) +# --------------------------------------------------------------------------- +# Simple in-memory rate limiter for verify-checkout-session (20 calls/60 s) +# Not persistent across workers — acceptable for the low-risk, low-volume +# nature of this endpoint. +# --------------------------------------------------------------------------- +_VERIFY_SESSION_WINDOW_SECS = 60 +_VERIFY_SESSION_MAX_CALLS = 20 +_verify_session_calls: dict[str, list[float]] = defaultdict(list) + + +def _check_verify_session_rate_limit(user_id: str) -> None: + now = datetime.now(UTC).timestamp() + cutoff = now - _VERIFY_SESSION_WINDOW_SECS + calls = [t for t in _verify_session_calls[user_id] if t > cutoff] + if len(calls) >= _VERIFY_SESSION_MAX_CALLS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Try again later.", + ) + calls.append(now) + _verify_session_calls[user_id] = calls + def get_stripe_client() -> StripeClient: """Return a configured Stripe client or raise if Stripe is disabled.""" @@ -145,7 +176,10 @@ async def _get_or_create_stripe_customer( try: customer = stripe_client.v1.customers.create( - params={"email": locked_user.email, "metadata": {"user_id": str(locked_user.id)}} + params={ + "email": locked_user.email, + "metadata": {"user_id": str(locked_user.id)}, + } ) except StripeError as exc: logger.exception("Failed to create Stripe customer for user %s", locked_user.id) @@ -288,6 +322,7 @@ async def _fulfill_completed_purchase( # Subscription event helpers # --------------------------------------------------------------------------- + async def _get_user_by_stripe_customer_id( db_session: AsyncSession, customer_id: str ) -> User | None: @@ -344,16 +379,34 @@ async def _handle_subscription_event( subscription_id, price_id, ) - except Exception: # noqa: BLE001 + except Exception: logger.warning("Could not parse plan from subscription %s", subscription_id) if not customer_id: - logger.error("Subscription event missing customer ID for subscription %s", subscription_id) + logger.error( + "Subscription event missing customer ID for subscription %s", + subscription_id, + ) + return StripeWebhookResponse() + + # Safety: never silently downgrade an active subscription to "free" due to + # an unrecognized price ID. Return early without modifying the user. + if ( + plan_id == "free" + and str(getattr(subscription, "status", "")).lower() == "active" + ): + logger.error( + "Subscription %s is active but price ID is unrecognized — skipping update to avoid downgrade", + subscription_id, + ) return StripeWebhookResponse() user = await _get_user_by_stripe_customer_id(db_session, customer_id) if user is None: - logger.warning("No user found for Stripe customer %s; skipping subscription event", customer_id) + logger.warning( + "No user found for Stripe customer %s; skipping subscription event", + customer_id, + ) return StripeWebhookResponse() # Map Stripe status → SubscriptionStatus enum @@ -398,9 +451,11 @@ async def _handle_subscription_event( limits = config.PLAN_LIMITS.get(plan_id, config.PLAN_LIMITS["free"]) user.monthly_token_limit = limits["monthly_token_limit"] - # Upgrade pages_limit on activation + # Upgrade pages_limit on activation; reset token counter date if new_status == SubscriptionStatus.ACTIVE: user.pages_limit = max(user.pages_used, limits["pages_limit"]) + if user.token_reset_date is None: + user.token_reset_date = datetime.now(UTC).date() # Downgrade pages_limit when canceling if new_status == SubscriptionStatus.CANCELED: @@ -430,18 +485,25 @@ async def _handle_invoice_payment_succeeded( # Reset tokens on subscription renewals and initial subscription creation if billing_reason not in {"subscription_cycle", "subscription_create"}: - logger.info("invoice.payment_succeeded billing_reason=%s; not resetting tokens", billing_reason) + logger.info( + "invoice.payment_succeeded billing_reason=%s; not resetting tokens", + billing_reason, + ) return StripeWebhookResponse() user = await _get_user_by_stripe_customer_id(db_session, customer_id) if user is None: - logger.warning("No user found for Stripe customer %s; skipping token reset", customer_id) + logger.warning( + "No user found for Stripe customer %s; skipping token reset", customer_id + ) return StripeWebhookResponse() user.tokens_used_this_month = 0 user.token_reset_date = datetime.now(UTC).date() - logger.info("Reset tokens_used_this_month for user %s on subscription renewal", user.id) + logger.info( + "Reset tokens_used_this_month for user %s on subscription renewal", user.id + ) await db_session.commit() return StripeWebhookResponse() @@ -456,7 +518,10 @@ async def _handle_invoice_payment_failed( user = await _get_user_by_stripe_customer_id(db_session, customer_id) if user is None: - logger.warning("No user found for Stripe customer %s; skipping past_due update", customer_id) + logger.warning( + "No user found for Stripe customer %s; skipping past_due update", + customer_id, + ) return StripeWebhookResponse() if user.subscription_status == SubscriptionStatus.ACTIVE: @@ -464,7 +529,11 @@ async def _handle_invoice_payment_failed( logger.info("Set subscription to PAST_DUE for user %s", user.id) await db_session.commit() else: - logger.info("invoice.payment_failed for user %s already in status %s; no change", user.id, user.subscription_status) + logger.info( + "invoice.payment_failed for user %s already in status %s; no change", + user.id, + user.subscription_status, + ) return StripeWebhookResponse() @@ -477,26 +546,43 @@ async def _activate_subscription_from_checkout( The full subscription lifecycle will also be handled by customer.subscription.created, but we activate immediately here so the user sees Pro access right after checkout. """ - customer_id = _normalize_optional_string(getattr(checkout_session, "customer", None)) - subscription_id = _normalize_optional_string(getattr(checkout_session, "subscription", None)) + customer_id = _normalize_optional_string( + getattr(checkout_session, "customer", None) + ) + subscription_id = _normalize_optional_string( + getattr(checkout_session, "subscription", None) + ) metadata = _get_metadata(checkout_session) plan_id_str = metadata.get("plan_id", "") if not customer_id: - logger.error("Subscription checkout session missing customer ID: %s", getattr(checkout_session, "id", "")) + logger.error( + "Subscription checkout session missing customer ID: %s", + getattr(checkout_session, "id", ""), + ) return StripeWebhookResponse() user = await _get_user_by_stripe_customer_id(db_session, customer_id) if user is None: - logger.warning("No user found for Stripe customer %s; skipping subscription activation", customer_id) + logger.warning( + "No user found for Stripe customer %s; skipping subscription activation", + customer_id, + ) return StripeWebhookResponse() # Idempotency: already activated - if user.subscription_status == SubscriptionStatus.ACTIVE and user.stripe_subscription_id == subscription_id: - logger.info("Subscription already active for user %s; skipping activation", user.id) + if ( + user.subscription_status == SubscriptionStatus.ACTIVE + and user.stripe_subscription_id == subscription_id + ): + logger.info( + "Subscription already active for user %s; skipping activation", user.id + ) return StripeWebhookResponse() - plan_id = plan_id_str if plan_id_str in {"pro_monthly", "pro_yearly"} else "pro_monthly" + plan_id = ( + plan_id_str if plan_id_str in {"pro_monthly", "pro_yearly"} else "pro_monthly" + ) limits = config.PLAN_LIMITS.get(plan_id, config.PLAN_LIMITS["pro_monthly"]) user.subscription_status = SubscriptionStatus.ACTIVE @@ -512,11 +598,20 @@ async def _activate_subscription_from_checkout( try: stripe_client = get_stripe_client() sub_obj = stripe_client.v1.subscriptions.retrieve(subscription_id) - user.subscription_current_period_end = _period_end_from_subscription(sub_obj) - except Exception: # noqa: BLE001 - logger.warning("Could not retrieve subscription %s for period_end", subscription_id) + user.subscription_current_period_end = _period_end_from_subscription( + sub_obj + ) + except Exception: + logger.warning( + "Could not retrieve subscription %s for period_end", subscription_id + ) - logger.info("Activated subscription for user %s: plan=%s subscription=%s", user.id, plan_id, subscription_id) + logger.info( + "Activated subscription for user %s: plan=%s subscription=%s", + user.id, + plan_id, + subscription_id, + ) await db_session.commit() return StripeWebhookResponse() @@ -601,6 +696,47 @@ async def create_subscription_checkout( db_session: AsyncSession = Depends(get_async_session), ) -> CreateSubscriptionCheckoutResponse: """Create a Stripe Checkout Session for a recurring subscription.""" + # Admin-approval mode: when Stripe is not configured, queue a manual request + if not config.STRIPE_SECRET_KEY: + if user.subscription_status == SubscriptionStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="You already have an active subscription.", + ) + existing = await db_session.execute( + select(SubscriptionRequest) + .where(SubscriptionRequest.user_id == user.id) + .where(SubscriptionRequest.status == SubscriptionRequestStatus.PENDING) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="You already have a pending subscription request.", + ) + cooldown_cutoff = datetime.now(UTC) - timedelta(hours=24) + recently_rejected = await db_session.execute( + select(SubscriptionRequest) + .where(SubscriptionRequest.user_id == user.id) + .where(SubscriptionRequest.status == SubscriptionRequestStatus.REJECTED) + .where(SubscriptionRequest.created_at >= cooldown_cutoff) + ) + if recently_rejected.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Your previous request was rejected. Please wait 24 hours before resubmitting.", + ) + req = SubscriptionRequest(user_id=user.id, plan_id=body.plan_id.value) + db_session.add(req) + await db_session.commit() + logger.info( + "Admin-approval subscription request created for user %s (plan=%s)", + user.id, + body.plan_id.value, + ) + return CreateSubscriptionCheckoutResponse( + checkout_url="", admin_approval_mode=True + ) + stripe_client = get_stripe_client() price_id = _get_price_id_for_plan(body.plan_id) success_url, cancel_url = _get_subscription_urls() @@ -653,6 +789,7 @@ async def verify_checkout_session( user: User = Depends(current_active_user), ) -> dict: """Verify a Stripe Checkout Session belongs to the user and is paid.""" + _check_verify_session_rate_limit(str(user.id)) stripe_client = get_stripe_client() try: session = stripe_client.v1.checkout.sessions.retrieve(session_id) @@ -743,7 +880,9 @@ async def stripe_webhook( return StripeWebhookResponse() if session_mode == "subscription": - return await _activate_subscription_from_checkout(db_session, checkout_session) + return await _activate_subscription_from_checkout( + db_session, checkout_session + ) return await _fulfill_completed_purchase(db_session, checkout_session) diff --git a/surfsense_backend/app/routes/vision_llm_routes.py b/surfsense_backend/app/routes/vision_llm_routes.py index 315c7c9fe..e7a7b35fa 100644 --- a/surfsense_backend/app/routes/vision_llm_routes.py +++ b/surfsense_backend/app/routes/vision_llm_routes.py @@ -15,11 +15,12 @@ from app.db import ( from app.schemas import ( GlobalVisionLLMConfigRead, VisionLLMConfigCreate, + VisionLLMConfigPublic, VisionLLMConfigRead, VisionLLMConfigUpdate, ) from app.services.vision_model_list_service import get_vision_model_list -from app.users import current_active_user +from app.users import current_active_user, current_superuser from app.utils.rbac import check_permission router = APIRouter() @@ -118,18 +119,11 @@ async def get_global_vision_llm_configs( async def create_vision_llm_config( config_data: VisionLLMConfigCreate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): + """Create a new vision LLM config. Superuser only.""" try: - await check_permission( - session, - user, - config_data.search_space_id, - Permission.VISION_CONFIGS_CREATE.value, - "You don't have permission to create vision LLM configs in this search space", - ) - - db_config = VisionLLMConfig(**config_data.model_dump(), user_id=user.id) + db_config = VisionLLMConfig(**config_data.model_dump(), user_id=None) session.add(db_config) await session.commit() await session.refresh(db_config) @@ -145,7 +139,7 @@ async def create_vision_llm_config( ) from e -@router.get("/vision-llm-configs", response_model=list[VisionLLMConfigRead]) +@router.get("/vision-llm-configs", response_model=list[VisionLLMConfigPublic]) async def list_vision_llm_configs( search_space_id: int, skip: int = 0, @@ -164,7 +158,10 @@ async def list_vision_llm_configs( result = await session.execute( select(VisionLLMConfig) - .filter(VisionLLMConfig.search_space_id == search_space_id) + .filter( + (VisionLLMConfig.search_space_id == search_space_id) + | (VisionLLMConfig.search_space_id == None) # noqa: E711 + ) .order_by(VisionLLMConfig.created_at.desc()) .offset(skip) .limit(limit) @@ -217,24 +214,19 @@ async def update_vision_llm_config( config_id: int, update_data: VisionLLMConfigUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): + """Update an existing vision LLM config. Superuser only.""" try: result = await session.execute( - select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + select(VisionLLMConfig).filter( + VisionLLMConfig.id == config_id, VisionLLMConfig.user_id.is_(None) + ) ) db_config = result.scalars().first() if not db_config: raise HTTPException(status_code=404, detail="Config not found") - await check_permission( - session, - user, - db_config.search_space_id, - Permission.VISION_CONFIGS_CREATE.value, - "You don't have permission to update vision LLM configs in this search space", - ) - for key, value in update_data.model_dump(exclude_unset=True).items(): setattr(db_config, key, value) @@ -256,24 +248,19 @@ async def update_vision_llm_config( async def delete_vision_llm_config( config_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User = Depends(current_superuser), ): + """Delete a vision LLM config. Superuser only.""" try: result = await session.execute( - select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + select(VisionLLMConfig).filter( + VisionLLMConfig.id == config_id, VisionLLMConfig.user_id.is_(None) + ) ) db_config = result.scalars().first() if not db_config: raise HTTPException(status_code=404, detail="Config not found") - await check_permission( - session, - user, - db_config.search_space_id, - Permission.VISION_CONFIGS_DELETE.value, - "You don't have permission to delete vision LLM configs in this search space", - ) - await session.delete(db_config) await session.commit() return { diff --git a/surfsense_backend/app/schemas/image_generation.py b/surfsense_backend/app/schemas/image_generation.py index 69f534e20..79f76a50c 100644 --- a/surfsense_backend/app/schemas/image_generation.py +++ b/surfsense_backend/app/schemas/image_generation.py @@ -55,8 +55,9 @@ class ImageGenerationConfigBase(BaseModel): class ImageGenerationConfigCreate(ImageGenerationConfigBase): """Schema for creating a new ImageGenerationConfig.""" - search_space_id: int = Field( - ..., description="Search space ID to associate the config with" + search_space_id: int | None = Field( + None, + description="Search space ID. None = global admin config visible to all spaces", ) @@ -79,8 +80,8 @@ class ImageGenerationConfigRead(ImageGenerationConfigBase): id: int created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) @@ -98,8 +99,8 @@ class ImageGenerationConfigPublic(BaseModel): api_version: str | None = None litellm_params: dict[str, Any] | None = None created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/schemas/new_llm_config.py b/surfsense_backend/app/schemas/new_llm_config.py index 4fb3aa400..d3a36b613 100644 --- a/surfsense_backend/app/schemas/new_llm_config.py +++ b/surfsense_backend/app/schemas/new_llm_config.py @@ -60,8 +60,9 @@ class NewLLMConfigBase(BaseModel): class NewLLMConfigCreate(NewLLMConfigBase): """Schema for creating a new NewLLMConfig.""" - search_space_id: int = Field( - ..., description="Search space ID to associate the config with" + search_space_id: int | None = Field( + None, + description="Search space ID. None = global admin config visible to all spaces", ) @@ -90,8 +91,8 @@ class NewLLMConfigRead(NewLLMConfigBase): id: int created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) @@ -119,8 +120,8 @@ class NewLLMConfigPublic(BaseModel): citations_enabled: bool created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/schemas/stripe.py b/surfsense_backend/app/schemas/stripe.py index f1322b5e9..4d8ffd1a1 100644 --- a/surfsense_backend/app/schemas/stripe.py +++ b/surfsense_backend/app/schemas/stripe.py @@ -33,6 +33,7 @@ class CreateSubscriptionCheckoutResponse(BaseModel): """Response containing the Stripe-hosted subscription checkout URL.""" checkout_url: str + admin_approval_mode: bool = False class CreateCheckoutSessionResponse(BaseModel): diff --git a/surfsense_backend/app/schemas/users.py b/surfsense_backend/app/schemas/users.py index 88d0a4f37..42940da83 100644 --- a/surfsense_backend/app/schemas/users.py +++ b/surfsense_backend/app/schemas/users.py @@ -6,6 +6,10 @@ from fastapi_users import schemas class UserRead(schemas.BaseUser[uuid.UUID]): pages_limit: int pages_used: int + monthly_token_limit: int + tokens_used_this_month: int + plan_id: str + subscription_status: str display_name: str | None = None avatar_url: str | None = None diff --git a/surfsense_backend/app/schemas/vision_llm.py b/surfsense_backend/app/schemas/vision_llm.py index ab2e609dc..3f01d2e44 100644 --- a/surfsense_backend/app/schemas/vision_llm.py +++ b/surfsense_backend/app/schemas/vision_llm.py @@ -20,7 +20,10 @@ class VisionLLMConfigBase(BaseModel): class VisionLLMConfigCreate(VisionLLMConfigBase): - search_space_id: int = Field(...) + search_space_id: int | None = Field( + None, + description="Search space ID. None = global admin config visible to all spaces", + ) class VisionLLMConfigUpdate(BaseModel): @@ -38,8 +41,8 @@ class VisionLLMConfigUpdate(BaseModel): class VisionLLMConfigRead(VisionLLMConfigBase): id: int created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) @@ -55,8 +58,8 @@ class VisionLLMConfigPublic(BaseModel): api_version: str | None = None litellm_params: dict[str, Any] | None = None created_at: datetime - search_space_id: int - user_id: uuid.UUID + search_space_id: int | None = None + user_id: uuid.UUID | None = None model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/services/page_limit_service.py b/surfsense_backend/app/services/page_limit_service.py index 47fe07fc6..987edcf9c 100644 --- a/surfsense_backend/app/services/page_limit_service.py +++ b/surfsense_backend/app/services/page_limit_service.py @@ -51,16 +51,25 @@ class PageLimitService: """ from app.db import User - # Get user's current page usage + # Get user's current page usage and subscription status result = await self.session.execute( - select(User.pages_used, User.pages_limit).where(User.id == user_id) + select(User.pages_used, User.pages_limit, User.subscription_status).where( + User.id == user_id + ) ) row = result.first() if not row: raise ValueError(f"User with ID {user_id} not found") - pages_used, pages_limit = row + pages_used, pages_limit, sub_status = row + + # PAST_DUE: enforce free-tier page limit to prevent usage without payment + if str(sub_status).lower() == "past_due": + from app.config import config as app_config # avoid circular import + + free_limit = app_config.PLAN_LIMITS.get("free", {}).get("pages_limit", 500) + pages_limit = min(pages_limit, free_limit) # Check if adding estimated pages would exceed limit if pages_used + estimated_pages > pages_limit: diff --git a/surfsense_backend/app/services/token_quota_service.py b/surfsense_backend/app/services/token_quota_service.py index 9ec6e07df..ae07d9e00 100644 --- a/surfsense_backend/app/services/token_quota_service.py +++ b/surfsense_backend/app/services/token_quota_service.py @@ -97,6 +97,15 @@ class TokenQuotaService: tokens_used = user.tokens_used_this_month or 0 token_limit = user.monthly_token_limit or 0 + # PAST_DUE: enforce free-tier token limit to prevent usage without payment + if str(getattr(user, "subscription_status", "")).lower() == "past_due": + from app.config import config as app_config # avoid circular import + + free_limit = app_config.PLAN_LIMITS.get("free", {}).get( + "monthly_token_limit", 50000 + ) + token_limit = min(token_limit, free_limit) + # Strict boundary: >= means at-limit is also exceeded if tokens_used + estimated_tokens >= token_limit and token_limit > 0: raise TokenQuotaExceededError( diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 66e0cc8dd..6e8f22939 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -300,3 +300,4 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) current_active_user = fastapi_users.current_user(active=True) current_optional_user = fastapi_users.current_user(active=True, optional=True) +current_superuser = fastapi_users.current_user(active=True, superuser=True) diff --git a/surfsense_web/app/admin/subscription-requests/page.tsx b/surfsense_web/app/admin/subscription-requests/page.tsx new file mode 100644 index 000000000..bdf184b80 --- /dev/null +++ b/surfsense_web/app/admin/subscription-requests/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { authenticatedFetch, isAuthenticated, redirectToLogin } from "@/lib/auth-utils"; +import { BACKEND_URL } from "@/lib/env-config"; + +interface SubscriptionRequestItem { + id: string; + user_id: string; + user_email: string; + plan_id: string; + status: string; + created_at: string; + approved_at: string | null; + approved_by: string | null; +} + +export default function AdminSubscriptionRequestsPage() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [actionInProgress, setActionInProgress] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); + + const fetchRequests = async () => { + if (!isAuthenticated()) { + redirectToLogin(); + return; + } + + try { + const response = await authenticatedFetch( + `${BACKEND_URL}/api/v1/admin/subscription-requests` + ); + if (response.status === 401) { + redirectToLogin(); + return; + } + if (response.status === 403) { + setAccessDenied(true); + return; + } + if (!response.ok) { + toast.error("Failed to load subscription requests."); + return; + } + const data: SubscriptionRequestItem[] = await response.json(); + setRequests(data); + } catch { + toast.error("Failed to load subscription requests."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRequests(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleAction = async (requestId: string, action: "approve" | "reject") => { + setActionInProgress(requestId); + try { + const response = await authenticatedFetch( + `${BACKEND_URL}/api/v1/admin/subscription-requests/${requestId}/${action}`, + { method: "POST" } + ); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + toast.error(err.detail ?? `Failed to ${action} request.`); + return; + } + toast.success(`Request ${action}d successfully.`); + setRequests((prev) => prev.filter((r) => r.id !== requestId)); + } catch { + toast.error(`Failed to ${action} request.`); + } finally { + setActionInProgress(null); + } + }; + + if (accessDenied) { + return ( +
+

+ Access denied. Superuser privileges required. +

+
+ ); + } + + if (loading) { + return ( +
+

Loading…

+
+ ); + } + + return ( +
+

Subscription Requests

+ + {requests.length === 0 ? ( +

No pending subscription requests.

+ ) : ( +
+ + + + + + + + + + + {requests.map((req) => ( + + + + + + + ))} + +
UserPlanRequested AtActions
{req.user_email}{req.plan_id.replace(/_/g, " ")} + {new Date(req.created_at).toLocaleString()} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 31ab2debc..2c290b511 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -703,6 +703,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid ? { pagesUsed: user.pages_used, pagesLimit: user.pages_limit, + tokensUsed: user.tokens_used_this_month, + tokensLimit: user.monthly_token_limit, } : undefined; diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 720aaecf1..a1c7dfc05 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -38,6 +38,8 @@ export interface ChatItem { export interface PageUsage { pagesUsed: number; pagesLimit: number; + tokensUsed: number; + tokensLimit: number; } export interface IconRailProps { @@ -78,6 +80,8 @@ export interface ChatsSectionProps { export interface PageUsageDisplayProps { pagesUsed: number; pagesLimit: number; + tokensUsed: number; + tokensLimit: number; } export interface SidebarUserProfileProps { diff --git a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx index 7dd46e484..958181ed2 100644 --- a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx +++ b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx @@ -11,12 +11,32 @@ import { stripeApiService } from "@/lib/apis/stripe-api.service"; interface PageUsageDisplayProps { pagesUsed: number; pagesLimit: number; + tokensUsed: number; + tokensLimit: number; } -export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return n.toLocaleString(); +} + +function progressColor(percent: number): string { + if (percent > 95) return "[&>div]:bg-red-500"; + if (percent > 80) return "[&>div]:bg-amber-500"; + return ""; +} + +export function PageUsageDisplay({ + pagesUsed, + pagesLimit, + tokensUsed, + tokensLimit, +}: PageUsageDisplayProps) { const params = useParams(); const searchSpaceId = params?.search_space_id ?? ""; - const usagePercentage = (pagesUsed / pagesLimit) * 100; + const pagePercent = Math.min(100, (pagesUsed / pagesLimit) * 100); + const tokenPercent = Math.min(100, (tokensUsed / tokensLimit) * 100); const { data: stripeStatus } = useQuery({ queryKey: ["stripe-status"], queryFn: () => stripeApiService.getStatus(), @@ -25,14 +45,29 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp return (
-
-
- - {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages - - {usagePercentage.toFixed(0)}% +
+ {/* Page usage */} +
+
+ + {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages + + {pagePercent.toFixed(0)}% +
+
- + + {/* Token usage */} +
+
+ + {formatTokenCount(tokensUsed)} / {formatTokenCount(tokensLimit)} tokens + + {tokenPercent.toFixed(0)}% +
+ +
+ + )} { @@ -129,12 +133,12 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { ) : ( )} diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index ec5bf6760..4c810d615 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "jotai"; import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react"; -import { type UIEvent, useCallback, useMemo, useState } from "react"; +import { type UIEvent, useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { globalImageGenConfigsAtom, @@ -45,8 +45,8 @@ import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; interface ModelSelectorProps { - onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; - onAddNewLLM: () => void; + onEditLLM?: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; + onAddNewLLM?: () => void; onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; onAddNewImage?: () => void; onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void; @@ -155,6 +155,30 @@ export function ModelSelector({ ); }, [currentVisionConfig]); + // ─── Auto-reset stale config selections ─── + // When configs finish loading and a saved preference points to a deleted config, + // silently clear the stale ID so the UI shows "Select a model" instead of erroring. + useEffect(() => { + if (!preferences || !searchSpaceId || llmUserLoading || llmGlobalLoading || prefsLoading) + return; + const agentLlmId = preferences.agent_llm_id; + if (agentLlmId === null || agentLlmId === undefined) return; + const existsInUser = llmUserConfigs?.some((c) => c.id === agentLlmId); + const existsInGlobal = llmGlobalConfigs?.some((c) => c.id === agentLlmId); + if (!existsInUser && !existsInGlobal) { + updatePreferences({ search_space_id: Number(searchSpaceId), data: { agent_llm_id: null } }); + } + }, [ + preferences, + llmUserConfigs, + llmGlobalConfigs, + llmUserLoading, + llmGlobalLoading, + prefsLoading, + searchSpaceId, + updatePreferences, + ]); + // ─── LLM filtering ─── const filteredLLMGlobal = useMemo(() => { if (!llmGlobalConfigs) return []; @@ -520,7 +544,7 @@ export function ModelSelector({
- {!isAutoMode && ( + {!isAutoMode && onEditLLM && (
- + {onEditLLM && ( + + )} ); @@ -600,21 +626,23 @@ export function ModelSelector({ )} - {/* Add New LLM Config */} -
- -
+ {/* Add New LLM Config — admin only */} + {onAddNewLLM && ( +
+ +
+ )} diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 2462096cd..028bc3593 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -56,6 +56,12 @@ function PricingBasic() { } const data = await response.json(); + + if (data.admin_approval_mode) { + toast.success("Subscription request submitted! An admin will approve it shortly."); + return; + } + const checkoutUrl = data.checkout_url; if (typeof checkoutUrl === "string" && checkoutUrl.startsWith("https://")) { window.location.href = checkoutUrl; @@ -103,7 +109,11 @@ function PricingBasic() { "Priority support on Discord", ], description: "For power users and professionals", - buttonText: isLoading ? "Redirecting…" : isOnline ? "Upgrade to Pro" : "Offline — unavailable", + buttonText: isLoading + ? "Redirecting…" + : isOnline + ? "Upgrade to Pro" + : "Offline — unavailable", href: "#", isPopular: true, onAction: handleUpgradePro, diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx index 831519aa2..5b19fa181 100644 --- a/surfsense_web/components/settings/image-model-manager.tsx +++ b/surfsense_web/components/settings/image-model-manager.tsx @@ -9,6 +9,7 @@ import { imageGenConfigsAtom, } from "@/atoms/image-gen-config/image-gen-config-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ImageConfigDialog } from "@/components/shared/image-config-dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { @@ -77,15 +78,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { return map; }, [members]); + // Permissions — only superusers can create/edit/delete model configs const { data: access } = useAtomValue(myAccessAtom); - const canCreate = - !!access && - (access.is_owner || (access.permissions?.includes("image_generations:create") ?? false)); - const canDelete = - !!access && - (access.is_owner || (access.permissions?.includes("image_generations:delete") ?? false)); - const canUpdate = canCreate; - const isReadOnly = !canCreate && !canDelete; + const { data: currentUser } = useAtomValue(currentUserAtom); + const isAdmin = !!currentUser?.is_superuser; + const canCreate = isAdmin; + const canDelete = isAdmin; + const canUpdate = isAdmin; + const isReadOnly = !isAdmin; const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingConfig, setEditingConfig] = useState(null); diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx index f83251426..dd7c102b2 100644 --- a/surfsense_web/components/settings/model-config-manager.tsx +++ b/surfsense_web/components/settings/model-config-manager.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; import { globalNewLLMConfigsAtom, @@ -87,15 +88,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { return map; }, [members]); - // Permissions + // Permissions — only superusers can create/edit/delete model configs const { data: access } = useAtomValue(myAccessAtom); - const canCreate = - !!access && (access.is_owner || (access.permissions?.includes("llm_configs:create") ?? false)); - const canUpdate = - !!access && (access.is_owner || (access.permissions?.includes("llm_configs:update") ?? false)); - const canDelete = - !!access && (access.is_owner || (access.permissions?.includes("llm_configs:delete") ?? false)); - const isReadOnly = !canCreate && !canUpdate && !canDelete; + const { data: currentUser } = useAtomValue(currentUserAtom); + const isAdmin = !!currentUser?.is_superuser; + const canCreate = isAdmin; + const canUpdate = isAdmin; + const canDelete = isAdmin; + const isReadOnly = !isAdmin; // Local state const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/surfsense_web/components/settings/vision-model-manager.tsx b/surfsense_web/components/settings/vision-model-manager.tsx index 57ea8c205..030d83770 100644 --- a/surfsense_web/components/settings/vision-model-manager.tsx +++ b/surfsense_web/components/settings/vision-model-manager.tsx @@ -4,6 +4,7 @@ import { useAtomValue } from "jotai"; import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms"; import { globalVisionLLMConfigsAtom, @@ -78,19 +79,14 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) { return map; }, [members]); + // Permissions — only superusers can create/edit/delete model configs const { data: access } = useAtomValue(myAccessAtom); - const canCreate = useMemo(() => { - if (!access) return false; - if (access.is_owner) return true; - return access.permissions?.includes("vision_configs:create") ?? false; - }, [access]); - const canDelete = useMemo(() => { - if (!access) return false; - if (access.is_owner) return true; - return access.permissions?.includes("vision_configs:delete") ?? false; - }, [access]); - const canUpdate = canCreate; - const isReadOnly = !canCreate && !canDelete; + const { data: currentUser } = useAtomValue(currentUserAtom); + const isAdmin = !!currentUser?.is_superuser; + const canCreate = isAdmin; + const canDelete = isAdmin; + const canUpdate = isAdmin; + const isReadOnly = !isAdmin; const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingConfig, setEditingConfig] = useState(null); diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index e7f4451b8..9783c0ef6 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -4,6 +4,7 @@ import { useAtom } from "jotai"; import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; @@ -132,6 +133,7 @@ export function DocumentUploadTab({ onAccordionStateChange, }: DocumentUploadTabProps) { const t = useTranslations("upload_documents"); + const router = useRouter(); const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [accordionValue, setAccordionValue] = useState(""); @@ -379,11 +381,20 @@ export function DocumentUploadTab({ setFolderUpload(null); onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : "Upload failed"; - trackDocumentUploadFailure(Number(searchSpaceId), message); - toast(t("upload_error"), { - description: `${t("upload_error_desc")}: ${message}`, - }); + const status = (error as { status?: number }).status; + if (status === 402) { + const message = error instanceof Error ? error.message : "Page quota exceeded"; + trackDocumentUploadFailure(Number(searchSpaceId), message); + toast.error(message, { + action: { label: "Upgrade", onClick: () => router.push("/pricing") }, + }); + } else { + const message = error instanceof Error ? error.message : "Upload failed"; + trackDocumentUploadFailure(Number(searchSpaceId), message); + toast(t("upload_error"), { + description: `${t("upload_error_desc")}: ${message}`, + }); + } } finally { setIsFolderUploading(false); setUploadProgress(0); @@ -422,11 +433,20 @@ export function DocumentUploadTab({ onError: (error: unknown) => { if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); setUploadProgress(0); - const message = error instanceof Error ? error.message : "Upload failed"; - trackDocumentUploadFailure(Number(searchSpaceId), message); - toast(t("upload_error"), { - description: `${t("upload_error_desc")}: ${message}`, - }); + const status = (error as { status?: number }).status; + if (status === 402) { + const message = error instanceof Error ? error.message : "Page quota exceeded"; + trackDocumentUploadFailure(Number(searchSpaceId), message); + toast.error(message, { + action: { label: "Upgrade", onClick: () => router.push("/pricing") }, + }); + } else { + const message = error instanceof Error ? error.message : "Upload failed"; + trackDocumentUploadFailure(Number(searchSpaceId), message); + toast(t("upload_error"), { + description: `${t("upload_error_desc")}: ${message}`, + }); + } }, } ); @@ -533,35 +553,35 @@ export function DocumentUploadTab({ ) ) : ( -
{ - if (!isElectron) fileInputRef.current?.click(); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
{ if (!isElectron) fileInputRef.current?.click(); - } - }} - > - -
-

- {isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")} -

-

{t("file_size_limit")}

-
-
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!isElectron) fileInputRef.current?.click(); + } + }} > - {renderBrowseButton({ fullWidth: true })} -
-
+ +
+

+ {isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")} +

+

{t("file_size_limit")}

+
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {renderBrowseButton({ fullWidth: true })} +
+
)} diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts index 85fee49a8..3179c9f48 100644 --- a/surfsense_web/contracts/types/user.types.ts +++ b/surfsense_web/contracts/types/user.types.ts @@ -8,6 +8,10 @@ export const user = z.object({ is_verified: z.boolean(), pages_limit: z.number(), pages_used: z.number(), + monthly_token_limit: z.number(), + tokens_used_this_month: z.number(), + plan_id: z.string(), + subscription_status: z.string(), display_name: z.string().nullish(), avatar_url: z.string().nullish(), });