mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
feat(workspaces): implement workspace limits feature with backend integration and UI updates
This commit is contained in:
parent
1385ff4789
commit
38b784fbac
12 changed files with 176 additions and 18 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue