mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
Merge pull request #593 from CREDO23/feat/add-jotai-tanstack-rbac-members-invites
[Feat] Members & Invites | Add jotai & tanstack
This commit is contained in:
commit
e48aa3f1c7
14 changed files with 640 additions and 547 deletions
|
|
@ -17,7 +17,7 @@ import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
|
|
@ -69,7 +69,7 @@ export function DashboardClientLayout({
|
||||||
);
|
);
|
||||||
}, [preferences]);
|
}, [preferences]);
|
||||||
|
|
||||||
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||||
|
|
||||||
// Skip onboarding check if we're already on the onboarding page
|
// Skip onboarding check if we're already on the onboarding page
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,14 @@ import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
|
||||||
import {
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
createRoleMutationAtom,
|
import { invitesApiService } from '@/lib/apis/invites-api.service';
|
||||||
deleteRoleMutationAtom,
|
|
||||||
updateRoleMutationAtom,
|
|
||||||
} from "@/atoms/roles/roles-mutation.atoms";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -115,15 +117,13 @@ import type {
|
||||||
} from "@/contracts/types/roles.types";
|
} from "@/contracts/types/roles.types";
|
||||||
import {
|
import {
|
||||||
type Invite,
|
type Invite,
|
||||||
type InviteCreate,
|
type CreateInviteRequest,
|
||||||
type Member,
|
} from "@/contracts/types/invites.types";
|
||||||
useInvites,
|
import type { Membership } from "@/contracts/types/members.types";
|
||||||
useMembers,
|
|
||||||
useUserAccess,
|
|
||||||
} from "@/hooks/use-rbac";
|
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { createRoleMutationAtom, deleteRoleMutationAtom, updateRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms";
|
||||||
|
|
||||||
// Animation variants
|
// Animation variants
|
||||||
const fadeInUp = {
|
const fadeInUp = {
|
||||||
|
|
@ -154,18 +154,50 @@ export default function TeamManagementPage() {
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const [activeTab, setActiveTab] = useState("members");
|
const [activeTab, setActiveTab] = useState("members");
|
||||||
|
|
||||||
const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId);
|
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||||
const {
|
|
||||||
members,
|
const hasPermission = useCallback(
|
||||||
loading: membersLoading,
|
(permission: string) => {
|
||||||
fetchMembers,
|
if (!access) return false;
|
||||||
updateMemberRole,
|
if (access.is_owner) return true;
|
||||||
removeMember,
|
return access.permissions?.includes(permission) ?? false;
|
||||||
} = useMembers(searchSpaceId);
|
},
|
||||||
|
[access]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: members = [], isLoading: membersLoading, refetch: fetchMembers } = useAtomValue(membersAtom);
|
||||||
|
|
||||||
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
|
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
|
||||||
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
|
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
|
||||||
const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
|
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<boolean> => {
|
||||||
|
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(
|
const handleUpdateRole = useCallback(
|
||||||
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
|
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
|
||||||
|
|
@ -202,6 +234,33 @@ export default function TeamManagementPage() {
|
||||||
[createRole, searchSpaceId]
|
[createRole, searchSpaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUpdateMember = useCallback(
|
||||||
|
async (membershipId: number, roleId: number | null): Promise<Membership> => {
|
||||||
|
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 {
|
const {
|
||||||
data: roles = [],
|
data: roles = [],
|
||||||
isLoading: rolesLoading,
|
isLoading: rolesLoading,
|
||||||
|
|
@ -212,12 +271,14 @@ export default function TeamManagementPage() {
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
invites,
|
data: invites = [],
|
||||||
loading: invitesLoading,
|
isLoading: invitesLoading,
|
||||||
fetchInvites,
|
refetch: fetchInvites,
|
||||||
createInvite,
|
} = useQuery({
|
||||||
revokeInvite,
|
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
|
||||||
} = useInvites(searchSpaceId);
|
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
|
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
|
||||||
const permissions = permissionsData?.permissions || [];
|
const permissions = permissionsData?.permissions || [];
|
||||||
|
|
@ -387,7 +448,7 @@ export default function TeamManagementPage() {
|
||||||
{activeTab === "invites" && canInvite && (
|
{activeTab === "invites" && canInvite && (
|
||||||
<CreateInviteDialog
|
<CreateInviteDialog
|
||||||
roles={roles}
|
roles={roles}
|
||||||
onCreateInvite={createInvite}
|
onCreateInvite={handleCreateInvite}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -404,8 +465,8 @@ export default function TeamManagementPage() {
|
||||||
members={members}
|
members={members}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
loading={membersLoading}
|
loading={membersLoading}
|
||||||
onUpdateRole={updateMemberRole}
|
onUpdateRole={handleUpdateMember}
|
||||||
onRemoveMember={removeMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
canManageRoles={hasPermission("members:manage_roles")}
|
canManageRoles={hasPermission("members:manage_roles")}
|
||||||
canRemove={hasPermission("members:remove")}
|
canRemove={hasPermission("members:remove")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -427,7 +488,7 @@ export default function TeamManagementPage() {
|
||||||
<InvitesTab
|
<InvitesTab
|
||||||
invites={invites}
|
invites={invites}
|
||||||
loading={invitesLoading}
|
loading={invitesLoading}
|
||||||
onRevokeInvite={revokeInvite}
|
onRevokeInvite={handleRevokeInvite}
|
||||||
canRevoke={canInvite}
|
canRevoke={canInvite}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -449,10 +510,10 @@ function MembersTab({
|
||||||
canManageRoles,
|
canManageRoles,
|
||||||
canRemove,
|
canRemove,
|
||||||
}: {
|
}: {
|
||||||
members: Member[];
|
members: Membership[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Member>;
|
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||||
canManageRoles: boolean;
|
canManageRoles: boolean;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
|
|
@ -1016,7 +1077,7 @@ function CreateInviteDialog({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
}: {
|
}: {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
onCreateInvite: (data: InviteCreate) => Promise<Invite>;
|
onCreateInvite: (data: CreateInviteRequest['data']) => Promise<Invite>;
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -1031,7 +1092,7 @@ function CreateInviteDialog({
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const data: InviteCreate = {};
|
const data: CreateInviteRequest['data'] = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (roleId && roleId !== "default") data.role_id = Number(roleId);
|
if (roleId && roleId !== "default") data.role_id = Number(roleId);
|
||||||
if (maxUses) data.max_uses = Number(maxUses);
|
if (maxUses) data.max_uses = Number(maxUses);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -16,7 +18,11 @@ import { motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -26,22 +32,46 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useInviteInfo } from "@/hooks/use-rbac";
|
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
||||||
|
|
||||||
export default function InviteAcceptPage() {
|
export default function InviteAcceptPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const inviteCode = params.invite_code as string;
|
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 [accepting, setAccepting] = useState(false);
|
||||||
const [accepted, setAccepted] = useState(false);
|
const [accepted, setAccepted] = useState(false);
|
||||||
const [acceptedData, setAcceptedData] = useState<{
|
const [acceptedData, setAcceptedData] = useState<AcceptInviteResponse | null>(null);
|
||||||
search_space_id: number;
|
|
||||||
search_space_name: string;
|
|
||||||
role_name: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal file
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal file
|
|
@ -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");
|
||||||
|
},
|
||||||
|
}));
|
||||||
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal file
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal file
|
|
@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal file
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal file
|
|
@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal file
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal file
|
|
@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -77,11 +77,10 @@ export const getInviteInfoRequest = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getInviteInfoResponse = z.object({
|
export const getInviteInfoResponse = z.object({
|
||||||
invite_code: z.string(),
|
|
||||||
search_space_name: z.string(),
|
search_space_name: z.string(),
|
||||||
role_name: z.string().nullable(),
|
role_name: z.string().nullable(),
|
||||||
expires_at: z.string().nullable(),
|
|
||||||
is_valid: z.boolean(),
|
is_valid: z.boolean(),
|
||||||
|
message: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,6 +93,8 @@ export const acceptInviteRequest = z.object({
|
||||||
export const acceptInviteResponse = z.object({
|
export const acceptInviteResponse = z.object({
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
|
search_space_name: z.string(),
|
||||||
|
role_name: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Invite = z.infer<typeof invite>;
|
export type Invite = z.infer<typeof invite>;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const updateMembershipRequest = z.object({
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
membership_id: z.number(),
|
membership_id: z.number(),
|
||||||
data: z.object({
|
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({
|
export const getMyAccessResponse = z.object({
|
||||||
user_id: z.string(),
|
search_space_name: z.string(),
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
is_owner: z.boolean(),
|
is_owner: z.boolean(),
|
||||||
permissions: z.array(z.string()),
|
permissions: z.array(z.string()),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export * from "./use-debounced-value";
|
export * from "./use-debounced-value";
|
||||||
export * from "./use-logs";
|
export * from "./use-logs";
|
||||||
export * from "./use-rbac";
|
|
||||||
export * from "./use-search-source-connectors";
|
export * from "./use-search-source-connectors";
|
||||||
|
|
|
||||||
|
|
@ -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<Member[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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<Invite[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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<UserAccess | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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<InviteInfo | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
155
surfsense_web/lib/apis/invites-api.service.ts
Normal file
155
surfsense_web/lib/apis/invites-api.service.ts
Normal file
|
|
@ -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();
|
||||||
126
surfsense_web/lib/apis/members-api.service.ts
Normal file
126
surfsense_web/lib/apis/members-api.service.ts
Normal file
|
|
@ -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();
|
||||||
|
|
@ -4,6 +4,7 @@ import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
||||||
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
||||||
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
||||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||||
|
import type { GetMembersRequest } from "@/contracts/types/members.types";
|
||||||
|
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
chats: {
|
chats: {
|
||||||
|
|
@ -52,4 +53,12 @@ export const cacheKeys = {
|
||||||
permissions: {
|
permissions: {
|
||||||
all: () => ["permissions"] as const,
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue