mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
feat: implement permission-based access controls for image and model configuration managers
This commit is contained in:
parent
adc4bc7075
commit
83033cebb9
2 changed files with 204 additions and 89 deletions
|
|
@ -19,7 +19,7 @@ import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
createImageGenConfigMutationAtom,
|
createImageGenConfigMutationAtom,
|
||||||
deleteImageGenConfigMutationAtom,
|
deleteImageGenConfigMutationAtom,
|
||||||
|
|
@ -148,6 +148,22 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
return map;
|
return map;
|
||||||
}, [members]);
|
}, [members]);
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
const { data: access } = useAtomValue(myAccessAtom);
|
||||||
|
const canCreate = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("image_generations:create") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const canDelete = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("image_generations:delete") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
// Backend uses image_generations:create for update as well
|
||||||
|
const canUpdate = canCreate;
|
||||||
|
const isReadOnly = !canCreate && !canDelete;
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
|
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
|
||||||
|
|
@ -344,6 +360,34 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Read-only / Limited permissions notice */}
|
||||||
|
{access && !isLoading && isReadOnly && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You have <span className="font-medium">read-only</span> access to image generation
|
||||||
|
configurations. Contact a space owner to request additional permissions.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You can{" "}
|
||||||
|
{[canCreate && "create and edit", canDelete && "delete"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" and ")}{" "}
|
||||||
|
image model configurations
|
||||||
|
{!canDelete && ", but cannot delete them"}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Global info */}
|
{/* Global info */}
|
||||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||||
<Alert className="bg-muted/50 py-3">
|
<Alert className="bg-muted/50 py-3">
|
||||||
|
|
@ -530,12 +574,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
|
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
|
||||||
<Button
|
{canCreate && (
|
||||||
onClick={openNewDialog}
|
<Button
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
onClick={openNewDialog}
|
||||||
>
|
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||||
Add Image Model
|
>
|
||||||
</Button>
|
Add Image Model
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(userConfigs?.length ?? 0) === 0 ? (
|
{(userConfigs?.length ?? 0) === 0 ? (
|
||||||
|
|
@ -546,12 +592,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
|
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
|
||||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
|
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
|
||||||
Add your own image generation model (DALL-E 3, GPT Image 1, etc.)
|
{canCreate
|
||||||
|
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
|
||||||
|
: "No image models have been added to this space yet. Contact a space owner to add one."}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={openNewDialog} size="lg" className="gap-2 text-xs md:text-sm h-9 md:h-10">
|
{canCreate && (
|
||||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
<Button onClick={openNewDialog} size="lg" className="gap-2 text-xs md:text-sm h-9 md:h-10">
|
||||||
Add First Image Model
|
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
</Button>
|
Add First Image Model
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -586,38 +636,44 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(canUpdate || canDelete) && (
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||||
<TooltipProvider>
|
{canUpdate && (
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => openEditDialog(config)}
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
onClick={() => openEditDialog(config)}
|
||||||
>
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
<Edit3 className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Edit3 className="h-3 w-3" />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent>Edit</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>Edit</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
<TooltipProvider>
|
</TooltipProvider>
|
||||||
<Tooltip>
|
)}
|
||||||
<TooltipTrigger asChild>
|
{canDelete && (
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => setConfigToDelete(config)}
|
<Button
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Trash2 className="h-3 w-3" />
|
onClick={() => setConfigToDelete(config)}
|
||||||
</Button>
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>Delete</TooltipContent>
|
<Trash2 className="h-3 w-3" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
</TooltipProvider>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider + Model */}
|
{/* Provider + Model */}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
createNewLLMConfigMutationAtom,
|
createNewLLMConfigMutationAtom,
|
||||||
deleteNewLLMConfigMutationAtom,
|
deleteNewLLMConfigMutationAtom,
|
||||||
|
|
@ -120,6 +120,25 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
return map;
|
return map;
|
||||||
}, [members]);
|
}, [members]);
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
const { data: access } = useAtomValue(myAccessAtom);
|
||||||
|
const canCreate = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("llm_configs:create") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const canUpdate = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("llm_configs:update") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const canDelete = useMemo(() => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes("llm_configs:delete") ?? false;
|
||||||
|
}, [access]);
|
||||||
|
const isReadOnly = !canCreate && !canUpdate && !canDelete;
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
|
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
|
||||||
|
|
@ -187,13 +206,15 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
|
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{canCreate && (
|
||||||
onClick={openNewDialog}
|
<Button
|
||||||
size="sm"
|
onClick={openNewDialog}
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
size="sm"
|
||||||
>
|
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||||
Add Configuration
|
>
|
||||||
</Button>
|
Add Configuration
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fetch Error Alert */}
|
{/* Fetch Error Alert */}
|
||||||
|
|
@ -215,6 +236,34 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Read-only / Limited permissions notice */}
|
||||||
|
{access && !isLoading && isReadOnly && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You have <span className="font-medium">read-only</span> access to LLM configurations.
|
||||||
|
Contact a space owner to request additional permissions.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
|
You can{" "}
|
||||||
|
{[canCreate && "create", canUpdate && "edit", canDelete && "delete"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" and ")}{" "}
|
||||||
|
configurations
|
||||||
|
{!canDelete && ", but cannot delete them"}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Global Configs Info */}
|
{/* Global Configs Info */}
|
||||||
{globalConfigs.length > 0 && (
|
{globalConfigs.length > 0 && (
|
||||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
|
@ -279,17 +328,21 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<div className="space-y-2 mb-4 md:mb-6">
|
<div className="space-y-2 mb-4 md:mb-6">
|
||||||
<h3 className="text-lg md:text-xl font-semibold">No Configurations Yet</h3>
|
<h3 className="text-lg md:text-xl font-semibold">No Configurations Yet</h3>
|
||||||
<p className="text-xs md:text-sm text-muted-foreground max-w-sm">
|
<p className="text-xs md:text-sm text-muted-foreground max-w-sm">
|
||||||
Create your first AI configuration to customize how your agent responds
|
{canCreate
|
||||||
|
? "Create your first AI configuration to customize how your agent responds"
|
||||||
|
: "No AI configurations have been added to this space yet. Contact a space owner to add one."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{canCreate && (
|
||||||
onClick={openNewDialog}
|
<Button
|
||||||
size="lg"
|
onClick={openNewDialog}
|
||||||
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
size="lg"
|
||||||
>
|
className="gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
>
|
||||||
Create First Configuration
|
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
</Button>
|
Create First Configuration
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -325,38 +378,44 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(canUpdate || canDelete) && (
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||||
<TooltipProvider>
|
{canUpdate && (
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => openEditDialog(config)}
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
onClick={() => openEditDialog(config)}
|
||||||
>
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
<Edit3 className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Edit3 className="h-3 w-3" />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent>Edit</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>Edit</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
<TooltipProvider>
|
</TooltipProvider>
|
||||||
<Tooltip>
|
)}
|
||||||
<TooltipTrigger asChild>
|
{canDelete && (
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => setConfigToDelete(config)}
|
<Button
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Trash2 className="h-3 w-3" />
|
onClick={() => setConfigToDelete(config)}
|
||||||
</Button>
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>Delete</TooltipContent>
|
<Trash2 className="h-3 w-3" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
</TooltipProvider>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider + Model */}
|
{/* Provider + Model */}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue