diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 92e448d9a..830a2a04e 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -850,6 +850,9 @@ class Config: # Auth AUTH_TYPE = os.getenv("AUTH_TYPE", "LOCAL") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" + # Max workspaces a user may own. The frontend reads this through the + # workspace limits route; do not duplicate this value client-side. + MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "100")) # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 9dd3f196c..53d4344f7 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.auth.context import AuthContext +from app.config import config from app.db import ( Permission, Workspace, @@ -81,6 +82,22 @@ async def create_workspace( user = auth.user try: workspace_data = workspace.model_dump() + not_deleting = ~Workspace.name.startswith("[DELETING] ") + owned_count = ( + await session.execute( + select(func.count()) + .select_from(Workspace) + .filter(Workspace.user_id == user.id, not_deleting) + ) + ).scalar_one() + if owned_count >= config.MAX_WORKSPACES_PER_USER: + raise HTTPException( + status_code=409, + detail=( + "Workspace limit reached. You can own at most " + f"{config.MAX_WORKSPACES_PER_USER} workspaces." + ), + ) # citations_enabled defaults to True (handled by Pydantic schema) # qna_custom_instructions defaults to None/empty (handled by DB) @@ -207,6 +224,11 @@ async def read_workspaces( ) from e +@router.get("/workspaces/limits") +async def read_workspace_limits(_auth: AuthContext = Depends(allow_any_principal)): + return {"max_workspaces_per_user": config.MAX_WORKSPACES_PER_USER} + + @router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead) async def read_workspace( workspace_id: int, diff --git a/surfsense_backend/tests/unit/routes/test_workspaces_limits.py b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py new file mode 100644 index 000000000..ab89d08bc --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.routes import workspaces_routes +from app.schemas import WorkspaceCreate + +pytestmark = pytest.mark.unit + + +class _CountResult: + def __init__(self, count: int): + self.count = count + + def scalar_one(self) -> int: + return self.count + + +class _FakeSession: + def __init__(self, owned_count: int): + self.owned_count = owned_count + + async def execute(self, _statement): + return _CountResult(self.owned_count) + + +@pytest.mark.asyncio +async def test_read_workspace_limits_uses_backend_config(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 37, + raising=False, + ) + + result = await workspaces_routes.read_workspace_limits(_auth=SimpleNamespace()) + + assert result == {"max_workspaces_per_user": 37} + + +@pytest.mark.asyncio +async def test_create_workspace_rejects_when_owned_limit_reached(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 2, + raising=False, + ) + auth = SimpleNamespace(user=SimpleNamespace(id="user-1")) + session = _FakeSession(owned_count=2) + + with pytest.raises(HTTPException) as exc_info: + await workspaces_routes.create_workspace( + WorkspaceCreate(name="Extra", description=""), + session=session, + auth=auth, + ) + + assert exc_info.value.status_code == 409 + assert "at most 2 workspaces" in exc_info.value.detail diff --git a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts index 85203cc1d..09e2aa290 100644 --- a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts +++ b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts @@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; export const activeWorkspaceIdAtom = atom(null); -export const workspacesQueryParamsAtom = atom({ - skip: 0, - limit: 10, - owned_only: false, +export const workspaceLimitsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.workspaces.limits, + staleTime: Infinity, + queryFn: async () => { + return workspacesApiService.getWorkspaceLimits(); + }, + }; }); export const workspacesAtom = atomWithQuery((get) => { - const queryParams = get(workspacesQueryParamsAtom); + const workspaceLimits = get(workspaceLimitsAtom).data; + const queryParams: GetWorkspacesRequest["queryParams"] = { + skip: 0, + ...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}), + owned_only: false, + }; return { queryKey: cacheKeys.workspaces.withQueryParams(queryParams), diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 682d2923e..a61a1a703 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -14,7 +14,7 @@ import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; -import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; +import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog"; import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight"; import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; @@ -84,6 +84,7 @@ export function LayoutDataProvider({ refetch: refetchWorkspaces, isSuccess: workspacesLoaded, } = useAtomValue(workspacesAtom); + const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom); const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); @@ -225,6 +226,13 @@ export function LayoutDataProvider({ createdAt: space.created_at, })); }, [workspacesData]); + const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user; + const ownedWorkspaceCount = workspaces.reduce( + (count, space) => count + (space.isOwner ? 1 : 0), + 0 + ); + const isAtWorkspaceLimit = + maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser; // Find active workspace from list, falling back to the route-scoped detail query. const activeWorkspace: Workspace | null = useMemo(() => { @@ -335,8 +343,14 @@ export function LayoutDataProvider({ ); const handleAddWorkspace = useCallback(() => { + if (isAtWorkspaceLimit) { + toast.error( + `Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.` + ); + return; + } setIsCreateWorkspaceDialogOpen(true); - }, []); + }, [isAtWorkspaceLimit, maxWorkspacesPerUser]); const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); @@ -663,6 +677,8 @@ export function LayoutDataProvider({ onWorkspaceDelete={handleWorkspaceDeleteClick} onWorkspaceSettings={handleWorkspaceSettings} onAddWorkspace={handleAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={activeWorkspace} navItems={navItems} onNavItemClick={handleNavItemClick} diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index 75192e5e2..d8577fe1d 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import * as z from "zod"; import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; import { Button } from "@/components/ui/button"; @@ -87,6 +88,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia ); } catch (error) { console.error("Failed to create workspace:", error); + toast.error(error instanceof Error ? error.message : "Failed to create workspace"); setIsSubmitting(false); } }; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 1f700c89f..0eeac9ca7 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -20,6 +20,8 @@ interface IconRailProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; isSingleRailMode?: boolean; onNewChat?: () => void; navItems?: NavItem[]; @@ -42,6 +44,8 @@ export function IconRail({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, isSingleRailMode = false, onNewChat, navItems = [], @@ -78,6 +82,10 @@ export function IconRail({ })), ] : []; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return (
@@ -99,18 +107,21 @@ export function IconRail({ - + + + - Add workspace + {addWorkspaceLabel} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 44a2259ee..70bb317dd 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -95,6 +95,8 @@ interface LayoutShellProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -198,6 +200,8 @@ export function LayoutShell({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -280,6 +284,8 @@ export function LayoutShell({ activeWorkspaceId={activeWorkspaceId} onWorkspaceSelect={onWorkspaceSelect} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={workspace} navItems={navItems} onNavItemClick={onNavItemClick} @@ -352,6 +358,8 @@ export function LayoutShell({ onWorkspaceDelete={onWorkspaceDelete} onWorkspaceSettings={onWorkspaceSettings} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} isSingleRailMode={false} user={user} onUserSettings={onUserSettings} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index de1b08a6a..c84d39891 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -17,6 +17,8 @@ interface MobileSidebarProps { activeWorkspaceId: number | null; onWorkspaceSelect: (id: number) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -67,6 +69,8 @@ export function MobileSidebar({ onWorkspaceSelect, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -107,6 +111,10 @@ export function MobileSidebar({ onChatSelect(chat); onOpenChange(false); }; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return ( @@ -136,10 +144,12 @@ export function MobileSidebar({ variant="ghost" size="icon" onClick={onAddWorkspace} + disabled={isAtWorkspaceLimit} + title={addWorkspaceLabel} className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50" > - Add workspace + {addWorkspaceLabel}
diff --git a/surfsense_web/contracts/types/workspace.types.ts b/surfsense_web/contracts/types/workspace.types.ts index a777f810a..8a9502131 100644 --- a/surfsense_web/contracts/types/workspace.types.ts +++ b/surfsense_web/contracts/types/workspace.types.ts @@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({ export const getWorkspacesResponse = z.array(workspace); +/** + * Workspace limits + */ +export const workspaceLimits = z.object({ + max_workspaces_per_user: z.number(), +}); + /** * Create workspace */ @@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({ // Inferred types export type Workspace = z.infer; +export type WorkspaceLimits = z.infer; export type GetWorkspacesRequest = z.infer; export type GetWorkspacesResponse = z.infer; export type CreateWorkspaceRequest = z.infer; diff --git a/surfsense_web/lib/apis/workspaces-api.service.ts b/surfsense_web/lib/apis/workspaces-api.service.ts index 8e1494e00..0bd0370a5 100644 --- a/surfsense_web/lib/apis/workspaces-api.service.ts +++ b/surfsense_web/lib/apis/workspaces-api.service.ts @@ -19,11 +19,16 @@ import { updateWorkspaceApiAccessResponse, updateWorkspaceRequest, updateWorkspaceResponse, + workspaceLimits, } from "@/contracts/types/workspace.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; class WorkspacesApiService { + getWorkspaceLimits = async () => { + return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits); + }; + /** * Get a list of workspaces with optional filtering and pagination */ diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 13c141f69..9fc2ef2cb 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -49,6 +49,7 @@ export const cacheKeys = { }, workspaces: { all: ["workspaces"] as const, + limits: ["workspaces", "limits"] as const, withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) => ["workspaces", ...stableEntries(queries)] as const, detail: (workspaceId: string) => ["workspaces", workspaceId] as const,