From 9b8c9876e9d64f3333d7e5367c94f2a6d689f2bb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 4 Aug 2026 16:55:06 +0530 Subject: [PATCH 1/2] feat(admin): list pending invites in user details side panel and extract org role fetching into useOrganizationRoles --- web/sdk/admin/hooks/useOrganizationRoles.ts | 71 ++++++++++ .../details/layout/membership-dropdown.tsx | 42 +----- .../details/layout/side-panel-invitation.tsx | 133 ++++++++++++++++++ .../views/users/details/layout/side-panel.tsx | 40 +++++- 4 files changed, 245 insertions(+), 41 deletions(-) create mode 100644 web/sdk/admin/hooks/useOrganizationRoles.ts create mode 100644 web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx diff --git a/web/sdk/admin/hooks/useOrganizationRoles.ts b/web/sdk/admin/hooks/useOrganizationRoles.ts new file mode 100644 index 0000000000..63d7ab7205 --- /dev/null +++ b/web/sdk/admin/hooks/useOrganizationRoles.ts @@ -0,0 +1,71 @@ +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"; + +/* + 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) => { + 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: orgId || "", + scopes: [SCOPES.ORG], + }), + { + 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/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..c9db47a596 --- /dev/null +++ b/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx @@ -0,0 +1,133 @@ +import { Flex, List, Text, Avatar, Skeleton } from "@raystack/apsara"; +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { type Invitation } from "@raystack/proton/frontier"; +import styles from "./side-panel.module.css"; +import { + timestampToDayjs, + type TimeStamp, +} 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; +} + +/* + Relative expiry text mirrored around now: + - live → "5 days left", lapsed → "5 days ago" + - diffs forwards either way, swapping only the suffix, so both sides stay in phase + - lapsed invites show up at all because the API never filters expires_at +*/ +function formatExpiry(expiresAt?: TimeStamp): { + text: string; + isExpired: boolean; +} { + const expires = timestampToDayjs(expiresAt); + if (!expires) return { text: "-", isExpired: false }; + + const now = dayjs(); + const isExpired = !expires.isAfter(now); + const [from, to] = isExpired ? [expires, now] : [now, expires]; + const suffix = isExpired ? "ago" : "left"; + + const days = to.diff(from, "day"); + if (days >= 1) { + return { text: `${days} day${days === 1 ? "" : "s"} ${suffix}`, isExpired }; + } + + const hours = to.diff(from, "hour"); + if (hours >= 1) { + return { + text: `${hours} hour${hours === 1 ? "" : "s"} ${suffix}`, + isExpired, + }; + } + + return { text: `Less than an hour ${suffix}`, isExpired }; +} + +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 } = formatExpiry(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) => ( + + + + )) + )} ); }; From 54da7cf8c4218ec3f4df950bfbca037a849d2b45 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 6 Aug 2026 16:40:28 +0530 Subject: [PATCH 2/2] refactor(admin): format invite expiry with dayjs relativeTime --- web/sdk/admin/utils/connect-timestamp.ts | 49 +++++++++++++++++++ .../details/layout/side-panel-invitation.tsx | 42 +--------------- 2 files changed, 51 insertions(+), 40 deletions(-) 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/users/details/layout/side-panel-invitation.tsx b/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx index c9db47a596..23144d7fb5 100644 --- a/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx +++ b/web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx @@ -1,12 +1,8 @@ import { Flex, List, Text, Avatar, Skeleton } from "@raystack/apsara"; import { useMemo } from "react"; -import dayjs from "dayjs"; import { type Invitation } from "@raystack/proton/frontier"; import styles from "./side-panel.module.css"; -import { - timestampToDayjs, - type TimeStamp, -} from "~/admin/utils/connect-timestamp"; +import { formatInviteExpiry } from "~/admin/utils/connect-timestamp"; import { useOrganizationLookup } from "~/admin/hooks/useOrganizationLookup"; import { useOrganizationRoles } from "~/admin/hooks/useOrganizationRoles"; @@ -16,40 +12,6 @@ interface SidePanelInvitationProps { isLoading?: boolean; } -/* - Relative expiry text mirrored around now: - - live → "5 days left", lapsed → "5 days ago" - - diffs forwards either way, swapping only the suffix, so both sides stay in phase - - lapsed invites show up at all because the API never filters expires_at -*/ -function formatExpiry(expiresAt?: TimeStamp): { - text: string; - isExpired: boolean; -} { - const expires = timestampToDayjs(expiresAt); - if (!expires) return { text: "-", isExpired: false }; - - const now = dayjs(); - const isExpired = !expires.isAfter(now); - const [from, to] = isExpired ? [expires, now] : [now, expires]; - const suffix = isExpired ? "ago" : "left"; - - const days = to.diff(from, "day"); - if (days >= 1) { - return { text: `${days} day${days === 1 ? "" : "s"} ${suffix}`, isExpired }; - } - - const hours = to.diff(from, "hour"); - if (hours >= 1) { - return { - text: `${hours} hour${hours === 1 ? "" : "s"} ${suffix}`, - isExpired, - }; - } - - return { text: `Less than an hour ${suffix}`, isExpired }; -} - export const SidePanelInvitation = ({ data, showTitle = false, @@ -89,7 +51,7 @@ export const SidePanelInvitation = ({ if (!data) return null; const orgName = org?.title ?? org?.name ?? data.orgId; - const { text: expiryText, isExpired } = formatExpiry(data.expiresAt); + const { text: expiryText, isExpired } = formatInviteExpiry(data.expiresAt); return (