diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 82197921a..0653ddbf2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -17,7 +17,7 @@ import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { useUserAccess } from "@/hooks/use-rbac"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { cn } from "@/lib/utils"; export function DashboardClientLayout({ @@ -69,7 +69,7 @@ export function DashboardClientLayout({ ); }, [preferences]); - const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum); + const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); // Skip onboarding check if we're already on the onboarding page 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 5f9c4dbad..1b0082186 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -46,12 +46,14 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { updateMemberMutationAtom, deleteMemberMutationAtom } from "@/atoms/members/members-mutation.atoms"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { createInviteMutationAtom, deleteInviteMutationAtom } from '@/atoms/invites/invites-mutation.atoms'; +import type { DeleteInviteRequest } from '@/contracts/types/invites.types'; +import type { UpdateMembershipRequest, DeleteMembershipRequest} from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; -import { - createRoleMutationAtom, - deleteRoleMutationAtom, - updateRoleMutationAtom, -} from "@/atoms/roles/roles-mutation.atoms"; +import { membersAtom } from "@/atoms/members/members-query.atoms"; +import { invitesApiService } from '@/lib/apis/invites-api.service'; import { AlertDialog, AlertDialogAction, @@ -115,15 +117,13 @@ import type { } from "@/contracts/types/roles.types"; import { type Invite, - type InviteCreate, - type Member, - useInvites, - useMembers, - useUserAccess, -} from "@/hooks/use-rbac"; + type CreateInviteRequest, +} from "@/contracts/types/invites.types"; +import type { Membership } from "@/contracts/types/members.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; +import { createRoleMutationAtom, deleteRoleMutationAtom, updateRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; // Animation variants const fadeInUp = { @@ -154,18 +154,50 @@ export default function TeamManagementPage() { const searchSpaceId = Number(params.search_space_id); const [activeTab, setActiveTab] = useState("members"); - const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId); - const { - members, - loading: membersLoading, - fetchMembers, - updateMemberRole, - removeMember, - } = useMembers(searchSpaceId); + const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); + + const hasPermission = useCallback( + (permission: string) => { + if (!access) return false; + if (access.is_owner) return true; + return access.permissions?.includes(permission) ?? false; + }, + [access] + ); + + const { data: members = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); + const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); + + const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); + const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom); + const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom); + + const handleRevokeInvite = useCallback( + async (inviteId: number): Promise => { + const request: DeleteInviteRequest = { + search_space_id: searchSpaceId, + invite_id: inviteId, + }; + await revokeInvite(request); + return true; + }, + [revokeInvite, searchSpaceId] + ); + + const handleCreateInvite = useCallback( + async (inviteData: CreateInviteRequest['data']) => { + const request: CreateInviteRequest = { + search_space_id: searchSpaceId, + data: inviteData, + }; + return await createInvite(request); + }, + [createInvite, searchSpaceId] + ); const handleUpdateRole = useCallback( async (roleId: number, data: { permissions?: string[] }): Promise => { @@ -202,6 +234,33 @@ export default function TeamManagementPage() { [createRole, searchSpaceId] ); + const handleUpdateMember = useCallback( + async (membershipId: number, roleId: number | null): Promise => { + const request: UpdateMembershipRequest = { + search_space_id: searchSpaceId, + membership_id: membershipId, + data: { + role_id: roleId, + }, + }; + return await updateMember(request) as Membership; + }, + [updateMember, searchSpaceId] + ); + + + const handleRemoveMember = useCallback( + async (membershipId: number) => { + const request: DeleteMembershipRequest = { + search_space_id: searchSpaceId, + membership_id: membershipId, + }; + await deleteMember(request); + + return true + }, + [deleteMember, searchSpaceId] + ); const { data: roles = [], isLoading: rolesLoading, @@ -212,12 +271,14 @@ export default function TeamManagementPage() { enabled: !!searchSpaceId, }); const { - invites, - loading: invitesLoading, - fetchInvites, - createInvite, - revokeInvite, - } = useInvites(searchSpaceId); + data: invites = [], + isLoading: invitesLoading, + refetch: fetchInvites, + } = useQuery({ + queryKey: cacheKeys.invites.all(searchSpaceId.toString()), + queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }), + staleTime: 5 * 60 * 1000, + }); const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom); const permissions = permissionsData?.permissions || []; @@ -387,7 +448,7 @@ export default function TeamManagementPage() { {activeTab === "invites" && canInvite && ( )} @@ -404,8 +465,8 @@ export default function TeamManagementPage() { members={members} roles={roles} loading={membersLoading} - onUpdateRole={updateMemberRole} - onRemoveMember={removeMember} + onUpdateRole={handleUpdateMember} + onRemoveMember={handleRemoveMember} canManageRoles={hasPermission("members:manage_roles")} canRemove={hasPermission("members:remove")} /> @@ -427,7 +488,7 @@ export default function TeamManagementPage() { @@ -449,10 +510,10 @@ function MembersTab({ canManageRoles, canRemove, }: { - members: Member[]; + members: Membership[]; roles: Role[]; loading: boolean; - onUpdateRole: (membershipId: number, roleId: number | null) => Promise; + onUpdateRole: (membershipId: number, roleId: number | null) => Promise; onRemoveMember: (membershipId: number) => Promise; canManageRoles: boolean; canRemove: boolean; @@ -1016,7 +1077,7 @@ function CreateInviteDialog({ searchSpaceId, }: { roles: Role[]; - onCreateInvite: (data: InviteCreate) => Promise; + onCreateInvite: (data: CreateInviteRequest['data']) => Promise; searchSpaceId: number; }) { const [open, setOpen] = useState(false); @@ -1031,7 +1092,7 @@ function CreateInviteDialog({ const handleCreate = async () => { setCreating(true); try { - const data: InviteCreate = {}; + const data: CreateInviteRequest['data'] = {}; if (name) data.name = name; if (roleId && roleId !== "default") data.role_id = Number(roleId); if (maxUses) data.max_uses = Number(maxUses); diff --git a/surfsense_web/app/invite/[invite_code]/page.tsx b/surfsense_web/app/invite/[invite_code]/page.tsx index 4ff78ac91..293015744 100644 --- a/surfsense_web/app/invite/[invite_code]/page.tsx +++ b/surfsense_web/app/invite/[invite_code]/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { useAtomValue } from "jotai"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, @@ -16,7 +18,11 @@ import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { use, useEffect, useState } from "react"; +import { use, useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms"; +import { invitesApiService } from "@/lib/apis/invites-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { Button } from "@/components/ui/button"; import { Card, @@ -26,22 +32,46 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { useInviteInfo } from "@/hooks/use-rbac"; import { getBearerToken } from "@/lib/auth-utils"; +import { AcceptInviteResponse } from "@/contracts/types/invites.types"; export default function InviteAcceptPage() { const params = useParams(); const router = useRouter(); const inviteCode = params.invite_code as string; - const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode); + const { data: inviteInfo = null, isLoading: loading } = useQuery({ + queryKey: cacheKeys.invites.info(inviteCode), + enabled: !!inviteCode, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!inviteCode) return null; + return invitesApiService.getInviteInfo({ + invite_code: inviteCode, + }); + }, + }); + + const { mutateAsync: acceptInviteMutation } = useAtomValue(acceptInviteMutationAtom); + + const acceptInvite = useCallback(async () => { + if (!inviteCode) { + toast.error("No invite code provided"); + return null; + } + + try { + const result = await acceptInviteMutation({ invite_code: inviteCode }); + return result; + } catch (err: any) { + toast.error(err.message || "Failed to accept invite"); + throw err; + } + }, [inviteCode, acceptInviteMutation]); + const [accepting, setAccepting] = useState(false); const [accepted, setAccepted] = useState(false); - const [acceptedData, setAcceptedData] = useState<{ - search_space_id: number; - search_space_name: string; - role_name: string; - } | null>(null); + const [acceptedData, setAcceptedData] = useState(null); const [error, setError] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(null); diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts new file mode 100644 index 000000000..2a82a8f15 --- /dev/null +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -0,0 +1,85 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { invitesApiService } from "@/lib/apis/invites-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; +import type { + CreateInviteRequest, + UpdateInviteRequest, + DeleteInviteRequest, + AcceptInviteRequest, +} from "@/contracts/types/invites.types"; +import { toast } from "sonner"; + +/** + * Mutation atom for creating an invite + */ +export const createInviteMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: CreateInviteRequest) => { + return invitesApiService.createInvite(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.invites.all(variables.search_space_id.toString()), + }); + toast.success("Invite created successfully"); + }, + onError: (error: Error) => { + console.error("Error creating invite:", error); + toast.error("Failed to create invite"); + }, +})); + +/** + * Mutation atom for updating an invite + */ +export const updateInviteMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: UpdateInviteRequest) => { + return invitesApiService.updateInvite(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.invites.all(variables.search_space_id.toString()), + }); + toast.success("Invite updated successfully"); + }, + onError: (error: Error) => { + console.error("Error updating invite:", error); + toast.error("Failed to update invite"); + }, +})); + +/** + * Mutation atom for deleting an invite + */ +export const deleteInviteMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: DeleteInviteRequest) => { + return invitesApiService.deleteInvite(request); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.invites.all(variables.search_space_id.toString()), + }); + toast.success("Invite deleted successfully"); + }, + onError: (error: Error) => { + console.error("Error deleting invite:", error); + toast.error("Failed to delete invite"); + }, +})); + +/** + * Mutation atom for accepting an invite + */ +export const acceptInviteMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: AcceptInviteRequest) => { + return invitesApiService.acceptInvite(request); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all }); + toast.success("Invite accepted successfully"); + }, + onError: (error: Error) => { + console.error("Error accepting invite:", error); + toast.error("Failed to accept invite"); + }, +})); diff --git a/surfsense_web/atoms/invites/invites-query.atoms.ts b/surfsense_web/atoms/invites/invites-query.atoms.ts new file mode 100644 index 000000000..db1aa70a0 --- /dev/null +++ b/surfsense_web/atoms/invites/invites-query.atoms.ts @@ -0,0 +1,22 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { invitesApiService } from "@/lib/apis/invites-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const invitesAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + if (!searchSpaceId) { + return []; + } + return invitesApiService.getInvites({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); diff --git a/surfsense_web/atoms/members/members-mutation.atoms.ts b/surfsense_web/atoms/members/members-mutation.atoms.ts new file mode 100644 index 000000000..0851a71e5 --- /dev/null +++ b/surfsense_web/atoms/members/members-mutation.atoms.ts @@ -0,0 +1,64 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + UpdateMembershipRequest, + UpdateMembershipResponse, + DeleteMembershipRequest, + DeleteMembershipResponse, + LeaveSearchSpaceRequest, + LeaveSearchSpaceResponse, +} from "@/contracts/types/members.types"; +import { membersApiService } from "@/lib/apis/members-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const updateMemberMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: UpdateMembershipRequest) => { + return membersApiService.updateMember(request); + }, + onSuccess: (_: UpdateMembershipResponse, request: UpdateMembershipRequest) => { + toast.success("Member updated successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.members.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to update member"); + }, + }; +}); + +export const deleteMemberMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: DeleteMembershipRequest) => { + return membersApiService.deleteMember(request); + }, + onSuccess: (_: DeleteMembershipResponse, request: DeleteMembershipRequest) => { + toast.success("Member removed successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.members.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to remove member"); + }, + }; +}); + +export const leaveSearchSpaceMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: LeaveSearchSpaceRequest) => { + return membersApiService.leaveSearchSpace(request); + }, + onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => { + toast.success("Successfully left the search space"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.members.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to leave search space"); + }, + }; +}); diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts new file mode 100644 index 000000000..8ed56ef0c --- /dev/null +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -0,0 +1,40 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { membersApiService } from "@/lib/apis/members-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const membersAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + if (!searchSpaceId) { + return []; + } + return membersApiService.getMembers({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); + +export const myAccessAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + if (!searchSpaceId) { + return null; + } + return membersApiService.getMyAccess({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); diff --git a/surfsense_web/contracts/types/invites.types.ts b/surfsense_web/contracts/types/invites.types.ts index 2a9460e53..0359d84d5 100644 --- a/surfsense_web/contracts/types/invites.types.ts +++ b/surfsense_web/contracts/types/invites.types.ts @@ -77,11 +77,10 @@ export const getInviteInfoRequest = z.object({ }); 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(), + message: z.string().nullable(), }); /** @@ -94,6 +93,8 @@ export const acceptInviteRequest = z.object({ export const acceptInviteResponse = z.object({ message: z.string(), search_space_id: z.number(), + search_space_name: z.string(), + role_name: z.string().nullable(), }); export type Invite = z.infer; diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index a6d6333ac..d20109b96 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -30,7 +30,7 @@ export const updateMembershipRequest = z.object({ search_space_id: z.number(), membership_id: z.number(), data: z.object({ - role_id: z.number(), + role_id: z.number().nullable(), }), }); @@ -67,7 +67,7 @@ export const getMyAccessRequest = z.object({ }); export const getMyAccessResponse = z.object({ - user_id: z.string(), + search_space_name: z.string(), search_space_id: z.number(), is_owner: z.boolean(), permissions: z.array(z.string()), diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts index db454c161..60afebc45 100644 --- a/surfsense_web/hooks/index.ts +++ b/surfsense_web/hooks/index.ts @@ -1,4 +1,3 @@ export * from "./use-debounced-value"; export * from "./use-logs"; -export * from "./use-rbac"; export * from "./use-search-source-connectors"; diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts deleted file mode 100644 index fa619407a..000000000 --- a/surfsense_web/hooks/use-rbac.ts +++ /dev/null @@ -1,499 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils"; - -// ============ Types ============ - -export interface Role { - id: number; - name: string; - description: string | null; - permissions: string[]; - is_default: boolean; - is_system_role: boolean; - search_space_id: number; - created_at: string; -} - -export interface Member { - id: number; - user_id: string; - search_space_id: number; - role_id: number | null; - is_owner: boolean; - joined_at: string; - created_at: string; - role: Role | null; - user_email: string | null; -} - -export interface Invite { - id: number; - invite_code: string; - search_space_id: number; - role_id: number | null; - created_by_id: string | null; - expires_at: string | null; - max_uses: number | null; - uses_count: number; - is_active: boolean; - name: string | null; - created_at: string; - role: Role | null; -} - -export interface InviteCreate { - name?: string; - role_id?: number; - expires_at?: string; - max_uses?: number; -} - -export interface InviteUpdate { - name?: string; - role_id?: number; - expires_at?: string; - max_uses?: number; - is_active?: boolean; -} - -export interface RoleCreate { - name: string; - description?: string; - permissions: string[]; - is_default?: boolean; -} - -export interface RoleUpdate { - name?: string; - description?: string; - permissions?: string[]; - is_default?: boolean; -} - -export interface PermissionInfo { - value: string; - name: string; - category: string; -} - -export interface UserAccess { - search_space_id: number; - search_space_name: string; - is_owner: boolean; - role_name: string | null; - permissions: string[]; -} - -export interface InviteInfo { - search_space_name: string; - role_name: string | null; - is_valid: boolean; - message: string | null; -} - -// ============ Members Hook ============ - -export function useMembers(searchSpaceId: number) { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchMembers = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch members"); - } - - const data = await response.json(); - setMembers(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch members"); - console.error("Error fetching members:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchMembers(); - }, [fetchMembers]); - - const updateMemberRole = useCallback( - async (membershipId: number, roleId: number | null) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify({ role_id: roleId }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update member role"); - } - - const updatedMember = await response.json(); - setMembers((prev) => prev.map((m) => (m.id === membershipId ? updatedMember : m))); - toast.success("Member role updated successfully"); - return updatedMember; - } catch (err: any) { - toast.error(err.message || "Failed to update member role"); - throw err; - } - }, - [searchSpaceId] - ); - - const removeMember = useCallback( - async (membershipId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to remove member"); - } - - setMembers((prev) => prev.filter((m) => m.id !== membershipId)); - toast.success("Member removed successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to remove member"); - return false; - } - }, - [searchSpaceId] - ); - - const leaveSearchSpace = useCallback(async () => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/me`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to leave search space"); - } - - toast.success("Successfully left the search space"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to leave search space"); - return false; - } - }, [searchSpaceId]); - - return { - members, - loading, - error, - fetchMembers, - updateMemberRole, - removeMember, - leaveSearchSpace, - }; -} - -// ============ Roles Hook ============ - -export function useInvites(searchSpaceId: number) { - const [invites, setInvites] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchInvites = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch invites"); - } - - const data = await response.json(); - setInvites(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch invites"); - console.error("Error fetching invites:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchInvites(); - }, [fetchInvites]); - - const createInvite = useCallback( - async (inviteData: InviteCreate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(inviteData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create invite"); - } - - const newInvite = await response.json(); - setInvites((prev) => [...prev, newInvite]); - toast.success("Invite created successfully"); - return newInvite; - } catch (err: any) { - toast.error(err.message || "Failed to create invite"); - throw err; - } - }, - [searchSpaceId] - ); - - const updateInvite = useCallback( - async (inviteId: number, inviteData: InviteUpdate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify(inviteData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update invite"); - } - - const updatedInvite = await response.json(); - setInvites((prev) => prev.map((i) => (i.id === inviteId ? updatedInvite : i))); - toast.success("Invite updated successfully"); - return updatedInvite; - } catch (err: any) { - toast.error(err.message || "Failed to update invite"); - throw err; - } - }, - [searchSpaceId] - ); - - const revokeInvite = useCallback( - async (inviteId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to revoke invite"); - } - - setInvites((prev) => prev.filter((i) => i.id !== inviteId)); - toast.success("Invite revoked successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to revoke invite"); - return false; - } - }, - [searchSpaceId] - ); - - return { - invites, - loading, - error, - fetchInvites, - createInvite, - updateInvite, - revokeInvite, - }; -} - -// ============ Permissions Hook ============ - -export function useUserAccess(searchSpaceId: number) { - const [access, setAccess] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchAccess = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/my-access`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch access info"); - } - - const data = await response.json(); - setAccess(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch access info"); - console.error("Error fetching access:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchAccess(); - }, [fetchAccess]); - - // Helper function to check if user has a specific permission - const hasPermission = useCallback( - (permission: string) => { - if (!access) return false; - // Owner/full access check - if (access.permissions.includes("*")) return true; - return access.permissions.includes(permission); - }, - [access] - ); - - // Helper function to check if user has any of the given permissions - const hasAnyPermission = useCallback( - (permissions: string[]) => { - if (!access) return false; - if (access.permissions.includes("*")) return true; - return permissions.some((p) => access.permissions.includes(p)); - }, - [access] - ); - - return { - access, - loading, - error, - fetchAccess, - hasPermission, - hasAnyPermission, - }; -} - -// ============ Invite Info Hook (Public) ============ - -export function useInviteInfo(inviteCode: string | null) { - const [inviteInfo, setInviteInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchInviteInfo = useCallback(async () => { - if (!inviteCode) { - setLoading(false); - return; - } - - try { - setLoading(true); - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/${inviteCode}/info`, - { - method: "GET", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch invite info"); - } - - const data = await response.json(); - setInviteInfo(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch invite info"); - console.error("Error fetching invite info:", err); - } finally { - setLoading(false); - } - }, [inviteCode]); - - useEffect(() => { - fetchInviteInfo(); - }, [fetchInviteInfo]); - - const acceptInvite = useCallback(async () => { - if (!inviteCode) { - toast.error("No invite code provided"); - return null; - } - - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/accept`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify({ invite_code: inviteCode }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to accept invite"); - } - - const data = await response.json(); - toast.success(data.message || "Successfully joined the search space"); - return data; - } catch (err: any) { - toast.error(err.message || "Failed to accept invite"); - throw err; - } - }, [inviteCode]); - - return { - inviteInfo, - loading, - error, - fetchInviteInfo, - acceptInvite, - }; -} diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts new file mode 100644 index 000000000..e7c3a8426 --- /dev/null +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -0,0 +1,155 @@ +import { + type CreateInviteRequest, + type CreateInviteResponse, + type GetInvitesRequest, + type GetInvitesResponse, + type UpdateInviteRequest, + type UpdateInviteResponse, + type DeleteInviteRequest, + type DeleteInviteResponse, + type GetInviteInfoRequest, + type GetInviteInfoResponse, + type AcceptInviteRequest, + type AcceptInviteResponse, + createInviteRequest, + createInviteResponse, + getInvitesRequest, + getInvitesResponse, + updateInviteRequest, + updateInviteResponse, + deleteInviteRequest, + deleteInviteResponse, + getInviteInfoRequest, + getInviteInfoResponse, + acceptInviteRequest, + acceptInviteResponse, +} from "@/contracts/types/invites.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class InvitesApiService { + /** + * Create a new invite + */ + createInvite = async (request: CreateInviteRequest) => { + const parsedRequest = createInviteRequest.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}/invites`, + createInviteResponse, + { + body: parsedRequest.data.data, + } + ); + }; + + /** + * Get all invites for a search space + */ + getInvites = async (request: GetInvitesRequest) => { + const parsedRequest = getInvitesRequest.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}/invites`, + getInvitesResponse + ); + }; + + /** + * Update an invite + */ + updateInvite = async (request: UpdateInviteRequest) => { + const parsedRequest = updateInviteRequest.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}/invites/${parsedRequest.data.invite_id}`, + updateInviteResponse, + { + body: parsedRequest.data.data, + } + ); + }; + + /** + * Delete an invite + */ + deleteInvite = async (request: DeleteInviteRequest) => { + const parsedRequest = deleteInviteRequest.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}/invites/${parsedRequest.data.invite_id}`, + deleteInviteResponse + ); + }; + + /** + * Get invite info by invite code + */ + getInviteInfo = async (request: GetInviteInfoRequest) => { + const parsedRequest = getInviteInfoRequest.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/invites/${parsedRequest.data.invite_code}/info`, + getInviteInfoResponse + ); + }; + + /** + * Accept an invite + */ + acceptInvite = async (request: AcceptInviteRequest) => { + const parsedRequest = acceptInviteRequest.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/invites/accept`, + acceptInviteResponse, + { + body: parsedRequest.data, + } + ); + }; +} + +export const invitesApiService = new InvitesApiService(); diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts new file mode 100644 index 000000000..10bb5da6a --- /dev/null +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -0,0 +1,126 @@ +import { + type GetMembersRequest, + type GetMembersResponse, + type UpdateMembershipRequest, + type UpdateMembershipResponse, + type DeleteMembershipRequest, + type DeleteMembershipResponse, + type LeaveSearchSpaceRequest, + type LeaveSearchSpaceResponse, + type GetMyAccessRequest, + type GetMyAccessResponse, + getMembersRequest, + getMembersResponse, + updateMembershipRequest, + updateMembershipResponse, + deleteMembershipRequest, + deleteMembershipResponse, + leaveSearchSpaceRequest, + leaveSearchSpaceResponse, + getMyAccessRequest, + getMyAccessResponse, +} from "@/contracts/types/members.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class MembersApiService { + /** + * Get members of a search space + */ + getMembers = async (request: GetMembersRequest) => { + const parsedRequest = getMembersRequest.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}/members`, + getMembersResponse, + ); + }; + + /** + * Update a member's role + */ + updateMember = async (request: UpdateMembershipRequest) => { + const parsedRequest = updateMembershipRequest.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}/members/${parsedRequest.data.membership_id}`, + updateMembershipResponse, + { + body: parsedRequest.data.data, + }, + ); + }; + + /** + * Delete a member from search space + */ + deleteMember = async (request: DeleteMembershipRequest) => { + const parsedRequest = deleteMembershipRequest.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}/members/${parsedRequest.data.membership_id}`, + deleteMembershipResponse, + ); + }; + + /** + * Leave a search space (remove self) + */ + leaveSearchSpace = async (request: LeaveSearchSpaceRequest) => { + const parsedRequest = leaveSearchSpaceRequest.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}/members/me`, + leaveSearchSpaceResponse, + ); + }; + + /** + * Get current user's access information for a search space + */ + getMyAccess = async (request: GetMyAccessRequest) => { + const parsedRequest = getMyAccessRequest.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}/my-access`, + getMyAccessResponse, + ); + }; +} + +export const membersApiService = new MembersApiService(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index db7af6636..be7fa13da 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -4,6 +4,7 @@ import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetPodcastsRequest } from "@/contracts/types/podcast.types"; import type { GetRolesRequest } from "@/contracts/types/roles.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; +import type { GetMembersRequest } from "@/contracts/types/members.types"; export const cacheKeys = { chats: { @@ -52,4 +53,12 @@ export const cacheKeys = { permissions: { all: () => ["permissions"] as const, }, + members: { + all: (searchSpaceId: string) => ["members", searchSpaceId] as const, + myAccess: (searchSpaceId: string) => ["members", "my-access", searchSpaceId] as const, + }, + invites: { + all: (searchSpaceId: string) => ["invites", searchSpaceId] as const, + info: (inviteCode: string) => ["invites", "info", inviteCode] as const, + }, };