feat: implement permission-based access controls for image and model configuration managers

This commit is contained in:
Anish Sarkar 2026-02-09 19:37:59 +05:30
parent adc4bc7075
commit 83033cebb9
2 changed files with 204 additions and 89 deletions

View file

@ -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>
{canCreate && (
<Button <Button
onClick={openNewDialog} onClick={openNewDialog}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9" className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
> >
Add Image Model Add Image Model
</Button> </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>
{canCreate && (
<Button onClick={openNewDialog} size="lg" className="gap-2 text-xs md:text-sm h-9 md:h-10"> <Button onClick={openNewDialog} 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" /> <Plus className="h-3 w-3 md:h-4 md:w-4" />
Add First Image Model Add First Image Model
</Button> </Button>
)}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -586,7 +636,9 @@ 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">
{canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -602,6 +654,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<TooltipContent>Edit</TooltipContent> <TooltipContent>Edit</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
{canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -617,7 +671,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<TooltipContent>Delete</TooltipContent> <TooltipContent>Delete</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
</div> </div>
)}
</div> </div>
{/* Provider + Model */} {/* Provider + Model */}

View file

@ -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,6 +206,7 @@ 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>
{canCreate && (
<Button <Button
onClick={openNewDialog} onClick={openNewDialog}
size="sm" size="sm"
@ -194,6 +214,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
> >
Add Configuration Add Configuration
</Button> </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,9 +328,12 @@ 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>
{canCreate && (
<Button <Button
onClick={openNewDialog} onClick={openNewDialog}
size="lg" size="lg"
@ -290,6 +342,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<Plus className="h-3 w-3 md:h-4 md:w-4" /> <Plus className="h-3 w-3 md:h-4 md:w-4" />
Create First Configuration Create First Configuration
</Button> </Button>
)}
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
@ -325,7 +378,9 @@ 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">
{canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -341,6 +396,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<TooltipContent>Edit</TooltipContent> <TooltipContent>Edit</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
{canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -356,7 +413,9 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<TooltipContent>Delete</TooltipContent> <TooltipContent>Delete</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
</div> </div>
)}
</div> </div>
{/* Provider + Model */} {/* Provider + Model */}