diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index dd3f25218..63826b407 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { type ColumnDef, type ColumnFiltersState, @@ -44,6 +45,12 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { createRoleMutationAtom, updateRoleMutationAtom, deleteRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; +import { useAtomValue } from "jotai"; +import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest, Role } from "@/contracts/types/roles.types"; +import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { AlertDialog, AlertDialogAction, @@ -103,12 +110,8 @@ import { type Invite, type InviteCreate, type Member, - type Role, - type RoleCreate, useInvites, useMembers, - usePermissions, - useRoles, useUserAccess, } from "@/hooks/use-rbac"; import { cn } from "@/lib/utils"; @@ -116,7 +119,7 @@ import { cn } from "@/lib/utils"; // Animation variants const fadeInUp = { hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" } }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const} }, }; const staggerContainer = { @@ -132,7 +135,7 @@ const cardVariants = { visible: { opacity: 1, scale: 1, - transition: { type: "spring", stiffness: 300, damping: 30 }, + transition: { type: "spring" as const, stiffness: 300, damping: 30 }, }, }; @@ -150,14 +153,55 @@ export default function TeamManagementPage() { updateMemberRole, removeMember, } = useMembers(searchSpaceId); + + const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); + const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); + const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); + + const handleUpdateRole = useCallback( + async (roleId: number, data: { permissions?: string[] }): Promise => { + const request: UpdateRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + data: data, + }; + return await updateRole(request); + }, + [updateRole, searchSpaceId] + ); + + const handleDeleteRole = useCallback( + async (roleId: number): Promise => { + const request: DeleteRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + }; + await deleteRole(request); + return true; + }, + [deleteRole, searchSpaceId] + ); + + const handleCreateRole = useCallback( + async (roleData: CreateRoleRequest['data']): Promise => { + const request: CreateRoleRequest = { + search_space_id: searchSpaceId, + data: roleData, + }; + return await createRole(request); + }, + [createRole, searchSpaceId] + ); + const { - roles, - loading: rolesLoading, - fetchRoles, - createRole, - updateRole, - deleteRole, - } = useRoles(searchSpaceId); + data: roles = [], + isLoading: rolesLoading, + refetch: fetchRoles, + } = useQuery({ + queryKey: cacheKeys.roles.all(searchSpaceId.toString()), + queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), + enabled: !!searchSpaceId, + }); const { invites, loading: invitesLoading, @@ -165,7 +209,19 @@ export default function TeamManagementPage() { createInvite, revokeInvite, } = useInvites(searchSpaceId); - const { groupedPermissions, loading: permissionsLoading } = usePermissions(); + + const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom); + const permissions = permissionsData?.permissions || []; + const groupedPermissions = useMemo(() => { + const groups: Record = {}; + for (const perm of permissions) { + if (!groups[perm.category]) { + groups[perm.category] = []; + } + groups[perm.category].push(perm); + } + return groups; + }, [permissions]); const canManageMembers = hasPermission("members:view"); const canManageRoles = hasPermission("roles:read"); @@ -329,7 +385,7 @@ export default function TeamManagementPage() { {activeTab === "roles" && hasPermission("roles:create") && ( )} @@ -351,8 +407,8 @@ export default function TeamManagementPage() { roles={roles} groupedPermissions={groupedPermissions} loading={rolesLoading} - onUpdateRole={updateRole} - onDeleteRole={deleteRole} + onUpdateRole={handleUpdateRole} + onDeleteRole={handleDeleteRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} /> @@ -663,7 +719,12 @@ function RolesTab({ {canUpdate && ( - + { + // TODO: Implement edit role dialog/modal + console.log("Edit role not yet implemented", role); + }} + > Edit Role @@ -882,7 +943,7 @@ function InvitesTab({ size="sm" className="gap-2" onClick={() => copyInviteLink(invite)} - disabled={isInactive} + disabled={Boolean(isInactive)} > {copiedId === invite.id ? ( <> @@ -1158,7 +1219,7 @@ function CreateRoleDialog({ onCreateRole, }: { groupedPermissions: Record; - onCreateRole: (data: RoleCreate) => Promise; + onCreateRole: (data: CreateRoleRequest['data']) => Promise; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); @@ -1177,7 +1238,7 @@ function CreateRoleDialog({ try { await onCreateRole({ name: name.trim(), - description: description.trim() || undefined, + description: description.trim() || null, permissions: selectedPermissions, is_default: isDefault, }); diff --git a/surfsense_web/atoms/permissions/permissions-query.atoms.ts b/surfsense_web/atoms/permissions/permissions-query.atoms.ts new file mode 100644 index 000000000..335ddd77d --- /dev/null +++ b/surfsense_web/atoms/permissions/permissions-query.atoms.ts @@ -0,0 +1,13 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { permissionsApiService } from "@/lib/apis/permissions-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const permissionsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.permissions.all(), + staleTime: 10 * 60 * 1000, // 10 minutes + queryFn: async () => { + return permissionsApiService.getPermissions(); + }, + }; +}); diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts new file mode 100644 index 000000000..47ece8b68 --- /dev/null +++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts @@ -0,0 +1,67 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + CreateRoleRequest, + CreateRoleResponse, + UpdateRoleRequest, + UpdateRoleResponse, + DeleteRoleRequest, + DeleteRoleResponse, +} from "@/contracts/types/roles.types"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: CreateRoleRequest) => { + return rolesApiService.createRole(request); + }, + onSuccess: (_: CreateRoleResponse, request: CreateRoleRequest) => { + toast.success("Role created successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to create role"); + }, + }; +}); + +export const updateRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: UpdateRoleRequest) => { + return rolesApiService.updateRole(request); + }, + onSuccess: (_: UpdateRoleResponse, request: UpdateRoleRequest) => { + toast.success("Role updated successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.byId(request.search_space_id.toString(), request.role_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to update role"); + }, + }; +}); + +export const deleteRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: DeleteRoleRequest) => { + return rolesApiService.deleteRole(request); + }, + onSuccess: (_: DeleteRoleResponse, request: DeleteRoleRequest) => { + toast.success("Role deleted successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to delete role"); + }, + }; +}); diff --git a/surfsense_web/contracts/types/invites.types.ts b/surfsense_web/contracts/types/invites.types.ts new file mode 100644 index 000000000..2a9460e53 --- /dev/null +++ b/surfsense_web/contracts/types/invites.types.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const invite = z.object({ + id: z.number(), + name: z.string().max(100).nullable().optional(), + invite_code: z.string(), + search_space_id: z.number(), + created_by_id: z.string().nullable(), + role_id: z.number().nullable(), + expires_at: z.string().nullable(), + max_uses: z.number().nullable(), + uses_count: z.number(), + is_active: z.boolean(), + created_at: z.string(), + role: role.nullable().optional(), +}); + +/** + * Create invite + */ +export const createInviteRequest = z.object({ + search_space_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + }), +}); + +export const createInviteResponse = invite; + +/** + * Get invites + */ +export const getInvitesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getInvitesResponse = z.array(invite); + +/** + * Update invite + */ +export const updateInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + is_active: z.boolean().optional(), + }), +}); + +export const updateInviteResponse = invite; + +/** + * Delete invite + */ +export const deleteInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), +}); + +export const deleteInviteResponse = z.object({ + message: z.string(), +}); + +/** + * Get invite info by code + */ +export const getInviteInfoRequest = z.object({ + invite_code: z.string(), +}); + +export const getInviteInfoResponse = z.object({ + invite_code: z.string(), + search_space_name: z.string(), + role_name: z.string().nullable(), + expires_at: z.string().nullable(), + is_valid: z.boolean(), +}); + +/** + * Accept invite + */ +export const acceptInviteRequest = z.object({ + invite_code: z.string(), +}); + +export const acceptInviteResponse = z.object({ + message: z.string(), + search_space_id: z.number(), +}); + +export type Invite = z.infer; +export type CreateInviteRequest = z.infer; +export type CreateInviteResponse = z.infer; +export type GetInvitesRequest = z.infer; +export type GetInvitesResponse = z.infer; +export type UpdateInviteRequest = z.infer; +export type UpdateInviteResponse = z.infer; +export type DeleteInviteRequest = z.infer; +export type DeleteInviteResponse = z.infer; +export type GetInviteInfoRequest = z.infer; +export type GetInviteInfoResponse = z.infer; +export type AcceptInviteRequest = z.infer; +export type AcceptInviteResponse = z.infer; diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts new file mode 100644 index 000000000..a6d6333ac --- /dev/null +++ b/surfsense_web/contracts/types/members.types.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const membership = z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number(), + role_id: z.number().nullable(), + is_owner: z.boolean(), + joined_at: z.string(), + created_at: z.string(), + role: role.nullable().optional(), + user_email: z.string().nullable().optional(), + user_is_active: z.boolean().nullable().optional(), +}); + +/** + * Get members + */ +export const getMembersRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMembersResponse = z.array(membership); + +/** + * Update membership + */ +export const updateMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), + data: z.object({ + role_id: z.number(), + }), +}); + +export const updateMembershipResponse = membership; + +/** + * Delete membership + */ +export const deleteMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), +}); + +export const deleteMembershipResponse = z.object({ + message: z.string(), +}); + +/** + * Leave search space + */ +export const leaveSearchSpaceRequest = z.object({ + search_space_id: z.number(), +}); + +export const leaveSearchSpaceResponse = z.object({ + message: z.string(), +}); + +/** + * Get my access + */ +export const getMyAccessRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMyAccessResponse = z.object({ + user_id: z.string(), + search_space_id: z.number(), + is_owner: z.boolean(), + permissions: z.array(z.string()), + role_name: z.string().nullable(), +}); + +export type Membership = z.infer; +export type GetMembersRequest = z.infer; +export type GetMembersResponse = z.infer; +export type UpdateMembershipRequest = z.infer; +export type UpdateMembershipResponse = z.infer; +export type DeleteMembershipRequest = z.infer; +export type DeleteMembershipResponse = z.infer; +export type LeaveSearchSpaceRequest = z.infer; +export type LeaveSearchSpaceResponse = z.infer; +export type GetMyAccessRequest = z.infer; +export type GetMyAccessResponse = z.infer; diff --git a/surfsense_web/contracts/types/permissions.types.ts b/surfsense_web/contracts/types/permissions.types.ts new file mode 100644 index 000000000..3f75192a3 --- /dev/null +++ b/surfsense_web/contracts/types/permissions.types.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const permissionInfo = z.object({ + value: z.string(), + name: z.string(), + category: z.string(), +}); + +/** + * Get permissions + */ +export const getPermissionsResponse = z.object({ + permissions: z.array(permissionInfo), +}); + +export type PermissionInfo = z.infer; +export type GetPermissionsResponse = z.infer; diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts new file mode 100644 index 000000000..31ad0e970 --- /dev/null +++ b/surfsense_web/contracts/types/roles.types.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +export const role = z.object({ + id: z.number(), + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + permissions: z.array(z.string()), + is_default: z.boolean(), + is_system_role: z.boolean(), + search_space_id: z.number(), + created_at: z.string(), +}); + +/** + * Create role + */ +export const createRoleRequest = z.object({ + search_space_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }), +}); + +export const createRoleResponse = role; + +/** + * Get roles + */ +export const getRolesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getRolesResponse = z.array(role); + +/** + * Get role by ID + */ +export const getRoleByIdRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const getRoleByIdResponse = role; + +/** + * Update role + */ +export const updateRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }).partial(), +}); + +export const updateRoleResponse = role; + +/** + * Delete role + */ +export const deleteRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const deleteRoleResponse = z.object({ + message: z.string(), +}); + +export type Role = z.infer; +export type CreateRoleRequest = z.infer; +export type CreateRoleResponse = z.infer; +export type GetRolesRequest = z.infer; +export type GetRolesResponse = z.infer; +export type GetRoleByIdRequest = z.infer; +export type GetRoleByIdResponse = z.infer; +export type UpdateRoleRequest = z.infer; +export type UpdateRoleResponse = z.infer; +export type DeleteRoleRequest = z.infer; +export type DeleteRoleResponse = z.infer; diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index ee3450746..fa619407a 100644 --- a/surfsense_web/hooks/use-rbac.ts +++ b/surfsense_web/hooks/use-rbac.ts @@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) { // ============ Roles Hook ============ -export function useRoles(searchSpaceId: number) { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchRoles = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch roles"); - } - - const data = await response.json(); - setRoles(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch roles"); - console.error("Error fetching roles:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchRoles(); - }, [fetchRoles]); - - const createRole = useCallback( - async (roleData: RoleCreate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create role"); - } - - const newRole = await response.json(); - setRoles((prev) => [...prev, newRole]); - toast.success("Role created successfully"); - return newRole; - } catch (err: any) { - toast.error(err.message || "Failed to create role"); - throw err; - } - }, - [searchSpaceId] - ); - - const updateRole = useCallback( - async (roleId: number, roleData: RoleUpdate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update role"); - } - - const updatedRole = await response.json(); - setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r))); - toast.success("Role updated successfully"); - return updatedRole; - } catch (err: any) { - toast.error(err.message || "Failed to update role"); - throw err; - } - }, - [searchSpaceId] - ); - - const deleteRole = useCallback( - async (roleId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to delete role"); - } - - setRoles((prev) => prev.filter((r) => r.id !== roleId)); - toast.success("Role deleted successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to delete role"); - return false; - } - }, - [searchSpaceId] - ); - - return { - roles, - loading, - error, - fetchRoles, - createRole, - updateRole, - deleteRole, - }; -} - -// ============ Invites Hook ============ - export function useInvites(searchSpaceId: number) { const [invites, setInvites] = useState([]); const [loading, setLoading] = useState(true); @@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) { // ============ Permissions Hook ============ -export function usePermissions() { - const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchPermissions = useCallback(async () => { - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch permissions"); - } - - const data = await response.json(); - setPermissions(data.permissions); - setError(null); - return data.permissions; - } catch (err: any) { - setError(err.message || "Failed to fetch permissions"); - console.error("Error fetching permissions:", err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchPermissions(); - }, [fetchPermissions]); - - // Group permissions by category - const groupedPermissions = useMemo(() => { - const groups: Record = {}; - for (const perm of permissions) { - if (!groups[perm.category]) { - groups[perm.category] = []; - } - groups[perm.category].push(perm); - } - return groups; - }, [permissions]); - - return { - permissions, - groupedPermissions, - loading, - error, - fetchPermissions, - }; -} - -// ============ User Access Hook ============ - export function useUserAccess(searchSpaceId: number) { const [access, setAccess] = useState(null); const [loading, setLoading] = useState(true); diff --git a/surfsense_web/lib/apis/permissions-api.service.ts b/surfsense_web/lib/apis/permissions-api.service.ts new file mode 100644 index 000000000..d161879b9 --- /dev/null +++ b/surfsense_web/lib/apis/permissions-api.service.ts @@ -0,0 +1,10 @@ +import { getPermissionsResponse } from "@/contracts/types/permissions.types"; +import { baseApiService } from "./base-api.service"; + +class PermissionsApiService { + getPermissions = async () => { + return baseApiService.get(`/api/v1/permissions`, getPermissionsResponse); + }; +} + +export const permissionsApiService = new PermissionsApiService(); diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts new file mode 100644 index 000000000..92083293a --- /dev/null +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -0,0 +1,109 @@ +import { + type CreateRoleRequest, + createRoleRequest, + createRoleResponse, + type DeleteRoleRequest, + deleteRoleRequest, + deleteRoleResponse, + type GetRoleByIdRequest, + getRoleByIdRequest, + getRoleByIdResponse, + type GetRolesRequest, + getRolesRequest, + getRolesResponse, + type UpdateRoleRequest, + updateRoleRequest, + updateRoleResponse, +} from "@/contracts/types/roles.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class RolesApiService { + createRole = async (request: CreateRoleRequest) => { + const parsedRequest = createRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + createRoleResponse, + { + body: parsedRequest.data.data, + }, + ); + }; + + getRoles = async (request: GetRolesRequest) => { + const parsedRequest = getRolesRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + getRolesResponse, + ); + }; + + getRoleById = async (request: GetRoleByIdRequest) => { + const parsedRequest = getRoleByIdRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + getRoleByIdResponse, + ); + }; + + updateRole = async (request: UpdateRoleRequest) => { + const parsedRequest = updateRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + updateRoleResponse, + { + body: parsedRequest.data.data, + }, + ); + }; + + deleteRole = async (request: DeleteRoleRequest) => { + const parsedRequest = deleteRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + deleteRoleResponse, + ); + }; +} + +export const rolesApiService = new RolesApiService(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index eb2c4972a..6ac7c6a6e 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -3,6 +3,7 @@ import type { GetDocumentsRequest } from "@/contracts/types/document.types"; import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetPodcastsRequest } from "@/contracts/types/podcast.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; +import type { GetRolesRequest } from "@/contracts/types/roles.types"; export const cacheKeys = { chats: { @@ -44,4 +45,11 @@ export const cacheKeys = { user: { current: () => ["user", "me"] as const, }, -}; \ No newline at end of file + roles: { + all: (searchSpaceId: string) => ["roles", searchSpaceId] as const, + byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const, + }, + permissions: { + all: () => ["permissions"] as const, + }, +};