From e9eb97b17e04b159cf54dc9d8d54a2826670eac4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 13:33:45 +0000 Subject: [PATCH 01/43] feat: add members API service with getMembers method --- surfsense_web/lib/apis/members-api.service.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 surfsense_web/lib/apis/members-api.service.ts 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..53dc42304 --- /dev/null +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -0,0 +1,21 @@ +import { baseApiService } from "./base-api.service"; +import { + type GetMembersRequest, + getMembersRequest, + getMembersResponse, +} from "@/contracts/types/members.types"; + +class MembersApiService { + /** + * Get members of a search space + */ + async getMembers(request: GetMembersRequest) { + const parsedRequest = getMembersRequest.parse(request); + return baseApiService.get( + `/searchspaces/${parsedRequest.search_space_id}/members`, + getMembersResponse, + ); + } +} + +export const membersApiService = new MembersApiService(); From 22f8d5bbbb5cf6509fd493c5ed83e6c708eb803d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 13:42:12 +0000 Subject: [PATCH 02/43] feat: add updateMember method to members API service --- surfsense_web/lib/apis/members-api.service.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 53dc42304..51f66a7b0 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -3,6 +3,9 @@ import { type GetMembersRequest, getMembersRequest, getMembersResponse, + type UpdateMembershipRequest, + updateMembershipRequest, + updateMembershipResponse, } from "@/contracts/types/members.types"; class MembersApiService { @@ -16,6 +19,20 @@ class MembersApiService { getMembersResponse, ); } + + /** + * Update a member's role + */ + async updateMember(request: UpdateMembershipRequest) { + const parsedRequest = updateMembershipRequest.parse(request); + return baseApiService.put( + `/searchspaces/${parsedRequest.search_space_id}/members/${parsedRequest.membership_id}`, + updateMembershipResponse, + { + body: parsedRequest.data, + }, + ); + } } export const membersApiService = new MembersApiService(); From d747f59ae9dd68db1f378e5accf797c412a2fcb8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 13:58:26 +0000 Subject: [PATCH 03/43] fix: update members API service to follow established patterns with safeParse and arrow functions --- surfsense_web/lib/apis/members-api.service.ts | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 51f66a7b0..4743904df 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -1,38 +1,57 @@ -import { baseApiService } from "./base-api.service"; import { type GetMembersRequest, + type GetMembersResponse, + type UpdateMembershipRequest, + type UpdateMembershipResponse, getMembersRequest, getMembersResponse, - type UpdateMembershipRequest, updateMembershipRequest, updateMembershipResponse, } from "@/contracts/types/members.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; class MembersApiService { /** * Get members of a search space */ - async getMembers(request: GetMembersRequest) { - const parsedRequest = getMembersRequest.parse(request); + 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( - `/searchspaces/${parsedRequest.search_space_id}/members`, + `/searchspaces/${parsedRequest.data.search_space_id}/members`, getMembersResponse, ); - } + }; /** * Update a member's role */ - async updateMember(request: UpdateMembershipRequest) { - const parsedRequest = updateMembershipRequest.parse(request); + 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( - `/searchspaces/${parsedRequest.search_space_id}/members/${parsedRequest.membership_id}`, + `/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, updateMembershipResponse, { - body: parsedRequest.data, + body: parsedRequest.data.data, }, ); - } + }; } export const membersApiService = new MembersApiService(); From eb38a0277562de4df6c003b263d685bca7e4826a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:13:53 +0000 Subject: [PATCH 04/43] feat: add deleteMember method to members API service --- surfsense_web/lib/apis/members-api.service.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 4743904df..a60cbf363 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -3,10 +3,14 @@ import { type GetMembersResponse, type UpdateMembershipRequest, type UpdateMembershipResponse, + type DeleteMembershipRequest, + type DeleteMembershipResponse, getMembersRequest, getMembersResponse, updateMembershipRequest, updateMembershipResponse, + deleteMembershipRequest, + deleteMembershipResponse, } from "@/contracts/types/members.types"; import { ValidationError } from "@/lib/error"; import { baseApiService } from "./base-api.service"; @@ -52,6 +56,25 @@ class MembersApiService { }, ); }; + + /** + * 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( + `/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, + deleteMembershipResponse, + ); + }; } export const membersApiService = new MembersApiService(); From 1d153f4f33b8a08f7f3a32895efdf1d2da3686f7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:20:59 +0000 Subject: [PATCH 05/43] feat: add leaveSearchSpace method to members API service --- surfsense_web/lib/apis/members-api.service.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index a60cbf363..9799a2695 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -5,12 +5,16 @@ import { type UpdateMembershipResponse, type DeleteMembershipRequest, type DeleteMembershipResponse, + type LeaveSearchSpaceRequest, + type LeaveSearchSpaceResponse, getMembersRequest, getMembersResponse, updateMembershipRequest, updateMembershipResponse, deleteMembershipRequest, deleteMembershipResponse, + leaveSearchSpaceRequest, + leaveSearchSpaceResponse, } from "@/contracts/types/members.types"; import { ValidationError } from "@/lib/error"; import { baseApiService } from "./base-api.service"; @@ -75,6 +79,25 @@ class MembersApiService { 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( + `/searchspaces/${parsedRequest.data.search_space_id}/members/me`, + leaveSearchSpaceResponse, + ); + }; } export const membersApiService = new MembersApiService(); From 52c3b5cc7e3305261f709c53be503f746914950f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:29:17 +0000 Subject: [PATCH 06/43] feat: add getMyAccess method to members API service --- surfsense_web/lib/apis/members-api.service.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/surfsense_web/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 9799a2695..1f8ee5bd8 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -7,6 +7,8 @@ import { type DeleteMembershipResponse, type LeaveSearchSpaceRequest, type LeaveSearchSpaceResponse, + type GetMyAccessRequest, + type GetMyAccessResponse, getMembersRequest, getMembersResponse, updateMembershipRequest, @@ -15,6 +17,8 @@ import { deleteMembershipResponse, leaveSearchSpaceRequest, leaveSearchSpaceResponse, + getMyAccessRequest, + getMyAccessResponse, } from "@/contracts/types/members.types"; import { ValidationError } from "@/lib/error"; import { baseApiService } from "./base-api.service"; @@ -98,6 +102,25 @@ class MembersApiService { 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( + `/searchspaces/${parsedRequest.data.search_space_id}/my-access`, + getMyAccessResponse, + ); + }; } export const membersApiService = new MembersApiService(); From baab42efb67b98d112b4216cd5fabe704a85baa1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:34:29 +0000 Subject: [PATCH 07/43] feat: add cache keys for members --- surfsense_web/lib/query-client/cache-keys.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index db7af6636..fa963f26d 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,8 @@ 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, + }, }; From 83a726737a253564277d4019ac05b55d6115f779 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:40:22 +0000 Subject: [PATCH 08/43] feat: add updateMemberMutationAtom --- .../atoms/members/members-mutation.atoms.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 surfsense_web/atoms/members/members-mutation.atoms.ts 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..3bd7a0392 --- /dev/null +++ b/surfsense_web/atoms/members/members-mutation.atoms.ts @@ -0,0 +1,26 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + UpdateMembershipRequest, + UpdateMembershipResponse, +} 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"); + }, + }; +}); From 5f541cf20620ab4bd0710ea1a36c548b4533d242 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:46:41 +0000 Subject: [PATCH 09/43] feat: add deleteMemberMutationAtom --- .../atoms/members/members-mutation.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/atoms/members/members-mutation.atoms.ts b/surfsense_web/atoms/members/members-mutation.atoms.ts index 3bd7a0392..f2c9c276e 100644 --- a/surfsense_web/atoms/members/members-mutation.atoms.ts +++ b/surfsense_web/atoms/members/members-mutation.atoms.ts @@ -3,6 +3,8 @@ import { toast } from "sonner"; import type { UpdateMembershipRequest, UpdateMembershipResponse, + DeleteMembershipRequest, + DeleteMembershipResponse, } from "@/contracts/types/members.types"; import { membersApiService } from "@/lib/apis/members-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -24,3 +26,20 @@ export const updateMemberMutationAtom = atomWithMutation(() => { }, }; }); + +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"); + }, + }; +}); From b20587df60c376b50da1a5587af404fa59c101ba Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:52:40 +0000 Subject: [PATCH 10/43] feat: add leaveSearchSpaceMutationAtom --- .../atoms/members/members-mutation.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/atoms/members/members-mutation.atoms.ts b/surfsense_web/atoms/members/members-mutation.atoms.ts index f2c9c276e..0851a71e5 100644 --- a/surfsense_web/atoms/members/members-mutation.atoms.ts +++ b/surfsense_web/atoms/members/members-mutation.atoms.ts @@ -5,6 +5,8 @@ import type { 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"; @@ -43,3 +45,20 @@ export const deleteMemberMutationAtom = atomWithMutation(() => { }, }; }); + +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"); + }, + }; +}); From fbd527209674e96065ee67f6e31cbeea072f46ce Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 14:58:01 +0000 Subject: [PATCH 11/43] feat: add membersAtom query for fetching members --- .../atoms/members/members-query.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 surfsense_web/atoms/members/members-query.atoms.ts 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..71d0b3572 --- /dev/null +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -0,0 +1,19 @@ +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 () => { + return membersApiService.getMembers({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); From 03f6efb5aaf8f8c39959ec07a2f54c89fe55ae62 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:03:14 +0000 Subject: [PATCH 12/43] feat: add myAccessAtom query for user access permissions --- .../atoms/members/members-query.atoms.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index 71d0b3572..8ed56ef0c 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -11,9 +11,30 @@ export const membersAtom = atomWithQuery((get) => { 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), + }); + }, + }; +}); From 109cd4f091ebdca87d1c784aa43d5d48d13ab5c2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:38:01 +0000 Subject: [PATCH 13/43] feat: add createInvite method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 surfsense_web/lib/apis/invites-api.service.ts 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..294d2a7b5 --- /dev/null +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -0,0 +1,54 @@ +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, + } + ); + }; +} + +export const invitesApiService = new InvitesApiService(); From 5c641829603d0c4746f8444bc132f838934dd0d9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:42:25 +0000 Subject: [PATCH 14/43] feat: add getInvites method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index 294d2a7b5..b01806b55 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -49,6 +49,25 @@ class InvitesApiService { } ); }; + + /** + * 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 + ); + }; } export const invitesApiService = new InvitesApiService(); From d061b6258fccf49b2184809b1fb9c00ec974dfee Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:46:39 +0000 Subject: [PATCH 15/43] feat: add updateInvite method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index b01806b55..e4a536774 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -64,8 +64,30 @@ class InvitesApiService { } return baseApiService.get( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites`, - getInvitesResponse + `/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, + } ); }; } From d32c824412b4a717430d49e421fa46e87aec9178 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:49:35 +0000 Subject: [PATCH 16/43] feat: add deleteInvite method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index e4a536774..75fdd7efc 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -83,11 +83,30 @@ class InvitesApiService { } 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}`, - updateInviteResponse, - { - body: parsedRequest.data.data, - } + deleteInviteResponse ); }; } From 0cf85943d21e4555e8a2e46bc3343ee7435216fc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:52:28 +0000 Subject: [PATCH 17/43] feat: add getInviteInfo method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index 75fdd7efc..2db3f382a 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -105,8 +105,27 @@ class InvitesApiService { } return baseApiService.delete( - `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`, - deleteInviteResponse + `/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 ); }; } From 01bcfa999eda387c21018cd750404c28850bd1ae Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:55:17 +0000 Subject: [PATCH 18/43] feat: add acceptInvite method to invites API service --- surfsense_web/lib/apis/invites-api.service.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/surfsense_web/lib/apis/invites-api.service.ts b/surfsense_web/lib/apis/invites-api.service.ts index 2db3f382a..e7c3a8426 100644 --- a/surfsense_web/lib/apis/invites-api.service.ts +++ b/surfsense_web/lib/apis/invites-api.service.ts @@ -124,8 +124,30 @@ class InvitesApiService { } return baseApiService.get( - `/api/v1/invites/${parsedRequest.data.invite_code}/info`, - getInviteInfoResponse + `/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, + } ); }; } From 85c8ca67cb3f49b51f05afbe467b9b84866c2dd8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 15:58:26 +0000 Subject: [PATCH 19/43] feat: add cache keys for invites --- surfsense_web/lib/query-client/cache-keys.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index fa963f26d..be7fa13da 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -57,4 +57,8 @@ export const cacheKeys = { 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, + }, }; From 3cd9018626a45e8e75209621ed04ce8d93efba1c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:06:39 +0000 Subject: [PATCH 20/43] feat: add createInviteMutationAtom for invite creation --- .../atoms/invites/invites-mutation.atoms.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 surfsense_web/atoms/invites/invites-mutation.atoms.ts 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..37deb1f6e --- /dev/null +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -0,0 +1,30 @@ +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"); + }, +})); From 9c22ae2da525036ab65d62955ccf012774e7c7ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:10:12 +0000 Subject: [PATCH 21/43] feat: add updateInviteMutationAtom for invite updates --- .../atoms/invites/invites-mutation.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts index 37deb1f6e..0a6d79607 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -28,3 +28,22 @@ export const createInviteMutationAtom = atomWithMutation(() => ({ 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"); + }, +})); From 5e0bc3823ca288c830ef6385b9c0605168fa720e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:14:36 +0000 Subject: [PATCH 22/43] feat: add deleteInviteMutationAtom --- .../atoms/invites/invites-mutation.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts index 0a6d79607..9001e5f26 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -47,3 +47,22 @@ export const updateInviteMutationAtom = atomWithMutation(() => ({ 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"); + }, +})); From 92e2414ff7bf6178cdeab7da311f2b74c6b3c067 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:16:38 +0000 Subject: [PATCH 23/43] feat: add acceptInviteMutationAtom --- .../atoms/invites/invites-mutation.atoms.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts index 9001e5f26..c1e56c465 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -66,3 +66,20 @@ export const deleteInviteMutationAtom = atomWithMutation(() => ({ 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: ["search-spaces"] }); + toast.success("Invite accepted successfully"); + }, + onError: (error: Error) => { + console.error("Error accepting invite:", error); + toast.error("Failed to accept invite"); + }, +})); From 920ac210c74d16f98e5cf412856c3f4869ffa227 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:19:19 +0000 Subject: [PATCH 24/43] fix: use centralized cache keys instead of hardcoded string --- surfsense_web/atoms/invites/invites-mutation.atoms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts index c1e56c465..2a82a8f15 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -75,7 +75,7 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({ return invitesApiService.acceptInvite(request); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["search-spaces"] }); + queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all }); toast.success("Invite accepted successfully"); }, onError: (error: Error) => { From 4ffd857110a6695b5b4235d010896f71ccd46fdf Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 16:31:37 +0000 Subject: [PATCH 25/43] feat: add invitesAtom query atom --- .../atoms/invites/invites-query.atoms.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 surfsense_web/atoms/invites/invites-query.atoms.ts 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), + }); + }, + }; +}); From 3fc301647448c98a26f00df351fd4247011a7871 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 17:11:21 +0000 Subject: [PATCH 26/43] feat: migrate members fetch to membersAtom in team page --- .../app/dashboard/[search_space_id]/team/page.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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..f432231f9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -47,6 +47,7 @@ import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; +import { membersAtom } from "@/atoms/members/members-query.atoms"; import { createRoleMutationAtom, deleteRoleMutationAtom, @@ -156,12 +157,12 @@ export default function TeamManagementPage() { const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId); const { - members, - loading: membersLoading, - fetchMembers, - updateMemberRole, - removeMember, - } = useMembers(searchSpaceId); + updateMemberRole, + removeMember, +} = useMembers(searchSpaceId); + + const { data: membersData = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); + const members = membersData as Member[]; const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); From bade48efefc7b04d482c6a252820e253de15192c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 07:10:58 +0000 Subject: [PATCH 27/43] docs: add AGENTS.md repository guidelines --- AGENTS.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f63b2c9ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +The repository contains three main modules: +- **`surfsense_web/`**: Next.js 15 frontend with TypeScript + - `app/`: App router pages and layouts + - `components/`: React components organized by feature + - `atoms/`: Jotai atoms for state management + - `lib/apis/`: API service layer with Zod validation + - `hooks/`: Custom React hooks (being migrated to jotai+tanstack) + - `contracts/types/`: Zod schemas and TypeScript types +- **`surfsense_backend/`**: FastAPI Python backend +- **`surfsense_browser_extension/`**: Browser extension + +## Build, Test, and Development Commands + +**Web Frontend** (from `surfsense_web/`): +```bash +pnpm install # Install dependencies +pnpm dev # Start development server +pnpm build # Production build +pnpm format # Format code with Biome +``` + +## Coding Style & Naming Conventions + +- **TypeScript**: Use tabs for indentation, arrow functions preferred +- **Files**: kebab-case (e.g., `llm-config-api.service.ts`) +- **Types**: PascalCase with Zod schemas, infer types at end of file +- **Atoms**: Descriptive names with "Atom" suffix (e.g., `documentsAtom`, `createDocumentMutationAtom`) +- **Formatting**: Biome for linting and formatting + +## Migration Pattern (Imperative to Jotai+TanStack Query) + +When migrating from imperative hooks to jotai+tanstack: +1. Create Zod schemas in `contracts/types/` +2. Create API service in `lib/apis/` +3. Add cache keys to `lib/query-client/cache-keys.ts` +4. Create query/mutation atoms in `atoms/` +5. Replace hook usage in components (maintain backward compatibility) +6. Delete old hook after all usages migrated + +**Important**: For queries needing dynamic inputs, use `useQuery` directly in components instead of atoms. + +## Commit Guidelines + +- **Format**: `type: description` (e.g., `feat: add user API service`, `fix: resolve type error`) +- **Types**: `feat`, `fix`, `refactor`, `docs`, `chore` +- **Scope**: One logical change per commit +- **Build**: Always run `pnpm build` before committing + +## Agent-Specific Instructions + +- Work incrementally - one function/component at a time +- Never commit without explicit approval +- Remove obvious comments that state what code clearly does +- Follow existing patterns from previous migrations +- Maintain backward compatibility during migrations From ceef916fba04d782477edfce65e2bfc8be90aaac Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 07:13:23 +0000 Subject: [PATCH 28/43] chore: add AGENTS.md to gitignore and remove from tracking --- .gitignore | 1 + AGENTS.md | 59 ------------------------------------------------------ 2 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index cb6d28b4e..ba1bfc976 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ .ruff_cache/ .venv .pnpm-store +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f63b2c9ec..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,59 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -The repository contains three main modules: -- **`surfsense_web/`**: Next.js 15 frontend with TypeScript - - `app/`: App router pages and layouts - - `components/`: React components organized by feature - - `atoms/`: Jotai atoms for state management - - `lib/apis/`: API service layer with Zod validation - - `hooks/`: Custom React hooks (being migrated to jotai+tanstack) - - `contracts/types/`: Zod schemas and TypeScript types -- **`surfsense_backend/`**: FastAPI Python backend -- **`surfsense_browser_extension/`**: Browser extension - -## Build, Test, and Development Commands - -**Web Frontend** (from `surfsense_web/`): -```bash -pnpm install # Install dependencies -pnpm dev # Start development server -pnpm build # Production build -pnpm format # Format code with Biome -``` - -## Coding Style & Naming Conventions - -- **TypeScript**: Use tabs for indentation, arrow functions preferred -- **Files**: kebab-case (e.g., `llm-config-api.service.ts`) -- **Types**: PascalCase with Zod schemas, infer types at end of file -- **Atoms**: Descriptive names with "Atom" suffix (e.g., `documentsAtom`, `createDocumentMutationAtom`) -- **Formatting**: Biome for linting and formatting - -## Migration Pattern (Imperative to Jotai+TanStack Query) - -When migrating from imperative hooks to jotai+tanstack: -1. Create Zod schemas in `contracts/types/` -2. Create API service in `lib/apis/` -3. Add cache keys to `lib/query-client/cache-keys.ts` -4. Create query/mutation atoms in `atoms/` -5. Replace hook usage in components (maintain backward compatibility) -6. Delete old hook after all usages migrated - -**Important**: For queries needing dynamic inputs, use `useQuery` directly in components instead of atoms. - -## Commit Guidelines - -- **Format**: `type: description` (e.g., `feat: add user API service`, `fix: resolve type error`) -- **Types**: `feat`, `fix`, `refactor`, `docs`, `chore` -- **Scope**: One logical change per commit -- **Build**: Always run `pnpm build` before committing - -## Agent-Specific Instructions - -- Work incrementally - one function/component at a time -- Never commit without explicit approval -- Remove obvious comments that state what code clearly does -- Follow existing patterns from previous migrations -- Maintain backward compatibility during migrations From 7bb840b62a27b9336caaefbf83b91c1ad0a5956f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 08:19:30 +0000 Subject: [PATCH 29/43] feat: migrate updateMemberRole to handleUpdateMember using jotai + tanstack query --- .../dashboard/[search_space_id]/team/page.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 f432231f9..335245000 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -46,6 +46,8 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { updateMemberMutationAtom } from "@/atoms/members/members-mutation.atoms"; +import type { UpdateMembershipRequest, Membership } from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { @@ -157,7 +159,6 @@ export default function TeamManagementPage() { const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId); const { - updateMemberRole, removeMember, } = useMembers(searchSpaceId); @@ -167,6 +168,8 @@ export default function TeamManagementPage() { const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); + const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); + const handleUpdateRole = useCallback( async (roleId: number, data: { permissions?: string[] }): Promise => { @@ -203,6 +206,20 @@ 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 Member; + }, + [updateMember, searchSpaceId] + ); + const { data: roles = [], isLoading: rolesLoading, @@ -405,7 +422,7 @@ export default function TeamManagementPage() { members={members} roles={roles} loading={membersLoading} - onUpdateRole={updateMemberRole} + onUpdateRole={handleUpdateMember} onRemoveMember={removeMember} canManageRoles={hasPermission("members:manage_roles")} canRemove={hasPermission("members:remove")} From 6e288a59fb8d28363cc1318d61e2a2fd7d140dd4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 08:46:23 +0000 Subject: [PATCH 30/43] feat: migrate removeMember to deleteMemberMutationAtom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 24 +++++++++++++------ .../contracts/types/members.types.ts | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) 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 335245000..39b158aee 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -46,8 +46,8 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { updateMemberMutationAtom } from "@/atoms/members/members-mutation.atoms"; -import type { UpdateMembershipRequest, Membership } from "@/contracts/types/members.types"; +import { updateMemberMutationAtom, deleteMemberMutationAtom } from "@/atoms/members/members-mutation.atoms"; +import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { @@ -121,7 +121,6 @@ import { type InviteCreate, type Member, useInvites, - useMembers, useUserAccess, } from "@/hooks/use-rbac"; import { rolesApiService } from "@/lib/apis/roles-api.service"; @@ -158,9 +157,6 @@ export default function TeamManagementPage() { const [activeTab, setActiveTab] = useState("members"); const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId); - const { - removeMember, -} = useMembers(searchSpaceId); const { data: membersData = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); const members = membersData as Member[]; @@ -170,6 +166,7 @@ export default function TeamManagementPage() { const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); + const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); const handleUpdateRole = useCallback( async (roleId: number, data: { permissions?: string[] }): Promise => { @@ -220,6 +217,19 @@ export default function TeamManagementPage() { [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, @@ -423,7 +433,7 @@ export default function TeamManagementPage() { roles={roles} loading={membersLoading} onUpdateRole={handleUpdateMember} - onRemoveMember={removeMember} + onRemoveMember={handleRemoveMember} canManageRoles={hasPermission("members:manage_roles")} canRemove={hasPermission("members:remove")} /> diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index a6d6333ac..8d06cf9bd 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(), }), }); From 82b06d42c24592a21be972da145f7dd057ba32d7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 19:50:41 +0000 Subject: [PATCH 31/43] feat: migrate invites fetch query to useQuery in team page --- .../dashboard/[search_space_id]/team/page.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 39b158aee..a86da67f3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -50,11 +50,8 @@ import { updateMemberMutationAtom, deleteMemberMutationAtom } from "@/atoms/memb import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; -import { - createRoleMutationAtom, - deleteRoleMutationAtom, - updateRoleMutationAtom, -} from "@/atoms/roles/roles-mutation.atoms"; +import { invitesApiService } from '@/lib/apis/invites-api.service'; +import type { Invite } from '@/contracts/types/invites.types'; import { AlertDialog, AlertDialogAction, @@ -117,7 +114,6 @@ import type { UpdateRoleRequest, } from "@/contracts/types/roles.types"; import { - type Invite, type InviteCreate, type Member, useInvites, @@ -126,6 +122,7 @@ import { 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 = { @@ -240,13 +237,20 @@ export default function TeamManagementPage() { enabled: !!searchSpaceId, }); const { - invites, - loading: invitesLoading, - fetchInvites, createInvite, revokeInvite, } = useInvites(searchSpaceId); + const { + 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 || []; const groupedPermissions = useMemo(() => { From 15541b71c93de2e476c5b8ee0b5bb1cc3713f345 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 19:58:59 +0000 Subject: [PATCH 32/43] feat: migrate createInvite to createInviteMutationAtom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 a86da67f3..aa28ec307 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -47,6 +47,8 @@ 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 { createInviteMutationAtom } from '@/atoms/invites/invites-mutation.atoms'; +import type { CreateInviteRequest } from '@/contracts/types/invites.types'; import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; @@ -164,6 +166,18 @@ export default function TeamManagementPage() { const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom); + const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom); + + const handleCreateInvite = useCallback( + async (inviteData: InviteCreate) => { + 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 => { @@ -237,7 +251,6 @@ export default function TeamManagementPage() { enabled: !!searchSpaceId, }); const { - createInvite, revokeInvite, } = useInvites(searchSpaceId); @@ -419,7 +432,7 @@ export default function TeamManagementPage() { {activeTab === "invites" && canInvite && ( )} From 0d2f58001b453a42c2bb6005c438fe21db850672 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 20:06:42 +0000 Subject: [PATCH 33/43] feat: migrate revokeInvite to deleteInviteMutationAtom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 aa28ec307..b797b0952 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -47,8 +47,8 @@ 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 { createInviteMutationAtom } from '@/atoms/invites/invites-mutation.atoms'; -import type { CreateInviteRequest } from '@/contracts/types/invites.types'; +import { createInviteMutationAtom, deleteInviteMutationAtom } from '@/atoms/invites/invites-mutation.atoms'; +import type { CreateInviteRequest, DeleteInviteRequest } from '@/contracts/types/invites.types'; import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; @@ -167,6 +167,19 @@ export default function TeamManagementPage() { 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: InviteCreate) => { @@ -251,7 +264,6 @@ export default function TeamManagementPage() { enabled: !!searchSpaceId, }); const { - revokeInvite, } = useInvites(searchSpaceId); const { @@ -472,7 +484,7 @@ export default function TeamManagementPage() { From 18ac6bf0c5fc446753f982548101bc5960929b75 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 20:11:20 +0000 Subject: [PATCH 34/43] feat: remove unused useInvites hook references from team page --- surfsense_web/app/dashboard/[search_space_id]/team/page.tsx | 4 ---- 1 file changed, 4 deletions(-) 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 b797b0952..9a7e53e7f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -118,7 +118,6 @@ import type { import { type InviteCreate, type Member, - useInvites, useUserAccess, } from "@/hooks/use-rbac"; import { rolesApiService } from "@/lib/apis/roles-api.service"; @@ -263,9 +262,6 @@ export default function TeamManagementPage() { queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), enabled: !!searchSpaceId, }); - const { - } = useInvites(searchSpaceId); - const { data: invites = [], isLoading: invitesLoading, From bce8340750036d474634b67ad7772fa8373307d2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 20:26:37 +0000 Subject: [PATCH 35/43] feat: migrate useUserAccess to myAccessAtom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 13 +- surfsense_web/hooks/use-rbac.ts | 131 ------------------ 2 files changed, 11 insertions(+), 133 deletions(-) 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 9a7e53e7f..82f2f96e6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -47,6 +47,7 @@ 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 { CreateInviteRequest, DeleteInviteRequest } from '@/contracts/types/invites.types'; import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; @@ -118,7 +119,6 @@ import type { import { type InviteCreate, type Member, - useUserAccess, } from "@/hooks/use-rbac"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -154,7 +154,16 @@ export default function TeamManagementPage() { const searchSpaceId = Number(params.search_space_id); const [activeTab, setActiveTab] = useState("members"); - const { access, loading: accessLoading, hasPermission } = useUserAccess(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: membersData = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); const members = membersData as Member[]; diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index fa619407a..e7f85b87e 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 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); From e520e3e217b5a1ad872dcb4ccaac443ecbe3bfd2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 20:31:09 +0000 Subject: [PATCH 36/43] chore: delete unused useMembers hook from use-rbac.ts --- surfsense_web/hooks/use-rbac.ts | 122 -------------------------------- 1 file changed, 122 deletions(-) diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index e7f85b87e..b4b3693d5 100644 --- a/surfsense_web/hooks/use-rbac.ts +++ b/surfsense_web/hooks/use-rbac.ts @@ -96,128 +96,6 @@ export interface InviteInfo { // ============ 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 useUserAccess(searchSpaceId: number) { const [access, setAccess] = useState(null); const [loading, setLoading] = useState(true); From 74c7e5e7b3944c44afed2b2e1a6e7ec12c9fa32e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 20:44:07 +0000 Subject: [PATCH 37/43] feat: migrate useUserAccess to myAccessAtom in client-layout --- .../app/dashboard/[search_space_id]/client-layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From f8ec87c7f2b71040965c1cdc49ae9b6f92be6df2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 17 Dec 2025 21:01:44 +0000 Subject: [PATCH 38/43] chore: delete unused useUserAccess hook from use-rbac.ts --- surfsense_web/hooks/use-rbac.ts | 69 --------------------------------- 1 file changed, 69 deletions(-) diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index b4b3693d5..2d8e4a22d 100644 --- a/surfsense_web/hooks/use-rbac.ts +++ b/surfsense_web/hooks/use-rbac.ts @@ -96,75 +96,6 @@ export interface InviteInfo { // ============ Members 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); From 1c00e6f12edb18f100e5861466bb274ea0b16f98 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 18 Dec 2025 07:37:19 +0000 Subject: [PATCH 39/43] fix: update invite schemas to match backend and fix cache key reference --- .../app/invite/[invite_code]/page.tsx | 46 +++++++++++++++---- .../contracts/types/invites.types.ts | 5 +- 2 files changed, 41 insertions(+), 10 deletions(-) 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/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; From 9c378550baf8857dfd0e06aa3c9d4e3c3582db85 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 18 Dec 2025 07:49:39 +0000 Subject: [PATCH 40/43] refactor: complete RBAC migration cleanup - remove unused hooks and fix type imports --- .../dashboard/[search_space_id]/team/page.tsx | 29 ++- surfsense_web/hooks/index.ts | 1 - surfsense_web/hooks/use-rbac.ts | 177 ------------------ 3 files changed, 14 insertions(+), 193 deletions(-) delete mode 100644 surfsense_web/hooks/use-rbac.ts 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 82f2f96e6..1b0082186 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -49,12 +49,11 @@ 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 { CreateInviteRequest, DeleteInviteRequest } from '@/contracts/types/invites.types'; -import type { UpdateMembershipRequest, DeleteMembershipRequest, Membership } from "@/contracts/types/members.types"; +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 { membersAtom } from "@/atoms/members/members-query.atoms"; import { invitesApiService } from '@/lib/apis/invites-api.service'; -import type { Invite } from '@/contracts/types/invites.types'; import { AlertDialog, AlertDialogAction, @@ -117,9 +116,10 @@ import type { UpdateRoleRequest, } from "@/contracts/types/roles.types"; import { - type InviteCreate, - type Member, -} from "@/hooks/use-rbac"; + type Invite, + 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"; @@ -165,8 +165,7 @@ export default function TeamManagementPage() { [access] ); - const { data: membersData = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); - const members = membersData as Member[]; + const { data: members = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom); const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); @@ -190,7 +189,7 @@ export default function TeamManagementPage() { ); const handleCreateInvite = useCallback( - async (inviteData: InviteCreate) => { + async (inviteData: CreateInviteRequest['data']) => { const request: CreateInviteRequest = { search_space_id: searchSpaceId, data: inviteData, @@ -236,7 +235,7 @@ export default function TeamManagementPage() { ); const handleUpdateMember = useCallback( - async (membershipId: number, roleId: number | null): Promise => { + async (membershipId: number, roleId: number | null): Promise => { const request: UpdateMembershipRequest = { search_space_id: searchSpaceId, membership_id: membershipId, @@ -244,7 +243,7 @@ export default function TeamManagementPage() { role_id: roleId, }, }; - return await updateMember(request) as Member; + return await updateMember(request) as Membership; }, [updateMember, searchSpaceId] ); @@ -511,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; @@ -1078,7 +1077,7 @@ function CreateInviteDialog({ searchSpaceId, }: { roles: Role[]; - onCreateInvite: (data: InviteCreate) => Promise; + onCreateInvite: (data: CreateInviteRequest['data']) => Promise; searchSpaceId: number; }) { const [open, setOpen] = useState(false); @@ -1093,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/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 2d8e4a22d..000000000 --- a/surfsense_web/hooks/use-rbac.ts +++ /dev/null @@ -1,177 +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 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, - }; -} From 2d48edf4203dfd7cb0a8d358f0da52269d7740f6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 18 Dec 2025 18:41:37 +0000 Subject: [PATCH 41/43] restore gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ba1bfc976..f6e6d440b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ node_modules/ .ruff_cache/ .venv -.pnpm-store -AGENTS.md +.pnpm-store \ No newline at end of file From 3bdaf637b4fb0327796e52d2bc1f1c70f4bb3de6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 18 Dec 2025 18:42:38 +0000 Subject: [PATCH 42/43] restore gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f6e6d440b..cb6d28b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ node_modules/ .ruff_cache/ .venv -.pnpm-store \ No newline at end of file +.pnpm-store From 94c830c5ab6752e3a8e233d227ee5d8db7955b9d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 18 Dec 2025 20:10:20 +0000 Subject: [PATCH 43/43] fix appi service endpoints --- surfsense_web/contracts/types/members.types.ts | 2 +- surfsense_web/lib/apis/members-api.service.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index 8d06cf9bd..d20109b96 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -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/lib/apis/members-api.service.ts b/surfsense_web/lib/apis/members-api.service.ts index 1f8ee5bd8..10bb5da6a 100644 --- a/surfsense_web/lib/apis/members-api.service.ts +++ b/surfsense_web/lib/apis/members-api.service.ts @@ -38,7 +38,7 @@ class MembersApiService { } return baseApiService.get( - `/searchspaces/${parsedRequest.data.search_space_id}/members`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members`, getMembersResponse, ); }; @@ -57,7 +57,7 @@ class MembersApiService { } return baseApiService.put( - `/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, updateMembershipResponse, { body: parsedRequest.data.data, @@ -79,7 +79,7 @@ class MembersApiService { } return baseApiService.delete( - `/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`, deleteMembershipResponse, ); }; @@ -98,7 +98,7 @@ class MembersApiService { } return baseApiService.delete( - `/searchspaces/${parsedRequest.data.search_space_id}/members/me`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/me`, leaveSearchSpaceResponse, ); }; @@ -117,7 +117,7 @@ class MembersApiService { } return baseApiService.get( - `/searchspaces/${parsedRequest.data.search_space_id}/my-access`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/my-access`, getMyAccessResponse, ); };