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
71 changes: 71 additions & 0 deletions web/sdk/admin/hooks/useOrganizationRoles.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
17 changes: 17 additions & 0 deletions web/sdk/admin/utils/connect-timestamp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
import dayjs, { type Dayjs } from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";

dayjs.extend(relativeTime);

export function timestampToDate(timestamp?: Timestamp): Date | null {
if (!timestamp) return null;
Expand Down Expand Up @@ -30,3 +33,17 @@ export function formatTimestamp(timestamp?: Timestamp, format: string = DATE_FOR
}

export type TimeStamp = Timestamp;

/** 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.fromNow(),
isExpired: !expires.isAfter(dayjs()),
};
}
42 changes: 2 additions & 40 deletions web/sdk/admin/views/users/details/layout/membership-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const SidePanelDetails = () => {

return (
<List>
<List.Header>User Details</List.Header>
<List.Header className={styles["list-header"]}>User Details</List.Header>
<List.Item>
<List.Label className={styles.listLabel}>ID</List.Label>
<List.Value>
Expand Down
97 changes: 97 additions & 0 deletions web/sdk/admin/views/users/details/layout/side-panel-invitation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 (
<List>
<Flex className={styles["loader-header"]}>
<Skeleton />
</Flex>
{[...Array(4)].map((_, index) => (
<List.Item key={index}>
<List.Value>
<Skeleton height="100%" />
</List.Value>
</List.Item>
))}
</List>
);
}

if (!data) return null;

const orgName = org?.title ?? org?.name ?? data.orgId;
const { text: expiryText, isExpired } = formatInviteExpiry(data.expiresAt);

return (
<List>
{showTitle && (
<List.Header className={styles["list-header"]}>Invitations</List.Header>
)}
<List.Item>
<List.Label className={styles.listLabel}>Name</List.Label>
<List.Value>
<Flex gap={3} align="center">
<Avatar
src={org?.avatar}
fallback={orgName?.[0]?.toUpperCase()}
size={1}
radius="full"
/>
<Text className={styles["text-overflow"]}>{orgName}</Text>
</Flex>
</List.Value>
</List.Item>
<List.Item>
<List.Label className={styles.listLabel}>Role</List.Label>
<List.Value>
<Text className={styles["text-overflow"]}>{roleTitles || "-"}</Text>
</List.Value>
</List.Item>
<List.Item>
<List.Label className={styles.listLabel}>Status</List.Label>
<List.Value>
<Text variant={isExpired ? "danger" : undefined}>
{isExpired ? "Expired" : "Pending"}
</Text>
</List.Value>
</List.Item>
<List.Item>
<List.Label className={styles.listLabel}>Expiry</List.Label>
<List.Value>
<Text>{expiryText}</Text>
</List.Value>
</List.Item>
</List>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export const SidePanelMembership = ({

return (
<List>
{showTitle && <List.Header>Membership</List.Header>}
{showTitle && (
<List.Header className={styles["list-header"]}>Membership</List.Header>
)}
<List.Item>
<List.Label className={styles.listLabel}>Name</List.Label>
<List.Value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Design wants base-primary; Apsara ships tertiary on List.Header's inner span. */
.list-header span {
color: var(--rs-color-foreground-base-primary);
}
.loader-header {
width: 100%;
height: 32px;
Expand Down
40 changes: 39 additions & 1 deletion web/sdk/admin/views/users/details/layout/side-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand All @@ -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 (
<SidePanel
Expand Down Expand Up @@ -66,6 +89,21 @@ export const UserDetailsSidePanel = () => {
</SidePanel.Section>
))
)}
{invitationsError ? (
<SidePanel.Section>
<Text variant="danger">Failed to load user invitations</Text>
</SidePanel.Section>
) : isInvitationsLoading ? (
<SidePanel.Section>
<SidePanelInvitation showTitle isLoading />
</SidePanel.Section>
) : (
invitations?.map((invite, index) => (
<SidePanel.Section key={invite.id}>
<SidePanelInvitation data={invite} showTitle={index === 0} />
</SidePanel.Section>
))
)}
</SidePanel>
);
};
Loading