Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions web/sdk/admin/assets/icons/DeleteIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { SVGProps } from "react";

export function DeleteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M9.82667 5.99986L9.596 11.9999M6.404 11.9999L6.17333 5.99986M10.5 3.59519C11.2758 3.65526 12.0492 3.74354 12.8187 3.85986C13.0467 3.89453 13.2733 3.93119 13.5 3.97053M12.8187 3.85986L12.1067 13.1152C12.0776 13.492 11.9074 13.844 11.63 14.1007C11.3527 14.3574 10.9886 14.5 10.6107 14.4999H5.38933C5.0114 14.5 4.64735 14.3574 4.36999 14.1007C4.09262 13.844 3.92239 13.492 3.89333 13.1152L3.18133 3.85986M3.18133 3.85986C2.95333 3.89386 2.72667 3.93053 2.5 3.96986M3.18133 3.85986C3.95076 3.74354 4.72416 3.65526 5.5 3.59519M10.5 3.59519V2.98453C10.5 2.19786 9.89333 1.54186 9.10667 1.51719C8.36908 1.49362 7.63092 1.49362 6.89333 1.51719C6.10667 1.54186 5.5 2.19853 5.5 2.98453V3.59519M10.5 3.59519C8.83581 3.46658 7.16419 3.46658 5.5 3.59519"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

export default DeleteIcon;
49 changes: 37 additions & 12 deletions web/sdk/admin/views/organizations/details/members/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,9 +31,9 @@ const MemberStates = {
interface getColumnsOptions {
roles: Role[];
memberCount: number;
handleAssignRoleAction: (
user: SearchOrganizationUsersResponse_OrganizationUser,
) => void;
updateRoleHandle: ReturnType<
typeof AlertDialog.createHandle<UpdateRolePayload>
>;
handleRemoveMemberAction: (
user: SearchOrganizationUsersResponse_OrganizationUser,
) => void;
Expand All @@ -39,7 +42,7 @@ interface getColumnsOptions {
export const getColumns = ({
roles = [],
memberCount,
handleAssignRoleAction,
updateRoleHandle,
handleRemoveMemberAction,
}: getColumnsOptions): DataTableColumnDef<
SearchOrganizationUsersResponse_OrganizationUser,
Expand Down Expand Up @@ -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 (
<Menu>
<Menu.Trigger
Expand All @@ -151,18 +159,35 @@ export const getColumns = ({
}
/>
<Menu.Content className={styles["table-action-dropdown"]}>
<Menu.Item
onClick={() => handleAssignRoleAction(row.original)}
data-test-id="admin-assign-role-action"
>
Assign role...
</Menu.Item>
{excludedRoles.map((role) => (
<Menu.Item
key={role.id}
leadingIcon={<UpdateIcon />}
onClick={() =>
updateRoleHandle.openWithPayload({
user: row.original,
role,
})
}
data-test-id={`admin-assign-role-${role.name}-action`}
>
Make {role.title}
</Menu.Item>
))}
{canRemoveMember && (
<Menu.Item
leadingIcon={
<DeleteIcon
style={{
color: "var(--rs-color-foreground-danger-primary)",
}}
/>
}
onClick={() => handleRemoveMemberAction(row.original)}
data-test-id="admin-remove-member-action"
style={{ color: "var(--rs-color-foreground-danger-primary)" }}
>
Remove...
Remove
</Menu.Item>
)}
</Menu.Content>
Expand Down
42 changes: 13 additions & 29 deletions web/sdk/admin/views/organizations/details/members/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -24,6 +24,8 @@ import {
import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query';
import { useDebounceValue } from 'usehooks-ts';

const updateRoleDialogHandle = AlertDialog.createHandle<UpdateRolePayload>();

const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
) {
Expand All @@ -169,7 +158,7 @@ export function OrganizationMembersView() {
const columns = getColumns({
roles,
memberCount,
handleAssignRoleAction: openAssignRoleDialog,
updateRoleHandle: updateRoleDialogHandle,
handleRemoveMemberAction: openRemoveMemberDialog,
});

Expand All @@ -185,7 +174,6 @@ export function OrganizationMembersView() {
}

async function updateMember() {
setAssignRoleConfig({ isOpen: false, user: null });
// Invalidate and refetch the query
await invalidateMembersQuery();
}
Expand All @@ -200,15 +188,11 @@ export function OrganizationMembersView() {

return (
<>
{assignRoleConfig.isOpen && assignRoleConfig.user ? (
<AssignRole
roles={roles}
user={assignRoleConfig.user}
organizationId={organizationId}
onRoleUpdate={updateMember}
onClose={closeAssignRoleDialog}
/>
) : null}
<UpdateRole
handle={updateRoleDialogHandle}
organizationId={organizationId}
onRoleUpdate={updateMember}
/>

{removeMemberConfig.isOpen && removeMemberConfig.user ? (
<RemoveMember
Expand Down
127 changes: 127 additions & 0 deletions web/sdk/admin/views/organizations/details/members/update-role.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type {
SearchOrganizationUsersResponse_OrganizationUser,
Role,
} from "@raystack/proton/frontier";
import {
FrontierServiceQueries,
SetOrganizationMemberRoleRequestSchema,
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import { useMutation } from "@connectrpc/connect-query";
import {
AlertDialog,
Button,
Text,
toastManager,
} from "@raystack/apsara-v1";
import { ConnectError } from "@connectrpc/connect";

export type UpdateRolePayload = {
user: SearchOrganizationUsersResponse_OrganizationUser;
role: Role;
};

interface UpdateRoleProps {
handle: ReturnType<typeof AlertDialog.createHandle<UpdateRolePayload>>;
organizationId: string;
onRoleUpdate: () => void;
}

export const UpdateRole = ({
handle,
organizationId,
onRoleUpdate,
}: UpdateRoleProps) => {
return (
<AlertDialog handle={handle}>
{({ payload: rawPayload }) => {
const payload = rawPayload as UpdateRolePayload | undefined;
return payload ? (
<UpdateRoleContent
payload={payload}
organizationId={organizationId}
onClose={() => handle.close()}
onRoleUpdate={onRoleUpdate}
/>
) : null;
}}
</AlertDialog>
);
};

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 (
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Update role</AlertDialog.Title>
</AlertDialog.Header>
<AlertDialog.Body>
<Text variant="secondary">
This will grant additional permissions to the user based on the new
role.
</Text>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button
type="button"
variant="outline"
color="neutral"
onClick={onClose}
disabled={isPending}
data-test-id="assign-role-cancel-button"
>
Cancel
</Button>
<Button
type="submit"
data-test-id="assign-role-update-button"
loading={isPending}
loaderText="Updating..."
onClick={onSubmit}
>
Update
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
);
}
Loading
Loading