diff --git a/web/sdk/admin/hooks/useOrganizationRoles.ts b/web/sdk/admin/hooks/useOrganizationRoles.ts index 63d7ab7205..10f0bb20d6 100644 --- a/web/sdk/admin/hooks/useOrganizationRoles.ts +++ b/web/sdk/admin/hooks/useOrganizationRoles.ts @@ -8,13 +8,21 @@ import { } from "@raystack/proton/frontier"; import { SCOPES } from "~/admin/utils/constants"; +interface UseOrganizationRolesOptions { + /** Skip both fetches while false. Defaults to true. */ + enabled?: boolean; +} + /* Roles assignable within an org: the platform's defaults plus the org's custom ones. Both halves are needed — a role id can come from either. - react-query caches per key, so repeat callers share one fetch - pass undefined/empty to skip the org-scoped half */ -export const useOrganizationRoles = (orgId?: string) => { +export const useOrganizationRoles = ( + orgId?: string, + { enabled = true }: UseOrganizationRolesOptions = {}, +) => { const { data: defaultRoles = [], isLoading: isDefaultRolesLoading, @@ -23,6 +31,7 @@ export const useOrganizationRoles = (orgId?: string) => { FrontierServiceQueries.listRoles, create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), { + enabled, select: (data) => data?.roles || [], }, ); @@ -38,7 +47,7 @@ export const useOrganizationRoles = (orgId?: string) => { scopes: [SCOPES.ORG], }), { - enabled: !!orgId, + enabled: enabled && !!orgId, select: (data) => data?.roles || [], }, ); diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 7f4d680a96..6ef9de6a56 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -8,8 +8,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { create } from "@bufbuild/protobuf"; import { OrganizationDetailsLayout } from "./layout"; -import { ORG_NAMESPACE } from "./types"; import { OrganizationContext } from "./contexts/organization-context"; +import { useOrganizationRoles } from "~/admin/hooks/useOrganizationRoles"; import { FrontierServiceQueries, GetBillingAccountRequestSchema, @@ -112,36 +112,12 @@ export const OrganizationDetailsView = ({ ); } - // Fetch default roles - const { - data: defaultRoles = [], - isLoading: isDefaultRolesLoading, - error: defaultRolesError, - } = useQuery( - FrontierServiceQueries.listRoles, - { scopes: [ORG_NAMESPACE] }, - { - enabled: !!organizationId, - select: (data) => data?.roles || [], - }, + // Fetch roles assignable in this org (platform defaults + org custom) + const { roles, isLoading: isRolesLoading } = useOrganizationRoles( + organizationId, + { enabled: !!organizationId }, ); - // Fetch organization-specific roles - const { - data: organizationRoles = [], - isLoading: isOrgRolesLoading, - error: orgRolesError, - } = useQuery( - FrontierServiceQueries.listOrganizationRoles, - { orgId: organizationId || "", scopes: [ORG_NAMESPACE] }, - { - enabled: !!organizationId, - select: (data) => data?.roles || [], - }, - ); - - const roles = [...defaultRoles, ...organizationRoles]; - // Fetch organization members const { data: orgMembersMap = {}, @@ -226,12 +202,6 @@ export const OrganizationDetailsView = ({ if (kycError) { console.error("Failed to fetch KYC details:", kycError); } - if (defaultRolesError) { - console.error("Failed to fetch default roles:", defaultRolesError); - } - if (orgRolesError) { - console.error("Failed to fetch organization roles:", orgRolesError); - } if (orgMembersError) { console.error("Failed to fetch organization members:", orgMembersError); } @@ -250,8 +220,6 @@ export const OrganizationDetailsView = ({ }, [ organizationError, kycError, - defaultRolesError, - orgRolesError, orgMembersError, billingAccountsError, billingAccountError, @@ -259,10 +227,7 @@ export const OrganizationDetailsView = ({ ]); const isLoading = - isOrganizationLoading || - isDefaultRolesLoading || - isOrgRolesLoading || - isBillingAccountLoading; + isOrganizationLoading || isRolesLoading || isBillingAccountLoading; return ( (); +// Stable ref: a fresh [] each render would remount the invites table. +const NO_INVITATIONS: Invitation[] = []; + const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, @@ -103,6 +116,24 @@ export function OrganizationMembersView() { user: SearchOrganizationUsersResponse_OrganizationUser | null; }>({ isOpen: false, user: null }); + const [isInvitesDialogOpen, setIsInvitesDialogOpen] = useState(false); + + // Not in the dialog: the toolbar needs the count before it mounts. + const { + data: invitations = NO_INVITATIONS, + isLoading: isInvitationsLoading, + error: invitationsError, + } = useQuery( + FrontierServiceQueries.listOrganizationInvitations, + create(ListOrganizationInvitationsRequestSchema, { + orgId: organizationId, + }), + { + enabled: !!organizationId, + select: data => data?.invitations || NO_INVITATIONS, + }, + ); + const title = `${t.member({ plural: true, case: "capital" })} | ${organization?.title} | ${t.organization({ plural: true, case: "capital" })}`; const [tableQuery, setTableQuery] = useState(INITIAL_QUERY); @@ -155,6 +186,16 @@ export function OrganizationMembersView() { const showZeroState = !isLoading && !isError && !hasActiveQuery && data.length === 0; + // DataTable.Toolbar's own rule: hidden in the zero state. + const showToolbar = data.length > 0 || Boolean(tableQuery.filters?.length); + /* + * - an org always has at least one member, so the toolbar-less branch below + * is a safeguard, not a state to expect + * - the trigger is the count: a failed fetch leaves nothing to label, so it + * stays hidden and only logs + */ + const showInvitesBtn = invitations.length > 0; + const onTableQueryChange = (newQuery: DataTableQuery) => { setTableQuery(newQuery); }; @@ -165,6 +206,15 @@ export function OrganizationMembersView() { } }; + useEffect(() => { + if (invitationsError) { + console.error( + "Failed to fetch organization invitations:", + invitationsError, + ); + } + }, [invitationsError]); + useEffect(() => { setSearchVisibility(true); return () => { @@ -230,6 +280,15 @@ export function OrganizationMembersView() { onClose={closeRemoveMemberDialog} /> ) : null} + + {isInvitesDialogOpen ? ( + setIsInvitesDialogOpen(false)} + /> + ) : null} - + {/* DataTable.Toolbar takes no children, so the row is rebuilt from + its parts to seat the invites trigger left of Display. */} + {(showToolbar || showInvitesBtn) && ( + + {showToolbar && } + + {showInvitesBtn && ( + + )} + {showToolbar && } + + + )} : isError ? : } classNames={{ diff --git a/web/sdk/admin/views/organizations/details/members/invited-members-columns.tsx b/web/sdk/admin/views/organizations/details/members/invited-members-columns.tsx new file mode 100644 index 0000000000..a43f9dcff1 --- /dev/null +++ b/web/sdk/admin/views/organizations/details/members/invited-members-columns.tsx @@ -0,0 +1,123 @@ +import { + AlertDialog, + IconButton, + Menu, + type DataTableColumnDef, +} from "@raystack/apsara"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import type { Invitation } from "@raystack/proton/frontier"; +import { DeleteIcon } from "~/admin/assets/icons/DeleteIcon"; +import { + formatInviteExpiry, + formatTimestamp, + type TimeStamp, +} from "~/admin/utils/connect-timestamp"; +import type { RemoveInvitePayload } from "./remove-invite-dialog"; +import styles from "./members.module.css"; + +interface GetColumnsOptions { + /** Role id → title, from useOrganizationRoles. */ + roleTitleById: Map; + removeInviteHandle: ReturnType< + typeof AlertDialog.createHandle + >; +} + +const seconds = (timestamp?: TimeStamp) => Number(timestamp?.seconds ?? 0); + +export const getInvitedMembersColumns = ({ + roleTitleById, + removeInviteHandle, +}: GetColumnsOptions): DataTableColumnDef[] => [ + { + // Invitations carry no user record — user_id is the invited email. + accessorKey: "userId", + header: "Email", + classNames: { + header: styles["invites-email-column"], + cell: styles["invites-email-column"], + }, + cell: ({ getValue }) => (getValue() as string) || "-", + enableSorting: true, + }, + { + accessorKey: "roleIds", + header: "Role", + cell: ({ getValue }) => { + const titles = (getValue() as string[]) + .map((id) => roleTitleById.get(id)) + .filter(Boolean); + return titles.join(", ") || "-"; + }, + }, + { + accessorKey: "expiresAt", + id: "status", + header: "Status", + cell: ({ row }) => + formatInviteExpiry(row.original.expiresAt).isExpired + ? "Expired" + : "Pending", + }, + { + accessorKey: "createdAt", + header: "Invited on", + cell: ({ row }) => formatTimestamp(row.original.createdAt), + // Timestamps are objects, so the default comparator can't order them. + sortingFn: (a, b) => + seconds(a.original.createdAt) - seconds(b.original.createdAt), + enableSorting: true, + }, + { + accessorKey: "expiresAt", + header: "Expiry", + cell: ({ row }) => formatInviteExpiry(row.original.expiresAt).text, + sortingFn: (a, b) => + seconds(a.original.expiresAt) - seconds(b.original.expiresAt), + enableSorting: true, + }, + { + accessorKey: "id", + header: "", + // Its accessor is a string, so search would match invite ids without this. + enableGlobalFilter: false, + classNames: { + header: styles["invites-action-column"], + cell: styles["invites-action-column"], + }, + cell: ({ row }) => { + // Offered on every row; only the confirmation copy differs. + const { isExpired } = formatInviteExpiry(row.original.expiresAt); + + return ( + + + + + } + /> + + } + className={styles["invites-remove-item"]} + onClick={() => + removeInviteHandle.openWithPayload({ + inviteId: row.original.id, + email: row.original.userId, + isExpired, + }) + } + data-test-id="admin-org-invites-remove-action" + > + Remove + + + + ); + }, + }, + ]; diff --git a/web/sdk/admin/views/organizations/details/members/invited-members-dialog.tsx b/web/sdk/admin/views/organizations/details/members/invited-members-dialog.tsx new file mode 100644 index 0000000000..8f9cf8bd7c --- /dev/null +++ b/web/sdk/admin/views/organizations/details/members/invited-members-dialog.tsx @@ -0,0 +1,123 @@ +import { useMemo, useState } from "react"; +import { + AlertDialog, + Button, + DataTable, + Dialog, + EmptyState, + Flex, + type DataTableSort, +} from "@raystack/apsara"; +import type { Invitation } from "@raystack/proton/frontier"; +import { UsersIcon } from "~/admin/assets/icons/UsersIcon"; +import { useOrganizationRoles } from "~/admin/hooks/useOrganizationRoles"; +import { useTerminology } from "~/admin/hooks/useTerminology"; +import { InviteUsersDialog } from "../layout/invite-users-dialog"; +import { getInvitedMembersColumns } from "./invited-members-columns"; +import { + RemoveInviteDialog, + type RemoveInvitePayload, +} from "./remove-invite-dialog"; +import styles from "./members.module.css"; + +const removeInviteHandle = AlertDialog.createHandle(); + +const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" }; + +interface InvitedMembersDialogProps { + organizationId: string; + invitations: Invitation[]; + isLoading: boolean; + onClose: () => void; +} + +const NoInvites = () => ( + } + /> +); + +export const InvitedMembersDialog = ({ + organizationId, + invitations, + isLoading, + onClose, +}: InvitedMembersDialogProps) => { + const t = useTerminology(); + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); + const { titleById } = useOrganizationRoles(organizationId); + + const columns = useMemo( + () => + getInvitedMembersColumns({ + roleTitleById: titleById, + removeInviteHandle, + }), + [titleById], + ); + + return ( + <> + {isInviteDialogOpen ? ( + + ) : null} + + + + + Invited {t.member({ plural: true })} + + + + {/* Client mode: the list API takes no query. */} + + + + {/* Sized via `width`; className lands on the inner input. */} + + + + } + classNames={{ + root: styles["invites-table-scroll"], + table: styles["table"], + header: styles["table-header"], + }} + /> + + + + + + + ); +}; diff --git a/web/sdk/admin/views/organizations/details/members/members.module.css b/web/sdk/admin/views/organizations/details/members/members.module.css index 1c2fda1e25..df0436e698 100644 --- a/web/sdk/admin/views/organizations/details/members/members.module.css +++ b/web/sdk/admin/views/organizations/details/members/members.module.css @@ -16,7 +16,7 @@ } .zero-state-container { - padding: var(--rs-space-17) var(--rs-space-10) var(--rs-space-10); + padding: var(--rs-space-17) var(--rs-space-10) var(--rs-space-10); } .table { @@ -39,13 +39,65 @@ width: var(--rs-space-12); } -.table-action-column > * { +/* Mirrors Apsara's own toolbar row, which we rebuild. */ +.toolbar { + align-self: stretch; + background: var(--rs-color-background-base-primary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + padding: var(--rs-space-3) var(--rs-space-7) var(--rs-space-3) var(--rs-space-5); +} + +/* Design's proportions of the 1440x1024 frame; long lists scroll the rows + while the header and search row stay put. */ +.invites-dialog-content { + width: 80vw; + height: 78vh; + display: flex; + flex-direction: column; +} + +.invites-dialog-body { + display: flex; + /* min-height:0 lets the scroll container shrink instead of overflowing. */ + min-height: 0; + flex: 1; + padding: var(--rs-space-5); +} + +.invites-table-wrapper { + width: 100%; + min-height: 0; + flex: 1; +} + +.invites-table-scroll { + min-height: 0; + flex: 1; + overflow: auto; +} + +.invites-action-column { + width: var(--rs-space-12); +} + +/* Cell colours its own leading-icon wrapper, so the icon needs the override. */ +.invites-remove-item, +.invites-remove-item svg { + color: var(--rs-color-foreground-danger-primary); +} + +/* table-layout:fixed splits columns evenly; emails need more. */ +.invites-email-column { + width: calc(100% / 3); +} + +.table-action-column>* { opacity: 0; transition: opacity 120ms ease-in-out; } -.table-wrapper tr:hover .table-action-column > *, -.table-action-column:focus-within > *, -.table-action-column:has([data-popup-open]) > * { +.table-wrapper tr:hover .table-action-column>*, +.table-action-column:focus-within>*, +.table-action-column:has([data-popup-open])>* { opacity: 1; -} +} \ No newline at end of file diff --git a/web/sdk/admin/views/organizations/details/members/remove-invite-dialog.tsx b/web/sdk/admin/views/organizations/details/members/remove-invite-dialog.tsx new file mode 100644 index 0000000000..045953b30e --- /dev/null +++ b/web/sdk/admin/views/organizations/details/members/remove-invite-dialog.tsx @@ -0,0 +1,152 @@ +import { create } from "@bufbuild/protobuf"; +import { + useMutation, + createConnectQueryKey, + useTransport, +} from "@connectrpc/connect-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { + FrontierServiceQueries, + DeleteOrganizationInvitationRequestSchema, +} from "@raystack/proton/frontier"; +import { AlertDialog, Button, toastManager } from "@raystack/apsara"; +import { handleConnectError } from "~/utils/error"; + +export type RemoveInvitePayload = { + inviteId: string; + email: string; + isExpired: boolean; +}; + +const DESCRIPTION = { + pending: (email: string) => + `The invitation for ${email} will be revoked and the link in their email will stop working.`, + expired: (email: string) => + `The invitation sent to ${email} has already expired. Removing it only clears the entry from this list.`, +}; + +interface RemoveInviteDialogProps { + handle: ReturnType>; + organizationId: string; +} + +export const RemoveInviteDialog = ({ + handle, + organizationId, +}: RemoveInviteDialogProps) => { + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as RemoveInvitePayload | undefined; + return payload ? ( + handle.close()} + /> + ) : null; + }} + + ); +}; + +function RemoveInviteContent({ + payload, + organizationId, + onClose, +}: { + payload: RemoveInvitePayload; + organizationId: string; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const transport = useTransport(); + + const { mutateAsync: deleteInvitation, isPending } = useMutation( + FrontierServiceQueries.deleteOrganizationInvitation, + ); + + async function onRemove() { + try { + await deleteInvitation( + create(DeleteOrganizationInvitationRequestSchema, { + orgId: organizationId, + id: payload.inviteId, + }), + ); + } catch (error) { + console.error(error); + handleConnectError(error, { + NotFound: () => + toastManager.add({ + title: "Invitation no longer exists", + type: "error", + }), + PermissionDenied: () => + toastManager.add({ + title: "You don't have permission to perform this action", + type: "error", + }), + Default: err => + toastManager.add({ + title: "Something went wrong", + description: err.rawMessage, + type: "error", + }), + }); + return; + } + + toastManager.add({ title: "Invitation removed", type: "success" }); + onClose(); + + // Outside the try: a failing refetch mustn't read as a failed delete. + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listOrganizationInvitations, + transport, + input: { orgId: organizationId }, + cardinality: "finite", + }), + }); + } + + return ( + + + Remove invitation + + {payload.isExpired + ? DESCRIPTION.expired(payload.email) + : DESCRIPTION.pending(payload.email)} + + + + + Cancel + + } + /> + + + + ); +}