diff --git a/web/sdk/admin/hooks/useOrganizationRoles.ts b/web/sdk/admin/hooks/useOrganizationRoles.ts new file mode 100644 index 0000000000..10f0bb20d6 --- /dev/null +++ b/web/sdk/admin/hooks/useOrganizationRoles.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@connectrpc/connect-query"; +import { create } from "@bufbuild/protobuf"; +import { + FrontierServiceQueries, + ListRolesRequestSchema, + ListOrganizationRolesRequestSchema, +} 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, + { enabled = true }: UseOrganizationRolesOptions = {}, +) => { + const { + data: defaultRoles = [], + isLoading: isDefaultRolesLoading, + error: defaultRolesError, + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), + { + enabled, + select: (data) => data?.roles || [], + }, + ); + + const { + data: organizationRoles = [], + isLoading: isOrgRolesLoading, + error: orgRolesError, + } = useQuery( + FrontierServiceQueries.listOrganizationRoles, + create(ListOrganizationRolesRequestSchema, { + orgId: orgId || "", + scopes: [SCOPES.ORG], + }), + { + enabled: enabled && !!orgId, + select: (data) => data?.roles || [], + }, + ); + + useEffect(() => { + if (defaultRolesError) { + console.error("Failed to fetch default roles:", defaultRolesError); + } + if (orgRolesError) { + console.error("Failed to fetch organization roles:", orgRolesError); + } + }, [defaultRolesError, orgRolesError]); + + const roles = useMemo( + () => [...defaultRoles, ...organizationRoles], + [defaultRoles, organizationRoles], + ); + + const titleById = useMemo( + () => new Map(roles.map((role) => [role.id, role.title || role.name])), + [roles], + ); + + return { + roles, + titleById, + isLoading: isDefaultRolesLoading || isOrgRolesLoading, + error: defaultRolesError ?? orgRolesError, + }; +}; diff --git a/web/sdk/admin/utils/connect-timestamp.ts b/web/sdk/admin/utils/connect-timestamp.ts index 4c3e01f91f..78ddd3976b 100644 --- a/web/sdk/admin/utils/connect-timestamp.ts +++ b/web/sdk/admin/utils/connect-timestamp.ts @@ -1,5 +1,9 @@ import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt"; import dayjs, { type Dayjs } from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import enLocale from "dayjs/locale/en"; + +dayjs.extend(relativeTime); export function timestampToDate(timestamp?: Timestamp): Date | null { if (!timestamp) return null; @@ -30,3 +34,48 @@ export function formatTimestamp(timestamp?: Timestamp, format: string = DATE_FOR } export type TimeStamp = Timestamp; + +/* + Invite expiry wants "5 days left"; stock "en" only says "in 5 days". + - registered as local, so the shared "en" locale stays untouched + - thresholds stay default: extend() installs a plugin once, so options + passed here would lose to whichever module extends first +*/ +const INVITE_LOCALE = "en-invite"; + +dayjs.locale( + INVITE_LOCALE, + { + ...enLocale, + relativeTime: { + future: "%s left", + past: "%s ago", + s: "Less than an hour", + m: "Less than an hour", + mm: "Less than an hour", + h: "1 hour", + hh: "%d hours", + d: "1 day", + dd: "%d days", + M: "1 month", + MM: "%d months", + y: "1 year", + yy: "%d years", + }, + }, + true, +); + +/** Relative expiry text plus the lapsed flag. Lapsed invites show up at all because the API never filters expires_at. */ +export function formatInviteExpiry(expiresAt?: Timestamp): { + text: string; + isExpired: boolean; +} { + const expires = timestampToDayjs(expiresAt); + if (!expires) return { text: "-", isExpired: false }; + + return { + text: expires.locale(INVITE_LOCALE).fromNow(), + isExpired: !expires.isAfter(dayjs()), + }; +} 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,23 @@ 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, + } = 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 +185,11 @@ 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); + // Invites can exist before any member does. + const showInvitesBtn = invitations.length > 0; + const onTableQueryChange = (newQuery: DataTableQuery) => { setTableQuery(newQuery); }; @@ -230,6 +265,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..f0c058cca4 --- /dev/null +++ b/web/sdk/admin/views/organizations/details/members/invited-members-columns.tsx @@ -0,0 +1,125 @@ +import { + AlertDialog, + IconButton, + Menu, + Text, + 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 }) => { + const { text, isExpired } = formatInviteExpiry(row.original.expiresAt); + return {text}; + }, + sortingFn: (a, b) => + seconds(a.original.expiresAt) - seconds(b.original.expiresAt), + enableSorting: true, + }, + { + accessorKey: "id", + header: "", + 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 + + } + /> + + + + ); +} diff --git a/web/sdk/admin/views/users/details/layout/membership-dropdown.tsx b/web/sdk/admin/views/users/details/layout/membership-dropdown.tsx index 52a5b93200..2716c7978a 100644 --- a/web/sdk/admin/views/users/details/layout/membership-dropdown.tsx +++ b/web/sdk/admin/views/users/details/layout/membership-dropdown.tsx @@ -4,14 +4,9 @@ import { useMemo, useState } from "react"; import { type SearchUserOrganizationsResponse_UserOrganization, SearchOrganizationUsersResponse_OrganizationUserSchema, - type Role, - FrontierServiceQueries, - ListRolesRequestSchema, - ListOrganizationRolesRequestSchema, } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; -import { useQuery } from "@connectrpc/connect-query"; -import { SCOPES } from "../../../../utils/constants"; +import { useOrganizationRoles } from "~/admin/hooks/useOrganizationRoles"; import { AssignRole } from "../../../../components/AssignRole"; import { useUser } from "../user-context"; import { SuspendUser } from "./suspend-user"; @@ -29,40 +24,7 @@ export const MembershipDropdown = ({ const [isSuspendDialogOpen, setIsSuspendDialogOpen] = useState(false); const { user } = useUser(); - const { data: defaultRoles = [], isLoading: isDefaultRolesLoading, error: defaultRolesError } = useQuery( - FrontierServiceQueries.listRoles, - create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), - { - select: (data) => data?.roles || [], - } - ); - - const { data: organizationRoles = [], isLoading: isOrgRolesLoading, error: orgRolesError } = useQuery( - FrontierServiceQueries.listOrganizationRoles, - create(ListOrganizationRolesRequestSchema, { - orgId: data?.orgId || "", - scopes: [SCOPES.ORG], - }), - { - enabled: !!data?.orgId, - select: (data) => data?.roles || [], - } - ); - - // Log errors if they occur - if (defaultRolesError) { - console.error("Failed to fetch default roles:", defaultRolesError); - } - if (orgRolesError) { - console.error("Failed to fetch organization roles:", orgRolesError); - } - - const roles = useMemo( - () => [...defaultRoles, ...organizationRoles], - [defaultRoles, organizationRoles] - ); - - const isLoading = isDefaultRolesLoading || isOrgRolesLoading; + const { roles, isLoading } = useOrganizationRoles(data?.orgId); const toggleAssignRoleDialog = () => { setIsAssignRoleDialogOpen(value => !value); diff --git a/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx b/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx new file mode 100644 index 0000000000..23144d7fb5 --- /dev/null +++ b/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx @@ -0,0 +1,95 @@ +import { Flex, List, Text, Avatar, Skeleton } from "@raystack/apsara"; +import { useMemo } from "react"; +import { type Invitation } from "@raystack/proton/frontier"; +import styles from "./side-panel.module.css"; +import { formatInviteExpiry } from "~/admin/utils/connect-timestamp"; +import { useOrganizationLookup } from "~/admin/hooks/useOrganizationLookup"; +import { useOrganizationRoles } from "~/admin/hooks/useOrganizationRoles"; + +interface SidePanelInvitationProps { + data?: Invitation; + showTitle?: boolean; + isLoading?: boolean; +} + +export const SidePanelInvitation = ({ + data, + showTitle = false, + isLoading = false, +}: SidePanelInvitationProps) => { + // Invitation carries only org_id; react-query dedupes repeat lookups. + const { data: org } = useOrganizationLookup(data?.orgId); + + const { titleById } = useOrganizationRoles(data?.orgId); + + const roleTitles = useMemo( + () => + (data?.roleIds || []) + .map((roleId) => titleById.get(roleId)) + .filter(Boolean) + .join(", "), + [titleById, data?.roleIds], + ); + + if (isLoading) { + return ( + + + + + {[...Array(4)].map((_, index) => ( + + + + + + ))} + + ); + } + + if (!data) return null; + + const orgName = org?.title ?? org?.name ?? data.orgId; + const { text: expiryText, isExpired } = formatInviteExpiry(data.expiresAt); + + return ( + + {showTitle && Invitations} + + Name + + + + {orgName} + + + + + Role + + {roleTitles || "-"} + + + + Invite + + + {isExpired ? "Expired" : "Pending"} + + + + + Expiry + + {expiryText} + + + + ); +}; diff --git a/web/sdk/admin/views/users/details/layout/side-panel.tsx b/web/sdk/admin/views/users/details/layout/side-panel.tsx index 23fe1842ad..f2004dbf02 100644 --- a/web/sdk/admin/views/users/details/layout/side-panel.tsx +++ b/web/sdk/admin/views/users/details/layout/side-panel.tsx @@ -1,10 +1,14 @@ import { Avatar, getAvatarColor, SidePanel, Text } from "@raystack/apsara"; import { SidePanelDetails } from "./side-panel-details"; import { SidePanelMembership } from "./side-panel-membership"; +import { SidePanelInvitation } from "./side-panel-invitation"; import styles from "./side-panel.module.css"; import { getUserName } from "../../util"; import { useUser } from "../user-context"; -import { AdminServiceQueries } from "@raystack/proton/frontier"; +import { + AdminServiceQueries, + FrontierServiceQueries, +} from "@raystack/proton/frontier"; import { useQuery } from "@connectrpc/connect-query"; export const UserDetailsSidePanel = () => { @@ -28,7 +32,26 @@ export const UserDetailsSidePanel = () => { }, ); + const { + data: invitationsResponse, + isLoading: isInvitationsLoading, + error: invitationsError, + } = useQuery( + FrontierServiceQueries.listUserInvitations, + // `id` is the user's email, not their uuid — invitations are keyed by email + // since the invitee may not have an account yet. + { + id: user?.email || "", + }, + { + enabled: !!user?.email, + staleTime: 0, + refetchOnWindowFocus: false, + }, + ); + const userOrganizations = userOrganizationsResponse?.userOrganizations || []; + const invitations = invitationsResponse?.invitations || []; return ( { )) )} + {invitationsError ? ( + + Failed to load user invitations + + ) : isInvitationsLoading ? ( + + + + ) : ( + invitations?.map((invite, index) => ( + + + + )) + )} ); };