diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index ed4d6dea3..4349edc20 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -373,10 +373,11 @@ async def read_documents( # Convert database objects to API-friendly format api_documents = [] for doc in db_documents: - # Get user name (display_name or email fallback) created_by_name = None + created_by_email = None if doc.created_by: - created_by_name = doc.created_by.display_name or doc.created_by.email + created_by_name = doc.created_by.display_name + created_by_email = doc.created_by.email # Parse status from JSONB status_data = None @@ -400,6 +401,7 @@ async def read_documents( search_space_id=doc.search_space_id, created_by_id=doc.created_by_id, created_by_name=created_by_name, + created_by_email=created_by_email, status=status_data, ) ) @@ -528,10 +530,11 @@ async def search_documents( # Convert database objects to API-friendly format api_documents = [] for doc in db_documents: - # Get user name (display_name or email fallback) created_by_name = None + created_by_email = None if doc.created_by: - created_by_name = doc.created_by.display_name or doc.created_by.email + created_by_name = doc.created_by.display_name + created_by_email = doc.created_by.email # Parse status from JSONB status_data = None @@ -555,6 +558,7 @@ async def search_documents( search_space_id=doc.search_space_id, created_by_id=doc.created_by_id, created_by_name=created_by_name, + created_by_email=created_by_email, status=status_data, ) ) diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py index 2ca341921..be464f9ec 100644 --- a/surfsense_backend/app/schemas/documents.py +++ b/surfsense_backend/app/schemas/documents.py @@ -60,9 +60,8 @@ class DocumentRead(BaseModel): updated_at: datetime | None search_space_id: int created_by_id: UUID | None = None # User who created/uploaded this document - created_by_name: str | None = ( - None # Display name or email of the user who created this document - ) + created_by_name: str | None = None + created_by_email: str | None = None status: DocumentStatusSchema | None = ( None # Processing status (ready, processing, failed) ) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx index dfd2c6d85..c07f34935 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx @@ -67,7 +67,7 @@ export function DocumentTypeChip({ type, className }: { type: string; className? const chip = ( {icon} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index c954474c6..ff9e1f246 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -3,13 +3,13 @@ import { useSetAtom } from "jotai"; import { CircleAlert, - CircleX, - FilePlus2, FileType, ListFilter, Search, SlidersHorizontal, Trash, + Upload, + X, } from "lucide-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; @@ -81,7 +81,7 @@ export function DocumentsFilters({ return ( - + Upload documents - - - - - - Role Permissions - - - {permissions.length} permissions across {categoryCount} categories - - - -
- {sortedCategories.map((category) => { - const actions = grouped[category]; - const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText }; - const IconComponent = config.icon; - return ( -
-
- - {config.label} -
-
- {actions.map((action) => ( - - {ACTION_LABELS[action] || action.replace(/_/g, " ")} - - ))} -
-
- ); - })} -
-
-
- - ); -} - -// ============ Roles Tab ============ - -function RolesTab({ - roles, - groupedPermissions, - loading, - onUpdateRole, - onDeleteRole, - onCreateRole, - canUpdate, - canDelete, - canCreate, -}: { - roles: Role[]; - groupedPermissions: Record; - loading: boolean; - onUpdateRole: ( - roleId: number, - data: { - name?: string; - description?: string | null; - permissions?: string[]; - is_default?: boolean; - } - ) => Promise; - onDeleteRole: (roleId: number) => Promise; - onCreateRole: (data: CreateRoleRequest["data"]) => Promise; - canUpdate: boolean; - canDelete: boolean; - canCreate: boolean; -}) { - const [showCreateRole, setShowCreateRole] = useState(false); - const [editingRoleId, setEditingRoleId] = useState(null); - - if (loading) { - return ( -
- -
- ); - } - - return ( - - {/* Create Role Button / Section */} - {canCreate && !showCreateRole && ( - - - - )} - - {/* Create Role Form */} - {showCreateRole && ( - setShowCreateRole(false)} - /> - )} - - {/* Edit Role Form */} - {editingRoleId !== null && - (() => { - const roleToEdit = roles.find((r) => r.id === editingRoleId); - if (!roleToEdit) return null; - return ( - setEditingRoleId(null)} - /> - ); - })()} - - {/* Roles Grid */} -
- {roles.map((role, index) => ( - - - {role.is_system_role && ( -
- System Role -
- )} - -
-
-
- -
-
- {role.name} - {role.is_default && ( - - Default - - )} -
-
- {!role.is_system_role && ( - - - - - e.preventDefault()}> - {canUpdate && ( - setEditingRoleId(role.id)}> - - Edit Role - - )} - {canDelete && ( - <> - - - - e.preventDefault()} - > - - Delete Role - - - - - Delete role? - - This will permanently delete the "{role.name}" role. Members - with this role will lose their permissions. - - - - Cancel - onDeleteRole(role.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - - - )} - - - )} -
- {role.description && ( - {role.description} - )} -
- - - -
-
- ))} -
-
- ); -} - -// ============ Invites Tab ============ - -function InvitesTab({ - invites, - loading, - onRevokeInvite, - canRevoke, -}: { - invites: Invite[]; - loading: boolean; - onRevokeInvite: (inviteId: number) => Promise; - canRevoke: boolean; -}) { - const [copiedId, setCopiedId] = useState(null); - - const copyInviteLink = useCallback((invite: Invite) => { - const link = `${window.location.origin}/invite/${invite.invite_code}`; - navigator.clipboard.writeText(link); - setCopiedId(invite.id); - toast.success("Invite link copied to clipboard"); - setTimeout(() => setCopiedId(null), 2000); - }, []); - - if (loading) { - return ( -
- -
- ); - } - - if (invites.length === 0) { + if (accessLoading || membersLoading) { return ( -
- +
+
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+
+
+ + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + +
+ +
+ + +
+
+
+ + + + +
+ +
+
+
+ ))} +
+
+
+
-

No invite links

-

- Create an invite link to allow others to join your search space with specific roles. -

); } return ( - {invites.map((invite, index) => { - const isExpired = invite.expires_at && new Date(invite.expires_at) < new Date(); - const isMaxedOut = invite.max_uses && invite.uses_count >= invite.max_uses; - const isInactive = !invite.is_active || isExpired || isMaxedOut; +
+
+ {/* Header row: Invite button on left, member count on right */} +
+
+ {canInvite && ( + + )} + {canInvite && activeInvites.length > 0 && ( + + )} +
+

+ {members.length} {members.length === 1 ? "member" : "members"} +

+
- return ( - - + + + + + + + Name + + + + + + Last logged in + + + + + + Role + + + + + + {owners.map((member, index) => ( + + ))} + {paginatedMembers.map((member, index) => ( + + ))} + {members.length === 0 && ( + + +
+ +

No members yet

+
+
+
+ )} +
+
+
+ + {/* Pagination */} + {totalItems > PAGE_SIZE && ( + - -
-
-
- -
-
-
-

{invite.name || "Unnamed Invite"}

- {isExpired && ( - - Expired - - )} - {isMaxedOut && ( - - Maxed - - )} - {!invite.is_active && !isExpired && !isMaxedOut && ( - - Inactive - - )} -
-
- - - {invite.role?.name || "Default role"} - - - - {invite.uses_count} - {invite.max_uses ? ` / ${invite.max_uses} uses` : " uses"} - - {invite.expires_at && ( - - - {isExpired - ? "Expired" - : `Exp: ${new Date(invite.expires_at).toLocaleDateString()}`} - - )} -
-
-
-
- - {canRevoke && ( - - - - - - - Revoke invite? - - This will permanently delete this invite link. Anyone with this link - will no longer be able to join. - - - - Cancel - onRevokeInvite(invite.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Revoke - - - - - )} -
-
-
- -
- ); - })} + + {displayStart}-{displayEnd} of {totalItems} + +
+ + + + +
+ + )} +
+
); } +// ============ Member Row ============ + +function MemberRow({ + member, + roles, + canManageRoles, + canRemove, + onUpdateRole, + onRemoveMember, + searchSpaceId, + index, +}: { + member: Membership; + roles: Role[]; + canManageRoles: boolean; + canRemove: boolean; + onUpdateRole: (membershipId: number, roleId: number | null) => Promise; + onRemoveMember: (membershipId: number) => Promise; + searchSpaceId: number; + index: number; +}) { + const router = useRouter(); + const initials = getAvatarInitials(member); + const avatarColor = getAvatarColor(member.user_id); + const displayName = member.user_display_name || member.user_email || "Unknown"; + const roleName = member.is_owner ? "Owner" : member.role?.name || "No role"; + const showActions = !member.is_owner && (canManageRoles || canRemove); + + return ( + + +
+
+ {member.user_avatar_url ? ( + {displayName} + ) : ( +
+ {initials} +
+ )} +
+
+

{displayName}

+ {member.user_display_name && member.user_email && ( +

+ {member.user_email} +

+ )} +
+
+
+ + + {formatRelativeDate(member.joined_at)} + + + + {showActions ? ( + + + + + e.preventDefault()} + className="min-w-[120px] bg-muted dark:border dark:border-neutral-700" + > + {canManageRoles && + roles + .filter((r) => r.name !== "Owner") + .map((role) => ( + onUpdateRole(member.id, role.id)} + > + Make {role.name} + + ))} + {canRemove && ( + + + e.preventDefault()} + > + Remove + + + + + Remove member? + + This will remove {member.user_email}{" "} + from this search space. They will lose access to all resources. + + + + Cancel + onRemoveMember(member.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )} + + + router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) + } + > + Manage Roles + + + + ) : ( + {roleName} + )} + +
+ ); +} + // ============ Create Invite Dialog ============ function CreateInviteDialog({ roles, onCreateInvite, searchSpaceId, - className, }: { roles: Role[]; onCreateInvite: (data: CreateInviteRequest["data"]) => Promise; searchSpaceId: number; - className?: string; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); @@ -1369,23 +645,28 @@ function CreateInviteDialog({ const [createdInvite, setCreatedInvite] = useState(null); const [copiedLink, setCopiedLink] = useState(false); + const assignableRoles = useMemo(() => roles.filter((r) => r.name !== "Owner"), [roles]); + const defaultRole = useMemo(() => assignableRoles.find((r) => r.is_default), [assignableRoles]); + + useEffect(() => { + if (defaultRole && !roleId) { + setRoleId(defaultRole.id.toString()); + } + }, [defaultRole, roleId]); + const handleCreate = async () => { setCreating(true); try { const data: CreateInviteRequest["data"] = {}; if (name) data.name = name; - if (roleId && roleId !== "default") data.role_id = Number(roleId); + if (roleId) data.role_id = Number(roleId); if (maxUses) data.max_uses = Number(maxUses); if (expiresAt) data.expires_at = expiresAt.toISOString(); const invite = await onCreateInvite(data); setCreatedInvite(invite); - // Track invite sent event - const roleName = - roleId && roleId !== "default" - ? roles.find((r) => r.id.toString() === roleId)?.name - : undefined; + const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; trackSearchSpaceInviteSent(searchSpaceId, { roleName, hasExpiry: !!expiresAt, @@ -1401,7 +682,7 @@ function CreateInviteDialog({ const handleClose = () => { setOpen(false); setName(""); - setRoleId(""); + setRoleId(defaultRole?.id.toString() ?? ""); setMaxUses(""); setExpiresAt(undefined); setCreatedInvite(null); @@ -1419,12 +700,18 @@ function CreateInviteDialog({ return ( (v ? setOpen(true) : handleClose())}> - - + e.preventDefault()} + > {createdInvite ? ( <> @@ -1442,16 +729,11 @@ function CreateInviteDialog({ {window.location.origin}/invite/{createdInvite.invite_code}
- {createdInvite.role?.name || "Default role"} {createdInvite.max_uses && ( @@ -1475,7 +757,7 @@ function CreateInviteDialog({ ) : ( <> - Create Invite Link + Invite Members Create a link to invite people to this search space. @@ -1494,20 +776,19 @@ function CreateInviteDialog({
@@ -1573,859 +854,123 @@ function CreateInviteDialog({ ); } -// ============ Create Role Section ============ +// ============ All Invites Dialog ============ -// Preset permission sets for quick role creation -const ROLE_PRESETS = { - editor: { - name: "Editor", - description: "Can create, read, and update content, but cannot delete or manage team settings", - permissions: [ - "documents:create", - "documents:read", - "documents:update", - "chats:create", - "chats:read", - "chats:update", - "comments:create", - "comments:read", - "llm_configs:create", - "llm_configs:read", - "llm_configs:update", - "podcasts:create", - "podcasts:read", - "podcasts:update", - "connectors:create", - "connectors:read", - "connectors:update", - "logs:read", - "members:invite", - "members:view", - "roles:read", - "settings:view", - ], - }, - viewer: { - name: "Viewer", - description: "Read-only access with ability to add comments", - permissions: [ - "documents:read", - "chats:read", - "comments:create", - "comments:read", - "llm_configs:read", - "podcasts:read", - "connectors:read", - "logs:read", - "members:view", - "roles:read", - "settings:view", - ], - }, - contributor: { - name: "Contributor", - description: "Can add and manage their own content", - permissions: [ - "documents:create", - "documents:read", - "documents:update", - "chats:create", - "chats:read", - "comments:create", - "comments:read", - "llm_configs:read", - "podcasts:read", - "connectors:read", - "logs:read", - "members:view", - "roles:read", - "settings:view", - ], - }, -}; - -// Action display labels -const ACTION_DISPLAY: Record = { - create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" }, - read: { label: "View", color: "text-blue-600 bg-blue-500/10" }, - update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" }, - delete: { label: "Delete", color: "text-red-600 bg-red-500/10" }, - invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" }, - view: { label: "View", color: "text-blue-600 bg-blue-500/10" }, - remove: { label: "Remove", color: "text-red-600 bg-red-500/10" }, - manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" }, -}; - -// Use the imported PermissionInfo type which now includes description -type PermissionWithDescription = PermissionInfo; - -function CreateRoleSection({ - groupedPermissions, - onCreateRole, - onCancel, +function AllInvitesDialog({ + invites, + onRevokeInvite, }: { - groupedPermissions: Record; - onCreateRole: (data: CreateRoleRequest["data"]) => Promise; - onCancel: () => void; + invites: Invite[]; + onRevokeInvite: (inviteId: number) => Promise; }) { - const [creating, setCreating] = useState(false); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [selectedPermissions, setSelectedPermissions] = useState([]); - const [isDefault, setIsDefault] = useState(false); - const [expandedCategories, setExpandedCategories] = useState([]); + const [copiedId, setCopiedId] = useState(null); - // Sort categories by order - const sortedCategories = useMemo(() => { - return Object.keys(groupedPermissions).sort((a, b) => { - const orderA = CATEGORY_CONFIG[a]?.order ?? 99; - const orderB = CATEGORY_CONFIG[b]?.order ?? 99; - return orderA - orderB; - }); - }, [groupedPermissions]); - - const handleCreate = async () => { - if (!name.trim()) { - toast.error("Please enter a role name"); - return; - } - - setCreating(true); - try { - await onCreateRole({ - name: name.trim(), - description: description.trim() || null, - permissions: selectedPermissions, - is_default: isDefault, - }); - onCancel(); - } catch (error) { - console.error("Failed to create role:", error); - } finally { - setCreating(false); - } + const copyLink = (invite: Invite) => { + const link = `${window.location.origin}/invite/${invite.invite_code}`; + navigator.clipboard.writeText(link); + setCopiedId(invite.id); + toast.success("Invite link copied"); + setTimeout(() => setCopiedId(null), 2000); }; - const togglePermission = useCallback((perm: string) => { - setSelectedPermissions((prev) => - prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] - ); - }, []); - - const toggleCategory = useCallback( - (category: string) => { - const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; - const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); - - if (allSelected) { - setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); - } else { - setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); - } - }, - [groupedPermissions, selectedPermissions] - ); - - const toggleCategoryExpanded = useCallback((category: string) => { - setExpandedCategories((prev) => - prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] - ); - }, []); - - const applyPreset = useCallback( - (presetKey: keyof typeof ROLE_PRESETS) => { - const preset = ROLE_PRESETS[presetKey]; - setSelectedPermissions(preset.permissions); - if (!name.trim()) { - setName(preset.name); - setDescription(preset.description); - } - toast.success(`Applied ${preset.name} preset`); - }, - [name] - ); - - const getCategoryStats = useCallback( - (category: string) => { - const perms = groupedPermissions[category] || []; - const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length; - return { selected, total: perms.length, allSelected: selected === perms.length }; - }, - [groupedPermissions, selectedPermissions] - ); - return ( - - - -
-
-
- + + + + + + + Active Invite Links + + {invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or + revoke access. + + +
+ {invites.map((invite) => ( +
+
+
+

{invite.name || "Unnamed invite"}

+
+ {invite.role?.name && ( + {invite.role.name} + )} + {invite.max_uses != null && ( + + + {invite.uses_count}/{invite.max_uses} + + )} + {invite.expires_at && ( + + + {new Date(invite.expires_at).toLocaleDateString()} + + )} +
+
+ + + + + + + Revoke invite? + + This will permanently delete this invite link. Anyone with this link will no + longer be able to join. + + + + Cancel + onRevokeInvite(invite.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke + + + +
-
- Create Custom Role - - Define permissions for a new role in this search space - -
-
- -
- - - {/* Quick Start with Presets */} -
- -
- {Object.entries(ROLE_PRESETS).map(([key, preset]) => ( - - ))} -
-
- - {/* Role Details */} -
-
- - setName(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - {/* Default Role Checkbox */} -
- setIsDefault(checked === true)} - /> -
- -

- New members without a specific role will be assigned this role -

-
-
- - {/* Permissions Section */} -
-
- - -
- -
- {sortedCategories.map((category) => { - const config = CATEGORY_CONFIG[category] || { - label: category, - icon: FileText, - description: "", - order: 99, - }; - const IconComponent = config.icon; - const stats = getCategoryStats(category); - const isExpanded = expandedCategories.includes(category); - const perms = groupedPermissions[category] || []; - - return ( -
- {/* Category Header */} -
toggleCategoryExpanded(category)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleCategoryExpanded(category); - } - }} - tabIndex={0} - role="button" - > -
-
0 ? "bg-primary/10" : "bg-muted" - )} - > - 0 ? "text-primary" : "text-muted-foreground" - )} - /> -
-
-
- {config.label} - 0 ? "default" : "secondary"} - className="text-xs h-5" - > - {stats.selected}/{stats.total} - -
-

- {config.description} -

-
-
-
- toggleCategory(category)} - onClick={(e) => e.stopPropagation()} - aria-label={`Select all ${config.label} permissions`} - /> - - - - - -
-
- - {/* Permissions List */} - {isExpanded && ( - -
- {perms.map((perm) => { - const action = perm.value.split(":")[1]; - const actionConfig = ACTION_DISPLAY[action] || { - label: action, - color: "text-gray-600 bg-gray-500/10", - }; - const isSelected = selectedPermissions.includes(perm.value); - - return ( -
togglePermission(perm.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - togglePermission(perm.value); - } - }} - tabIndex={0} - role="checkbox" - aria-checked={isSelected} - > -
- togglePermission(perm.value)} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {actionConfig.label} - -
-

- {perm.description} -

-
-
-
- ); - })} -
-
- )} -
- ); - })} -
-
- - {/* Actions */} -
- - -
-
- - - ); -} - -function EditRoleSection({ - role, - groupedPermissions, - onUpdateRole, - onCancel, -}: { - role: Role; - groupedPermissions: Record; - onUpdateRole: ( - roleId: number, - data: { - name?: string; - description?: string | null; - permissions?: string[]; - is_default?: boolean; - } - ) => Promise; - onCancel: () => void; -}) { - const [saving, setSaving] = useState(false); - const [name, setName] = useState(role.name); - const [description, setDescription] = useState(role.description || ""); - const [selectedPermissions, setSelectedPermissions] = useState(role.permissions); - const [isDefault, setIsDefault] = useState(role.is_default); - const [expandedCategories, setExpandedCategories] = useState([]); - - // Sort categories by order - const sortedCategories = useMemo(() => { - return Object.keys(groupedPermissions).sort((a, b) => { - const orderA = CATEGORY_CONFIG[a]?.order ?? 99; - const orderB = CATEGORY_CONFIG[b]?.order ?? 99; - return orderA - orderB; - }); - }, [groupedPermissions]); - - const handleSave = async () => { - if (!name.trim()) { - toast.error("Please enter a role name"); - return; - } - - setSaving(true); - try { - await onUpdateRole(role.id, { - name: name.trim(), - description: description.trim() || null, - permissions: selectedPermissions, - is_default: isDefault, - }); - toast.success("Role updated successfully"); - onCancel(); - } catch (error) { - console.error("Failed to update role:", error); - toast.error("Failed to update role"); - } finally { - setSaving(false); - } - }; - - const togglePermission = useCallback((perm: string) => { - setSelectedPermissions((prev) => - prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] - ); - }, []); - - const toggleCategory = useCallback( - (category: string) => { - const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; - const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); - - if (allSelected) { - setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); - } else { - setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); - } - }, - [groupedPermissions, selectedPermissions] - ); - - const toggleCategoryExpanded = useCallback((category: string) => { - setExpandedCategories((prev) => - prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] - ); - }, []); - - const getCategoryStats = useCallback( - (category: string) => { - const perms = groupedPermissions[category] || []; - const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length; - return { selected, total: perms.length, allSelected: selected === perms.length }; - }, - [groupedPermissions, selectedPermissions] - ); - - return ( - - - -
-
-
- -
-
- Edit Role - - Modify permissions for "{role.name}" - + {copiedId === invite.id ? ( + + ) : ( + + )} +
- -
-
- - {/* Role Details */} -
-
- - setName(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
- - {/* Default Role Checkbox */} -
- setIsDefault(checked === true)} - /> -
- -

- New members without a specific role will be assigned this role -

-
-
- - {/* Permissions Section */} -
-
- - -
- -
- {sortedCategories.map((category) => { - const config = CATEGORY_CONFIG[category] || { - label: category, - icon: FileText, - description: "", - order: 99, - }; - const IconComponent = config.icon; - const stats = getCategoryStats(category); - const isExpanded = expandedCategories.includes(category); - const perms = groupedPermissions[category] || []; - - return ( -
- {/* Category Header */} -
toggleCategoryExpanded(category)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleCategoryExpanded(category); - } - }} - tabIndex={0} - role="button" - > -
-
0 ? "bg-primary/10" : "bg-muted" - )} - > - 0 ? "text-primary" : "text-muted-foreground" - )} - /> -
-
-
- {config.label} - 0 ? "default" : "secondary"} - className="text-xs h-5" - > - {stats.selected}/{stats.total} - -
-

- {config.description} -

-
-
-
- toggleCategory(category)} - onClick={(e) => e.stopPropagation()} - aria-label={`Select all ${config.label} permissions`} - /> - - - - - -
-
- - {/* Permissions List */} - {isExpanded && ( - -
- {perms.map((perm) => { - const action = perm.value.split(":")[1]; - const actionConfig = ACTION_DISPLAY[action] || { - label: action, - color: "text-gray-600 bg-gray-500/10", - }; - const isSelected = selectedPermissions.includes(perm.value); - - return ( -
togglePermission(perm.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - togglePermission(perm.value); - } - }} - tabIndex={0} - role="checkbox" - aria-checked={isSelected} - > -
- togglePermission(perm.value)} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {actionConfig.label} - -
-

- {perm.description} -

-
-
-
- ); - })} -
-
- )} -
- ); - })} -
-
- - {/* Actions */} -
- - -
-
-
-
+ ))} +
+ +
); } diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 143508977..bac5c44a3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -221,7 +221,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger )} - + Manage Connectors {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( @@ -374,7 +374,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}

diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 2d5a3bba5..107e15d2b 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -210,6 +210,26 @@ export function LayoutShell({ onCloseMobileSidebar={() => setMobileMenuOpen(false)} /> )} + + {/* Mobile All Shared Chats - slide-out panel */} + {allSharedChatsPanel && ( + setMobileMenuOpen(false)} + /> + )} + + {/* Mobile All Private Chats - slide-out panel */} + {allPrivateChatsPanel && ( + setMobileMenuOpen(false)} + /> + )} diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 3dd77ea1d..36a20cf3f 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { ArchiveIcon, + ChevronLeft, MessageCircleMore, MoreHorizontal, + PenLine, RotateCcwIcon, Search, Trash2, @@ -17,6 +19,14 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -69,6 +79,10 @@ export function AllPrivateChatsSidebar({ const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null); + const [newTitle, setNewTitle] = useState(""); + const [isRenaming, setIsRenaming] = useState(false); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const isSearchMode = !!debouncedSearchQuery.trim(); @@ -187,6 +201,35 @@ export function AllPrivateChatsSidebar({ [queryClient, searchSpaceId, t] ); + const handleStartRename = useCallback((threadId: number, title: string) => { + setRenamingThread({ id: threadId, title }); + setNewTitle(title); + setShowRenameDialog(true); + }, []); + + const handleConfirmRename = useCallback(async () => { + if (!renamingThread || !newTitle.trim()) return; + setIsRenaming(true); + try { + await updateThread(renamingThread.id, { title: newTitle.trim() }); + toast.success(t("chat_renamed") || "Chat renamed"); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + queryClient.invalidateQueries({ + queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)], + }); + } catch (error) { + console.error("Error renaming thread:", error); + toast.error(t("error_renaming_chat") || "Failed to rename chat"); + } finally { + setIsRenaming(false); + setShowRenameDialog(false); + setRenamingThread(null); + setNewTitle(""); + } + }, [renamingThread, newTitle, queryClient, searchSpaceId, t]); + const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); @@ -205,6 +248,17 @@ export function AllPrivateChatsSidebar({ >
+ {isMobile && ( + + )}

{t("chats") || "Private Chats"}

@@ -356,6 +410,14 @@ export function AllPrivateChatsSidebar({ + {!thread.archived && ( + handleStartRename(thread.id, thread.title || "New Chat")} + > + + {t("rename") || "Rename"} + + )} handleToggleArchive(thread.id, thread.archived)} disabled={isArchiving} @@ -412,6 +474,51 @@ export function AllPrivateChatsSidebar({
)} + + + + + {t("rename_chat") || "Rename Chat"} + + + {t("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewTitle(e.target.value)} + placeholder={t("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenaming && newTitle.trim()) { + handleConfirmRename(); + } + }} + /> + + + + + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index fae9fb05e..108ba4cde 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { ArchiveIcon, + ChevronLeft, MessageCircleMore, MoreHorizontal, + PenLine, RotateCcwIcon, Search, Trash2, @@ -17,6 +19,14 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -69,6 +79,10 @@ export function AllSharedChatsSidebar({ const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null); + const [newTitle, setNewTitle] = useState(""); + const [isRenaming, setIsRenaming] = useState(false); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const isSearchMode = !!debouncedSearchQuery.trim(); @@ -187,6 +201,35 @@ export function AllSharedChatsSidebar({ [queryClient, searchSpaceId, t] ); + const handleStartRename = useCallback((threadId: number, title: string) => { + setRenamingThread({ id: threadId, title }); + setNewTitle(title); + setShowRenameDialog(true); + }, []); + + const handleConfirmRename = useCallback(async () => { + if (!renamingThread || !newTitle.trim()) return; + setIsRenaming(true); + try { + await updateThread(renamingThread.id, { title: newTitle.trim() }); + toast.success(t("chat_renamed") || "Chat renamed"); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + queryClient.invalidateQueries({ + queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)], + }); + } catch (error) { + console.error("Error renaming thread:", error); + toast.error(t("error_renaming_chat") || "Failed to rename chat"); + } finally { + setIsRenaming(false); + setShowRenameDialog(false); + setRenamingThread(null); + setNewTitle(""); + } + }, [renamingThread, newTitle, queryClient, searchSpaceId, t]); + const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); @@ -205,6 +248,17 @@ export function AllSharedChatsSidebar({ >
+ {isMobile && ( + + )}

{t("shared_chats") || "Shared Chats"}

@@ -356,6 +410,14 @@ export function AllSharedChatsSidebar({ + {!thread.archived && ( + handleStartRename(thread.id, thread.title || "New Chat")} + > + + {t("rename") || "Rename"} + + )} handleToggleArchive(thread.id, thread.archived)} disabled={isArchiving} @@ -412,6 +474,51 @@ export function AllSharedChatsSidebar({
)} + + + + + {t("rename_chat") || "Rename Chat"} + + + {t("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewTitle(e.target.value)} + placeholder={t("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenaming && newTitle.trim()) { + handleConfirmRename(); + } + }} + /> + + + + + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 157a2ae04..c41964142 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -4,7 +4,7 @@ import { ArchiveIcon, MessageSquare, MoreHorizontal, - PencilIcon, + PenLine, RotateCcwIcon, Trash2, } from "lucide-react"; @@ -74,7 +74,7 @@ export function ChatListItem({ onRename(); }} > - + {t("rename") || "Rename"} )} diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index ce64adae9..9fc0a121a 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -727,7 +727,7 @@ export function InboxSidebar({ {t("filter") || "Filter"} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 567236498..f5909ef85 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -131,7 +131,7 @@ export function MobileSidebar({ {/* Sidebar Content - right side */} -
+
- + - + {t("manage_members")} - + {t("search_space_settings")} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx index beb1c81c0..805911f88 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({ exit={{ x: "-100%" }} transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }} className={cn( - "h-full w-full bg-background flex flex-col pointer-events-auto", + "h-full w-full bg-background flex flex-col pointer-events-auto select-none", "sm:border-r sm:shadow-xl" )} role="dialog" diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 3ba7bb2a0..1a0fac9e9 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -177,7 +177,7 @@ export function SidebarUserProfile({ {displayName} - +
@@ -191,14 +191,14 @@ export function SidebarUserProfile({ - + {t("user_settings")} {setTheme && ( - + {t("theme")} @@ -216,7 +216,7 @@ export function SidebarUserProfile({ isSelected && "text-primary" )} > - + {t(themeOption.value)} {isSelected && } @@ -229,7 +229,7 @@ export function SidebarUserProfile({ - + {t("language")} @@ -262,7 +262,7 @@ export function SidebarUserProfile({ {isLoggingOut ? ( ) : ( - + )} {isLoggingOut ? t("loggingOut") : t("logout")} @@ -299,7 +299,7 @@ export function SidebarUserProfile({ - +
@@ -313,14 +313,14 @@ export function SidebarUserProfile({ - + {t("user_settings")} {setTheme && ( - + {t("theme")} @@ -338,7 +338,7 @@ export function SidebarUserProfile({ isSelected && "text-primary" )} > - + {t(themeOption.value)} {isSelected && } @@ -351,7 +351,7 @@ export function SidebarUserProfile({ - + {t("language")} @@ -381,11 +381,7 @@ export function SidebarUserProfile({ - {isLoggingOut ? ( - - ) : ( - - )} + {isLoggingOut ? : } {isLoggingOut ? t("loggingOut") : t("logout")} diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 0cd23b705..bfb6d91fc 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -144,7 +144,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS + + + {versions.map((v, i) => ( + setActiveReportId(v.id)} + className={v.id === activeReportId ? "bg-accent font-medium" : ""} > - v{activeVersionIndex + 1} - - - - - {versions.map((v, i) => ( - setActiveReportId(v.id)} - className={v.id === activeReportId ? "bg-accent font-medium" : ""} - > - Version {i + 1} - - ))} - - - ) : ( - /* Desktop: inline version buttons */ -
-
- {versions.map((v, i) => ( - - ))} -
- - {activeVersionIndex + 1} of {versions.length} - -
- ))} + Version {i + 1} +
+ ))} +
+ + )}
{onClose && ( +
+ )} + + + + {editingRole && ( + { + if (!open) setEditingRoleId(null); + }} + role={editingRole} + groupedPermissions={groupedPermissions} + onUpdateRole={onUpdateRole} + /> + )} + +
+ {roles.map((role, index) => ( + + + + + e.preventDefault()}> + {canUpdate && ( + setEditingRoleId(role.id)}> + + Edit Role + + )} + {canDelete && ( + <> + + + + e.preventDefault()} + > + + Delete Role + + + + + Delete role? + + This will permanently delete the "{role.name}" role. + Members with this role will lose their permissions. + + + + Cancel + onDeleteRole(role.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + )} + + +
+ )} + + + + ))} +
+ + ); +} + +// ============ Permissions Editor (shared by Create and Edit) ============ + +function PermissionsEditor({ + groupedPermissions, + selectedPermissions, + onTogglePermission, + onToggleCategory, +}: { + groupedPermissions: Record; + selectedPermissions: string[]; + onTogglePermission: (perm: string) => void; + onToggleCategory: (category: string) => void; +}) { + const [expandedCategories, setExpandedCategories] = useState([]); + + const sortedCategories = useMemo(() => { + return Object.keys(groupedPermissions).sort((a, b) => { + const orderA = CATEGORY_CONFIG[a]?.order ?? 99; + const orderB = CATEGORY_CONFIG[b]?.order ?? 99; + return orderA - orderB; + }); + }, [groupedPermissions]); + + const toggleCategoryExpanded = useCallback((category: string) => { + setExpandedCategories((prev) => + prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] + ); + }, []); + + const getCategoryStats = useCallback( + (category: string) => { + const perms = groupedPermissions[category] || []; + const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length; + return { + selected, + total: perms.length, + allSelected: selected === perms.length, + }; + }, + [groupedPermissions, selectedPermissions] + ); + + return ( +
+
+ + +
+ +
+ {sortedCategories.map((category) => { + const config = CATEGORY_CONFIG[category] || { + label: category, + icon: FileText, + description: "", + order: 99, + }; + const IconComponent = config.icon; + const stats = getCategoryStats(category); + const isExpanded = expandedCategories.includes(category); + const perms = groupedPermissions[category] || []; + + return ( +
+ + + {isExpanded && ( + +
+ {perms.map((perm) => { + const action = perm.value.split(":")[1]; + const actionLabel = ACTION_LABELS[action] || action.replace(/_/g, " "); + const isSelected = selectedPermissions.includes(perm.value); + + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} + +// ============ Create Role Dialog ============ + +function CreateRoleDialog({ + open, + onOpenChange, + groupedPermissions, + onCreateRole, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + groupedPermissions: Record; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; +}) { + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedPermissions, setSelectedPermissions] = useState([]); + const [isDefault, setIsDefault] = useState(false); + + const handleClose = () => { + onOpenChange(false); + setName(""); + setDescription(""); + setSelectedPermissions([]); + setIsDefault(false); + }; + + const handleCreate = async () => { + if (!name.trim()) { + toast.error("Please enter a role name"); + return; + } + + setCreating(true); + try { + await onCreateRole({ + name: name.trim(), + description: description.trim() || null, + permissions: selectedPermissions, + is_default: isDefault, + }); + handleClose(); + } catch (error) { + console.error("Failed to create role:", error); + } finally { + setCreating(false); + } + }; + + const togglePermission = useCallback((perm: string) => { + setSelectedPermissions((prev) => + prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] + ); + }, []); + + const toggleCategory = useCallback( + (category: string) => { + const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; + const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); + + if (allSelected) { + setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); + } else { + setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); + } + }, + [groupedPermissions, selectedPermissions] + ); + + const applyPreset = useCallback( + (presetKey: keyof typeof ROLE_PRESETS) => { + const preset = ROLE_PRESETS[presetKey]; + setSelectedPermissions(preset.permissions); + if (!name.trim()) { + setName(preset.name); + setDescription(preset.description); + } + toast.success(`Applied ${preset.name} preset`); + }, + [name] + ); + + return ( + (v ? onOpenChange(true) : handleClose())}> + + + Create Custom Role + + Define permissions for a new role in this search space + + +
+
+
+ +
+ {Object.entries(ROLE_PRESETS).map(([key, preset]) => ( + + ))} +
+
+ +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ +
+ setIsDefault(checked === true)} + /> +
+ +

+ New members without a specific role will be assigned this role +

+
+
+ + +
+
+
+ + +
+
+
+ ); +} + +// ============ Edit Role Dialog ============ + +function EditRoleDialog({ + open, + onOpenChange, + role, + groupedPermissions, + onUpdateRole, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + role: Role; + groupedPermissions: Record; + onUpdateRole: ( + roleId: number, + data: { + name?: string; + description?: string | null; + permissions?: string[]; + is_default?: boolean; + } + ) => Promise; +}) { + const [saving, setSaving] = useState(false); + const [name, setName] = useState(role.name); + const [description, setDescription] = useState(role.description || ""); + const [selectedPermissions, setSelectedPermissions] = useState(role.permissions); + const [isDefault, setIsDefault] = useState(role.is_default); + + useEffect(() => { + if (open) { + setName(role.name); + setDescription(role.description || ""); + setSelectedPermissions(role.permissions); + setIsDefault(role.is_default); + } + }, [open, role]); + + const handleSave = async () => { + if (!name.trim()) { + toast.error("Please enter a role name"); + return; + } + + setSaving(true); + try { + await onUpdateRole(role.id, { + name: name.trim(), + description: description.trim() || null, + permissions: selectedPermissions, + is_default: isDefault, + }); + toast.success("Role updated successfully"); + onOpenChange(false); + } catch (error) { + console.error("Failed to update role:", error); + toast.error("Failed to update role"); + } finally { + setSaving(false); + } + }; + + const togglePermission = useCallback((perm: string) => { + setSelectedPermissions((prev) => + prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] + ); + }, []); + + const toggleCategory = useCallback( + (category: string) => { + const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; + const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); + + if (allSelected) { + setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p))); + } else { + setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); + } + }, + [groupedPermissions, selectedPermissions] + ); + + return ( + + + + Edit Role + + Modify permissions for "{role.name}" + + +
+
+
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ +
+ setIsDefault(checked === true)} + /> +
+ +

+ New members without a specific role will be assigned this role +

+
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index 1018cbdf6..94ba187d6 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtom } from "jotai"; -import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react"; +import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -452,7 +452,6 @@ export function DocumentUploadTab({
-
{t("supported_file_types")} diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index f96ecf85e..6ec577f51 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -44,6 +44,7 @@ export const document = z.object({ search_space_id: z.number(), created_by_id: z.string().nullable().optional(), created_by_name: z.string().nullable().optional(), + created_by_email: z.string().nullable().optional(), }); export const extensionDocumentContent = z.object({ diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts index e3a315cd1..55d48c4f1 100644 --- a/surfsense_web/hooks/use-documents.ts +++ b/surfsense_web/hooks/use-documents.ts @@ -26,7 +26,7 @@ interface DocumentElectric { status: DocumentStatusType | null; } -// Document for display (with resolved user name) +// Document for display (with resolved user name and email) export interface DocumentDisplay { id: number; search_space_id: number; @@ -34,6 +34,7 @@ export interface DocumentDisplay { title: string; created_by_id: string | null; created_by_name: string | null; + created_by_email: string | null; created_at: string; status: DocumentStatusType; } @@ -94,8 +95,9 @@ export function useDocuments( // Track if initial API load is complete (source of truth) const apiLoadedRef = useRef(false); - // User cache: userId → displayName + // User cache: userId → displayName / email const userCacheRef = useRef>(new Map()); + const emailCacheRef = useRef>(new Map()); // Electric sync refs const syncHandleRef = useRef(null); @@ -119,10 +121,21 @@ export function useDocuments( // Populate user cache from API response const populateUserCache = useCallback( - (items: Array<{ created_by_id?: string | null; created_by_name?: string | null }>) => { + ( + items: Array<{ + created_by_id?: string | null; + created_by_name?: string | null; + created_by_email?: string | null; + }> + ) => { for (const item of items) { - if (item.created_by_id && item.created_by_name) { - userCacheRef.current.set(item.created_by_id, item.created_by_name); + if (item.created_by_id) { + if (item.created_by_name) { + userCacheRef.current.set(item.created_by_id, item.created_by_name); + } + if (item.created_by_email) { + emailCacheRef.current.set(item.created_by_id, item.created_by_email); + } } } }, @@ -138,6 +151,7 @@ export function useDocuments( title: string; created_by_id?: string | null; created_by_name?: string | null; + created_by_email?: string | null; created_at: string; status?: DocumentStatusType | null; }): DocumentDisplay => ({ @@ -147,6 +161,7 @@ export function useDocuments( title: item.title, created_by_id: item.created_by_id ?? null, created_by_name: item.created_by_name ?? null, + created_by_email: item.created_by_email ?? null, created_at: item.created_at, status: item.status ?? { state: "ready" }, }), @@ -160,6 +175,9 @@ export function useDocuments( created_by_name: doc.created_by_id ? (userCacheRef.current.get(doc.created_by_id) ?? null) : null, + created_by_email: doc.created_by_id + ? (emailCacheRef.current.get(doc.created_by_id) ?? null) + : null, status: doc.status ?? { state: "ready" }, }), [] @@ -351,6 +369,9 @@ export function useDocuments( created_by_name: doc.created_by_id ? (userCacheRef.current.get(doc.created_by_id) ?? null) : null, + created_by_email: doc.created_by_id + ? (emailCacheRef.current.get(doc.created_by_id) ?? null) + : null, })) ); } @@ -455,6 +476,7 @@ export function useDocuments( setAllDocuments([]); apiLoadedRef.current = false; userCacheRef.current.clear(); + emailCacheRef.current.clear(); } prevSearchSpaceIdRef.current = searchSpaceId; }, [searchSpaceId]); diff --git a/surfsense_web/lib/provider-icons.tsx b/surfsense_web/lib/provider-icons.tsx index b7f49fe33..e405b7766 100644 --- a/surfsense_web/lib/provider-icons.tsx +++ b/surfsense_web/lib/provider-icons.tsx @@ -43,7 +43,7 @@ export function getProviderIcon( { isAutoMode, className = "size-4" }: { isAutoMode?: boolean; className?: string } = {} ) { if (isAutoMode || provider?.toUpperCase() === "AUTO") { - return ; + return ; } switch (provider?.toUpperCase()) { diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 20e665586..d7b062ce1 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -682,7 +682,7 @@ "rename_chat": "Rename Chat", "rename_chat_description": "Enter a new name for this conversation.", "chat_title_placeholder": "Chat title", - "renaming": "Renaming...", + "renaming": "Renaming", "no_archived_chats": "No archived chats", "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", @@ -720,7 +720,8 @@ "unread": "Unread", "connectors": "Connectors", "all_connectors": "All connectors", - "close": "Close" + "close": "Close", + "cancel": "Cancel" }, "errors": { "something_went_wrong": "Something went wrong", @@ -746,6 +747,8 @@ "nav_system_instructions_desc": "SearchSpace-wide AI instructions", "nav_public_links": "Public Chat Links", "nav_public_links_desc": "Manage publicly shared chat links", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Name", "general_name_placeholder": "Enter search space name", "general_name_description": "A unique name for your search space.", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index 9db0a99c9..d21b635b5 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -720,7 +720,8 @@ "unread": "No leído", "connectors": "Conectores", "all_connectors": "Todos los conectores", - "close": "Cerrar" + "close": "Cerrar", + "cancel": "Cancelar" }, "errors": { "something_went_wrong": "Algo salió mal", @@ -746,6 +747,8 @@ "nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda", "nav_public_links": "Enlaces de chat públicos", "nav_public_links_desc": "Administrar enlaces de chat compartidos públicamente", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Nombre", "general_name_placeholder": "Ingresa el nombre del espacio de búsqueda", "general_name_description": "Un nombre único para tu espacio de búsqueda.", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index 0c5370fd0..6a3cb7ffb 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -720,7 +720,8 @@ "unread": "अपठित", "connectors": "कनेक्टर", "all_connectors": "सभी कनेक्टर", - "close": "बंद करें" + "close": "बंद करें", + "cancel": "रद्द करें" }, "errors": { "something_went_wrong": "कुछ गलत हो गया", @@ -746,6 +747,8 @@ "nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश", "nav_public_links": "सार्वजनिक चैट लिंक", "nav_public_links_desc": "सार्वजनिक रूप से साझा किए गए चैट लिंक प्रबंधित करें", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "नाम", "general_name_placeholder": "सर्च स्पेस का नाम दर्ज करें", "general_name_description": "आपके सर्च स्पेस के लिए एक अद्वितीय नाम।", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index 3803e066c..ddaa7c0d3 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -720,7 +720,8 @@ "unread": "Não lido", "connectors": "Conectores", "all_connectors": "Todos os conectores", - "close": "Fechar" + "close": "Fechar", + "cancel": "Cancelar" }, "errors": { "something_went_wrong": "Algo deu errado", @@ -746,6 +747,8 @@ "nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa", "nav_public_links": "Links de chat públicos", "nav_public_links_desc": "Gerenciar links de chat compartilhados publicamente", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "Nome", "general_name_placeholder": "Insira o nome do espaço de pesquisa", "general_name_description": "Um nome único para seu espaço de pesquisa.", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 4c194a5dc..db29eb198 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -704,7 +704,8 @@ "unread": "未读", "connectors": "连接器", "all_connectors": "所有连接器", - "close": "关闭" + "close": "关闭", + "cancel": "取消" }, "errors": { "something_went_wrong": "出错了", @@ -730,6 +731,8 @@ "nav_system_instructions_desc": "搜索空间级别的 AI 指令", "nav_public_links": "公开聊天链接", "nav_public_links_desc": "管理公开分享的聊天链接", + "nav_team_roles": "Team Roles", + "nav_team_roles_desc": "Manage team roles & permissions", "general_name_label": "名称", "general_name_placeholder": "输入搜索空间名称", "general_name_description": "您的搜索空间的唯一名称。",