Merge pull request #593 from CREDO23/feat/add-jotai-tanstack-rbac-members-invites

[Feat] Members & Invites | Add jotai & tanstack
This commit is contained in:
Rohan Verma 2025-12-18 16:26:04 -08:00 committed by GitHub
commit e48aa3f1c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 640 additions and 547 deletions

View file

@ -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

View file

@ -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);

View file

@ -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);

View 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");
},
}));

View 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),
});
},
};
});

View 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");
},
};
});

View 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),
});
},
};
});

View file

@ -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>;

View file

@ -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()),

View file

@ -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";

View file

@ -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,
};
}

View 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();

View 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();

View file

@ -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,
},
}; };