feat(workspaces): implement workspace limits feature with backend integration and UI updates

This commit is contained in:
Anish Sarkar 2026-07-16 15:44:49 +05:30
parent 1385ff4789
commit 38b784fbac
12 changed files with 176 additions and 18 deletions

View file

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

View file

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

View file

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

View file

@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeWorkspaceIdAtom = atom<string | null>(null);
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
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),

View file

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

View file

@ -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);
}
};

View file

@ -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 (
<div className={cn("flex h-full w-14 min-h-0 flex-col items-center", className)}>
@ -99,18 +107,21 @@ export function IconRail({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
</Button>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
disabled={isAtWorkspaceLimit}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50 disabled:opacity-50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add workspace
{addWorkspaceLabel}
</TooltipContent>
</Tooltip>

View file

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

View file

@ -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 (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@ -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"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</div>
</ScrollArea>

View file

@ -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<typeof workspace>;
export type WorkspaceLimits = z.infer<typeof workspaceLimits>;
export type GetWorkspacesRequest = z.infer<typeof getWorkspacesRequest>;
export type GetWorkspacesResponse = z.infer<typeof getWorkspacesResponse>;
export type CreateWorkspaceRequest = z.infer<typeof createWorkspaceRequest>;

View file

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

View file

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