mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 10:26:33 +02:00
Merge upstream/dev - preserve local UI enhancements (Logs in menu, conditional search, hover edit buttons)
This commit is contained in:
commit
089beb8d8c
117 changed files with 12068 additions and 4857 deletions
|
|
@ -16,7 +16,6 @@ import {
|
|||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -197,11 +196,7 @@ export function DashboardClientLayout({
|
|||
return (
|
||||
<DocumentUploadDialogProvider>
|
||||
<OnboardingTour />
|
||||
<LayoutDataProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
breadcrumb={<DashboardBreadcrumb />}
|
||||
languageSwitcher={<LanguageSwitcher />}
|
||||
>
|
||||
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
|
||||
{children}
|
||||
</LayoutDataProvider>
|
||||
</DocumentUploadDialogProvider>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
// extractWriteTodosFromContent,
|
||||
hydratePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
|
|
@ -32,6 +33,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
|||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
|
|
@ -49,6 +51,8 @@ import {
|
|||
type MessageRecord,
|
||||
type ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||
import {
|
||||
trackChatCreated,
|
||||
trackChatError,
|
||||
|
|
@ -257,6 +261,44 @@ export default function NewChatPage() {
|
|||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Live collaboration: sync session state and messages via Electric SQL
|
||||
useChatSessionStateSync(threadId);
|
||||
const { data: membersData } = useAtomValue(membersAtom);
|
||||
|
||||
const handleElectricMessagesUpdate = useCallback(
|
||||
(electricMessages: { id: number; thread_id: number; role: string; content: unknown; author_id: string | null; created_at: string }[]) => {
|
||||
if (isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
if (electricMessages.length < prev.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return electricMessages.map((msg) => {
|
||||
const member = msg.author_id
|
||||
? membersData?.find((m) => m.user_id === msg.author_id)
|
||||
: null;
|
||||
|
||||
return convertToThreadMessage({
|
||||
id: msg.id,
|
||||
thread_id: msg.thread_id,
|
||||
role: msg.role.toLowerCase() as "user" | "assistant" | "system",
|
||||
content: msg.content,
|
||||
author_id: msg.author_id,
|
||||
created_at: msg.created_at,
|
||||
author_display_name: member?.user_display_name ?? null,
|
||||
author_avatar_url: member?.user_avatar_url ?? null,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
[isRunning, membersData]
|
||||
);
|
||||
|
||||
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||
|
||||
|
|
@ -367,7 +409,7 @@ export default function NewChatPage() {
|
|||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||
const searchParams = useSearchParams();
|
||||
const targetCommentId = searchParams.get("commentId");
|
||||
|
||||
|
|
@ -586,8 +628,6 @@ export default function NewChatPage() {
|
|||
content: persistContent,
|
||||
})
|
||||
.then(() => {
|
||||
// For new threads, the backend updates the title from the first user message
|
||||
// Invalidate threads query so sidebar shows the updated title in real-time
|
||||
if (isNewThread) {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||
}
|
||||
|
|
@ -1056,17 +1096,13 @@ export default function NewChatPage() {
|
|||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
<SaveMemoryToolUI />
|
||||
<RecallMemoryToolUI />
|
||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={
|
||||
<ChatHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
thread={currentThread}
|
||||
onThreadVisibilityChange={handleVisibilityChange}
|
||||
/>
|
||||
}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
|
|
|||
|
|
@ -3,29 +3,38 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Crown,
|
||||
Edit2,
|
||||
FileText,
|
||||
Hash,
|
||||
Link2,
|
||||
LinkIcon,
|
||||
Loader2,
|
||||
Logs,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plug,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -512,6 +521,25 @@ export default function TeamManagementPage() {
|
|||
|
||||
// ============ Members Tab ============
|
||||
|
||||
// Helper function to get avatar initials
|
||||
function getAvatarInitials(member: Membership): string {
|
||||
// Try display name first
|
||||
if (member.user_display_name) {
|
||||
const parts = member.user_display_name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return member.user_display_name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Try email
|
||||
if (member.user_email) {
|
||||
const emailName = member.user_email.split("@")[0];
|
||||
return emailName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
// Fallback
|
||||
return "U";
|
||||
}
|
||||
|
||||
function MembersTab({
|
||||
members,
|
||||
roles,
|
||||
|
|
@ -560,7 +588,7 @@ function MembersTab({
|
|||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search members..."
|
||||
placeholder="Search members"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
|
|
@ -573,10 +601,30 @@ function MembersTab({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">Member</TableHead>
|
||||
<TableHead className="px-2 md:px-4">Role</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Joined</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Member
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="px-2 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Role
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Joined
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Actions
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -601,19 +649,36 @@ function MembersTab({
|
|||
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
<div className="flex items-center gap-1.5 md:gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-2 ring-background">
|
||||
<User className="h-4 w-4 md:h-5 md:w-5 text-primary" />
|
||||
</div>
|
||||
{member.user_avatar_url ? (
|
||||
<Image
|
||||
src={member.user_avatar_url}
|
||||
alt={member.user_display_name || member.user_email || "User"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||
<span className="text-xs md:text-sm font-medium text-primary">
|
||||
{getAvatarInitials(member)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background">
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<Crown className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs md:text-sm truncate">
|
||||
{member.user_email || "Unknown"}
|
||||
{member.user_display_name || member.user_email || "Unknown"}
|
||||
</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
{member.is_owner && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -640,29 +705,21 @@ function MembersTab({
|
|||
<SelectItem value="none">No role</SelectItem>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-3 w-3" />
|
||||
{role.name}
|
||||
</div>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[10px] md:text-xs py-0 md:py-0.5"
|
||||
>
|
||||
<Shield className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
<Badge variant="secondary" className="text-[10px] md:text-xs py-0 md:py-0.5">
|
||||
{member.role?.name || "No role"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(member.joined_at).toLocaleDateString()}
|
||||
</div>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||
{canRemove && !member.is_owner && (
|
||||
|
|
@ -708,13 +765,137 @@ function MembersTab({
|
|||
);
|
||||
}
|
||||
|
||||
// ============ Role Permissions Display ============
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
|
||||
documents: { label: "Documents", icon: FileText, order: 1 },
|
||||
chats: { label: "Chats", icon: MessageSquare, order: 2 },
|
||||
comments: { label: "Comments", icon: MessageCircle, order: 3 },
|
||||
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
|
||||
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
|
||||
connectors: { label: "Connectors", icon: Plug, order: 6 },
|
||||
logs: { label: "Logs", icon: Logs, order: 7 },
|
||||
members: { label: "Members", icon: Users, order: 8 },
|
||||
roles: { label: "Roles", icon: Shield, order: 9 },
|
||||
settings: { label: "Settings", icon: Settings, order: 10 },
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "Create",
|
||||
read: "Read",
|
||||
update: "Update",
|
||||
delete: "Delete",
|
||||
invite: "Invite",
|
||||
view: "View",
|
||||
remove: "Remove",
|
||||
manage_roles: "Manage Roles",
|
||||
};
|
||||
|
||||
function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||
if (permissions.includes("*")) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shrink-0">
|
||||
<Crown className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">Full Access</p>
|
||||
<p className="text-xs text-muted-foreground">All permissions granted</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group permissions by category
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const perm of permissions) {
|
||||
const [category, action] = perm.split(":");
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(action);
|
||||
}
|
||||
|
||||
// Sort categories by predefined order
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const categoryCount = sortedCategories.length;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-border/50 bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{permissions.length} Permissions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">View details</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
|
||||
<DialogHeader className="p-4 md:p-5 border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
Role Permissions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{permissions.length} permissions across {categoryCount} categories
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="divide-y divide-border/50">
|
||||
{sortedCategories.map((category) => {
|
||||
const actions = grouped[category];
|
||||
const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] font-medium"
|
||||
>
|
||||
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Roles Tab ============
|
||||
|
||||
function RolesTab({
|
||||
roles,
|
||||
groupedPermissions,
|
||||
groupedPermissions: _groupedPermissions,
|
||||
loading,
|
||||
onUpdateRole,
|
||||
onUpdateRole: _onUpdateRole,
|
||||
onDeleteRole,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
|
|
@ -778,8 +959,7 @@ function RolesTab({
|
|||
role.name === "Owner" && "text-amber-600",
|
||||
role.name === "Editor" && "text-blue-600",
|
||||
role.name === "Viewer" && "text-gray-600",
|
||||
!["Owner", "Editor", "Viewer"].includes(role.name) &&
|
||||
"text-primary"
|
||||
!["Owner", "Editor", "Viewer"].includes(role.name) && "text-primary"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -853,32 +1033,7 @@ function RolesTab({
|
|||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.includes("*") ? (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-500"
|
||||
>
|
||||
Full Access
|
||||
</Badge>
|
||||
) : (
|
||||
role.permissions.slice(0, 5).map((perm) => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{perm.replace(":", " ")}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{!role.permissions.includes("*") && role.permissions.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{role.permissions.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RolePermissionsDisplay permissions={role.permissions} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
|
@ -1488,7 +1643,8 @@ function CreateRoleDialog({
|
|||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only) permissions
|
||||
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
|
||||
permissions
|
||||
</p>
|
||||
<ScrollArea className="h-64 rounded-lg border p-4">
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1500,8 +1656,10 @@ function CreateRoleDialog({
|
|||
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
|
|
@ -1510,19 +1668,21 @@ function CreateRoleDialog({
|
|||
<span className="text-sm font-medium capitalize">
|
||||
{category} ({categorySelected}/{perms.length})
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
||||
{perms.map((perm) => (
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
key={perm.value}
|
||||
className="flex items-center gap-2 cursor-pointer text-left"
|
||||
onClick={() => togglePermission(perm.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.value)}
|
||||
onCheckedChange={() => togglePermission(perm.value)}
|
||||
/>
|
||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue