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