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/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/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) => (
+
+
+
+ ))
+ )}
);
};