From ea55b6625e390e92e01daba2787032890201e9ae Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 2 Jun 2026 15:49:23 +0530 Subject: [PATCH] feat: single role in admin --- web/sdk/admin/assets/icons/DeleteIcon.tsx | 23 +++ .../organizations/details/members/columns.tsx | 49 +++-- .../organizations/details/members/index.tsx | 42 ++--- .../details/members/update-role.tsx | 127 +++++++++++++ .../details/projects/members/assign-role.tsx | 167 ------------------ .../details/projects/members/columns.tsx | 47 +++-- .../details/projects/members/index.tsx | 43 ++--- .../details/projects/members/update-role.tsx | 133 ++++++++++++++ 8 files changed, 382 insertions(+), 249 deletions(-) create mode 100644 web/sdk/admin/assets/icons/DeleteIcon.tsx create mode 100644 web/sdk/admin/views/organizations/details/members/update-role.tsx delete mode 100644 web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx create mode 100644 web/sdk/admin/views/organizations/details/projects/members/update-role.tsx diff --git a/web/sdk/admin/assets/icons/DeleteIcon.tsx b/web/sdk/admin/assets/icons/DeleteIcon.tsx new file mode 100644 index 0000000000..c3f6439c05 --- /dev/null +++ b/web/sdk/admin/assets/icons/DeleteIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from "react"; + +export function DeleteIcon(props: SVGProps) { + return ( + + + + ); +} + +export default DeleteIcon; diff --git a/web/sdk/admin/views/organizations/details/members/columns.tsx b/web/sdk/admin/views/organizations/details/members/columns.tsx index fbcc0130f6..23dfd343b0 100644 --- a/web/sdk/admin/views/organizations/details/members/columns.tsx +++ b/web/sdk/admin/views/organizations/details/members/columns.tsx @@ -6,14 +6,17 @@ import { Text, Menu, IconButton, + AlertDialog, } from "@raystack/apsara-v1"; import type { SearchOrganizationUsersResponse_OrganizationUser, Role, } from "@raystack/proton/frontier"; +import type { UpdateRolePayload } from "./update-role"; import styles from "./members.module.css"; import dayjs from "dayjs"; -import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; +import { DeleteIcon } from "~/admin/assets/icons/DeleteIcon"; import { isNullTimestamp, TimeStamp, @@ -28,9 +31,9 @@ const MemberStates = { interface getColumnsOptions { roles: Role[]; memberCount: number; - handleAssignRoleAction: ( - user: SearchOrganizationUsersResponse_OrganizationUser, - ) => void; + updateRoleHandle: ReturnType< + typeof AlertDialog.createHandle + >; handleRemoveMemberAction: ( user: SearchOrganizationUsersResponse_OrganizationUser, ) => void; @@ -39,7 +42,7 @@ interface getColumnsOptions { export const getColumns = ({ roles = [], memberCount, - handleAssignRoleAction, + updateRoleHandle, handleRemoveMemberAction, }: getColumnsOptions): DataTableColumnDef< SearchOrganizationUsersResponse_OrganizationUser, @@ -141,6 +144,11 @@ export const getColumns = ({ cell: ({ row }) => { // The last remaining member of an organization cannot be removed. const canRemoveMember = memberCount > 1; + const userRoleIds = row.original.roleIds || []; + // Only offer roles the member doesn't already have. + const excludedRoles = roles.filter( + (role) => role.id && !userRoleIds.includes(role.id), + ); return ( - handleAssignRoleAction(row.original)} - data-test-id="admin-assign-role-action" - > - Assign role... - + {excludedRoles.map((role) => ( + } + onClick={() => + updateRoleHandle.openWithPayload({ + user: row.original, + role, + }) + } + data-test-id={`admin-assign-role-${role.name}-action`} + > + Make {role.title} + + ))} {canRemoveMember && ( + } onClick={() => handleRemoveMemberAction(row.original)} data-test-id="admin-remove-member-action" + style={{ color: "var(--rs-color-foreground-danger-primary)" }} > - Remove... + Remove )} diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index 7de93a8477..26762582a9 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -1,4 +1,4 @@ -import { DataTable, EmptyState, Flex } from "@raystack/apsara-v1"; +import { AlertDialog, DataTable, EmptyState, Flex } from "@raystack/apsara-v1"; import type { DataTableQuery, DataTableSort } from "@raystack/apsara-v1"; import { PageTitle } from "../../../../components/PageTitle"; import styles from "./members.module.css"; @@ -15,7 +15,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { UsersIcon } from '../../../../assets/icons/UsersIcon'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { OrganizationContext } from '../contexts/organization-context'; -import { AssignRole } from '../../../../components/AssignRole'; +import { UpdateRole, type UpdateRolePayload } from './update-role'; import { RemoveMember } from './remove-member'; import { getConnectNextPageParam, @@ -24,6 +24,8 @@ import { import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { useDebounceValue } from 'usehooks-ts'; +const updateRoleDialogHandle = AlertDialog.createHandle(); + const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, @@ -78,10 +80,6 @@ export function OrganizationMembersView() { const organizationId = organization?.id || ""; - const [assignRoleConfig, setAssignRoleConfig] = useState<{ - isOpen: boolean; - user: SearchOrganizationUsersResponse_OrganizationUser | null; - }>({ isOpen: false, user: null }); const [removeMemberConfig, setRemoveMemberConfig] = useState<{ isOpen: boolean; user: SearchOrganizationUsersResponse_OrganizationUser | null; @@ -124,8 +122,9 @@ export function OrganizationMembersView() { ); const data = infiniteData?.pages?.flatMap(page => page.orgUsers) || []; - const memberCount = - infiniteData?.pages?.[0]?.pagination?.totalCount ?? data.length; + // The backend doesn't send total_count, so rely on the loaded rows. This is + // only used to prevent removing the last remaining member. + const memberCount = data.length; const loading = (isLoading || isFetchingNextPage) && !isError; const onTableQueryChange = (newQuery: DataTableQuery) => { @@ -146,16 +145,6 @@ export function OrganizationMembersView() { }; }, [setSearchVisibility, onSearchChange]); - function openAssignRoleDialog( - user: SearchOrganizationUsersResponse_OrganizationUser, - ) { - setAssignRoleConfig({ isOpen: true, user }); - } - - function closeAssignRoleDialog() { - setAssignRoleConfig({ isOpen: false, user: null }); - } - function openRemoveMemberDialog( user: SearchOrganizationUsersResponse_OrganizationUser, ) { @@ -169,7 +158,7 @@ export function OrganizationMembersView() { const columns = getColumns({ roles, memberCount, - handleAssignRoleAction: openAssignRoleDialog, + updateRoleHandle: updateRoleDialogHandle, handleRemoveMemberAction: openRemoveMemberDialog, }); @@ -185,7 +174,6 @@ export function OrganizationMembersView() { } async function updateMember() { - setAssignRoleConfig({ isOpen: false, user: null }); // Invalidate and refetch the query await invalidateMembersQuery(); } @@ -200,15 +188,11 @@ export function OrganizationMembersView() { return ( <> - {assignRoleConfig.isOpen && assignRoleConfig.user ? ( - - ) : null} + {removeMemberConfig.isOpen && removeMemberConfig.user ? ( >; + organizationId: string; + onRoleUpdate: () => void; +} + +export const UpdateRole = ({ + handle, + organizationId, + onRoleUpdate, +}: UpdateRoleProps) => { + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as UpdateRolePayload | undefined; + return payload ? ( + handle.close()} + onRoleUpdate={onRoleUpdate} + /> + ) : null; + }} + + ); +}; + +function UpdateRoleContent({ + payload, + organizationId, + onClose, + onRoleUpdate, +}: { + payload: UpdateRolePayload; + organizationId: string; + onClose: () => void; + onRoleUpdate: () => void; +}) { + const { mutateAsync: setMemberRole, isPending } = useMutation( + FrontierServiceQueries.setOrganizationMemberRole, + ); + + async function onSubmit() { + try { + await setMemberRole( + create(SetOrganizationMemberRoleRequestSchema, { + orgId: organizationId, + userId: payload.user.id, + roleId: payload.role.id, + }), + ); + + onRoleUpdate(); + toastManager.add({ + title: "Role assigned successfully", + type: "success", + }); + onClose(); + } catch (error) { + toastManager.add({ + title: "Failed to assign role", + description: error instanceof ConnectError ? error.message : undefined, + type: "error", + }); + console.error(error); + } + } + + return ( + + + Update role + + + + This will grant additional permissions to the user based on the new + role. + + + + + + + + ); +} diff --git a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx deleted file mode 100644 index f2e516388e..0000000000 --- a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - Button, - Dialog, - Flex, - Label, - Radio, - Text, - toastManager, -} from "@raystack/apsara-v1"; -import styles from "./members.module.css"; -import type { - SearchProjectUsersResponse_ProjectUser, - Role, -} from "@raystack/proton/frontier"; -import { - FrontierServiceQueries, - SetProjectMemberRoleRequestSchema, -} from "@raystack/proton/frontier"; -import { create } from "@bufbuild/protobuf"; -import { ConnectError } from "@connectrpc/connect"; -import { useMutation } from "@connectrpc/connect-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { SCOPES } from "~/admin/utils/constants"; - -interface AssignRoleProps { - projectId: string; - roles: Role[]; - user?: SearchProjectUsersResponse_ProjectUser; - onRoleUpdate: (user: SearchProjectUsersResponse_ProjectUser) => void; - onClose: () => void; -} - -const formSchema = z.object({ - roleId: z.string().min(1, "A role must be selected"), -}); - -type FormData = z.infer; - -export const AssignRole = ({ - roles = [], - user, - projectId, - onRoleUpdate, - onClose, -}: AssignRoleProps) => { - const currentRoleId = user?.roleIds?.[0] || ""; - - const { - handleSubmit, - watch, - setValue, - formState: { isSubmitting, errors, isDirty }, - } = useForm({ - defaultValues: { - roleId: currentRoleId, - }, - resolver: zodResolver(formSchema), - }); - - const { mutateAsync: setProjectMemberRole } = useMutation( - FrontierServiceQueries.setProjectMemberRole, - ); - - const selectedRoleId = watch("roleId"); - - const onSubmit = async (data: FormData) => { - try { - await setProjectMemberRole( - create(SetProjectMemberRoleRequestSchema, { - projectId, - principalId: user?.id || "", - principalType: SCOPES.USER, - roleId: data.roleId, - }), - ); - - if (onRoleUpdate) { - onRoleUpdate({ - ...user, - roleIds: [data.roleId], - } as SearchProjectUsersResponse_ProjectUser); - } - - toastManager.add({ title: "Role assigned successfully", type: "success" }); - } catch (error) { - toastManager.add({ - title: "Failed to assign role", - description: error instanceof ConnectError ? error.message : undefined, - type: "error", - }); - console.error(error); - } - }; - - return ( - - - - Assign Role - -
- - - - Taking this action may result in changes in the role which might - lead to changes in access of the user. - - - setValue("roleId", value as string, { shouldDirty: true }) - } - > - - {roles.map((role) => { - const htmlId = `role-${role.id}`; - return ( - - - - - ); - })} - {errors.roleId && ( - {errors.roleId.message} - )} - - - - - - - Cancel - - } - /> - - -
-
-
- ); -}; diff --git a/web/sdk/admin/views/organizations/details/projects/members/columns.tsx b/web/sdk/admin/views/organizations/details/projects/members/columns.tsx index e7ca551151..4ce66b1d51 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/columns.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/columns.tsx @@ -9,18 +9,23 @@ import { Flex, getAvatarColor, Text, + AlertDialog, } from "@raystack/apsara-v1"; import type { DataTableColumnDef } from "@raystack/apsara-v1"; -import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; +import { DeleteIcon } from "~/admin/assets/icons/DeleteIcon"; +import type { UpdateRolePayload } from "./update-role"; interface getColumnsOptions { roles: Role[]; - handleAssignRoleAction: (user: SearchProjectUsersResponse_ProjectUser) => void; + updateRoleHandle: ReturnType< + typeof AlertDialog.createHandle + >; handleRemoveAction: (user: SearchProjectUsersResponse_ProjectUser) => void; } export const getColumns = ({ - handleAssignRoleAction, + updateRoleHandle, handleRemoveAction, roles = [], }: getColumnsOptions): DataTableColumnDef< @@ -86,6 +91,11 @@ export const getColumns = ({ cell: styles["table-action-column"], }, cell: ({ row }) => { + const userRoleIds = row.original.roleIds || []; + // Only offer roles the member doesn't already have. + const excludedRoles = roles.filter( + (role) => role.id && !userRoleIds.includes(role.id), + ); return ( } /> @@ -96,17 +106,34 @@ export const getColumns = ({ // @ts-ignore portal={false} > + {excludedRoles.map((role) => ( + } + onClick={() => + updateRoleHandle.openWithPayload({ + user: row.original, + role, + }) + } + data-test-id={`admin-assign-role-${role.name}-action`} + > + Make {role.title} + + ))} handleAssignRoleAction(row.original)} - data-test-id="admin-assign-role-action" - > - Assign role... - - + } onClick={() => handleRemoveAction(row.original)} data-test-id="admin-remove-user-action" + style={{ color: "var(--rs-color-foreground-danger-primary)" }} > - Remove user... + Remove user diff --git a/web/sdk/admin/views/organizations/details/projects/members/index.tsx b/web/sdk/admin/views/organizations/details/projects/members/index.tsx index 8a5dbdb111..54ce6a2f78 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -1,4 +1,4 @@ -import { DataTable, Dialog, EmptyState, Flex } from "@raystack/apsara-v1"; +import { AlertDialog, DataTable, Dialog, EmptyState, Flex } from "@raystack/apsara-v1"; import type { DataTableQuery } from "@raystack/apsara-v1"; import { useCallback, useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; @@ -16,7 +16,7 @@ import { useDebouncedState } from "@raystack/apsara-v1/hooks"; import styles from "./members.module.css"; import { UsersIcon } from "../../../../../assets/icons/UsersIcon"; import { getColumns } from "./columns"; -import { AssignRole } from "./assign-role"; +import { UpdateRole, type UpdateRolePayload } from "./update-role"; import { PROJECT_NAMESPACE } from "../../types"; import { RemoveMember } from "./remove-member"; import { AddMembersDropdown } from "./add-members-dropdown"; @@ -42,6 +42,8 @@ const INITIAL_QUERY: DataTableQuery = { limit: DEFAULT_PAGE_SIZE, }; +const updateRoleDialogHandle = AlertDialog.createHandle(); + export const ProjectMembersDialog = ({ projectId, onClose, @@ -60,11 +62,6 @@ export const ProjectMembersDialog = ({ 200, ); - const [assignRoleConfig, setAssignRoleConfig] = useState<{ - isOpen: boolean; - user: SearchProjectUsersResponse_ProjectUser | null; - }>({ isOpen: false, user: null }); - const [removeMemberConfig, setRemoveMemberConfig] = useState<{ isOpen: boolean; user: SearchProjectUsersResponse_ProjectUser | null; @@ -153,17 +150,6 @@ export const ProjectMembersDialog = ({ await refetch(); } - const openAssignRoleDialog = useCallback( - (user: SearchProjectUsersResponse_ProjectUser) => { - setAssignRoleConfig({ isOpen: true, user }); - }, - [], - ); - - const closeAssignRoleDialog = useCallback(() => { - setAssignRoleConfig({ isOpen: false, user: null }); - }, []); - const openRemoveMemberDialog = useCallback( (user: SearchProjectUsersResponse_ProjectUser) => { setRemoveMemberConfig({ isOpen: true, user }); @@ -179,10 +165,10 @@ export const ProjectMembersDialog = ({ () => getColumns({ roles: projectRoles, - handleAssignRoleAction: openAssignRoleDialog, + updateRoleHandle: updateRoleDialogHandle, handleRemoveAction: openRemoveMemberDialog, }), - [projectRoles, openAssignRoleDialog, openRemoveMemberDialog], + [projectRoles, openRemoveMemberDialog], ); async function removeMember(user: SearchProjectUsersResponse_ProjectUser) { @@ -190,9 +176,8 @@ export const ProjectMembersDialog = ({ setRemoveMemberConfig({ isOpen: false, user: null }); } - async function updateMember(user: SearchProjectUsersResponse_ProjectUser) { + async function updateMember() { await refetch(); - setAssignRoleConfig({ isOpen: false, user: null }); } const loading = isMembersLoading || isFetchingNextPage; @@ -200,15 +185,11 @@ export const ProjectMembersDialog = ({ return ( <> - {assignRoleConfig.isOpen && assignRoleConfig.user ? ( - - ) : null} + {removeMemberConfig.isOpen && removeMemberConfig.user ? ( >; + projectId: string; + onRoleUpdate: () => void; +} + +export const UpdateRole = ({ + handle, + projectId, + onRoleUpdate, +}: UpdateRoleProps) => { + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as UpdateRolePayload | undefined; + return payload ? ( + handle.close()} + onRoleUpdate={onRoleUpdate} + /> + ) : null; + }} + + ); +}; + +function UpdateRoleContent({ + payload, + projectId, + onClose, + onRoleUpdate, +}: { + payload: UpdateRolePayload; + projectId: string; + onClose: () => void; + onRoleUpdate: () => void; +}) { + const { mutateAsync: setProjectMemberRole, isPending } = useMutation( + FrontierServiceQueries.setProjectMemberRole, + ); + + async function onSubmit() { + try { + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { + projectId, + principalId: payload.user.id || "", + principalType: SCOPES.USER, + roleId: payload.role.id, + }), + ); + + onRoleUpdate(); + toastManager.add({ + title: "Role assigned successfully", + type: "success", + }); + onClose(); + } catch (error) { + toastManager.add({ + title: "Failed to assign role", + description: error instanceof ConnectError ? error.message : undefined, + type: "error", + }); + console.error(error); + } + } + + return ( + + + Update role + + + + This will grant additional permissions to the user based on the new + role. + + + + + + + + ); +}