Skip to content
Open
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,
};
};
49 changes: 49 additions & 0 deletions web/sdk/admin/utils/connect-timestamp.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()),
};
}
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
95 changes: 95 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,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 (
<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>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}>Invite</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>
);
};
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