Skip to content
80 changes: 80 additions & 0 deletions web/sdk/admin/hooks/useOrganizationRoles.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
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()),
};
}
47 changes: 6 additions & 41 deletions web/sdk/admin/views/organizations/details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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);
}
Expand All @@ -250,19 +220,14 @@ export const OrganizationDetailsView = ({
}, [
organizationError,
kycError,
defaultRolesError,
orgRolesError,
orgMembersError,
billingAccountsError,
billingAccountError,
tokenBalanceError,
]);

const isLoading =
isOrganizationLoading ||
isDefaultRolesLoading ||
isOrgRolesLoading ||
isBillingAccountLoading;
isOrganizationLoading || isRolesLoading || isBillingAccountLoading;
return (
<OrganizationContext.Provider
value={{
Expand Down
75 changes: 71 additions & 4 deletions web/sdk/admin/views/organizations/details/members/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { AlertDialog, DataTable, EmptyState, Flex } from "@raystack/apsara";
import { AlertDialog, Button, DataTable, EmptyState, Flex } from "@raystack/apsara";
import type { DataTableQuery, DataTableSort } from "@raystack/apsara";
import { PageTitle } from "~/admin/components/PageTitle";
import styles from "./members.module.css";
import { useContext, useEffect, useMemo, useState } from "react";
import { getColumns } from "./columns";
import type { SearchOrganizationUsersResponse_OrganizationUser } from "@raystack/proton/frontier";
import { AdminServiceQueries } from "@raystack/proton/frontier";
import type {
Invitation,
SearchOrganizationUsersResponse_OrganizationUser,
} from "@raystack/proton/frontier";
import {
AdminServiceQueries,
FrontierServiceQueries,
ListOrganizationInvitationsRequestSchema,
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import {
useInfiniteQuery,
useQuery,
createConnectQueryKey,
useTransport
} from '@connectrpc/connect-query';
Expand All @@ -24,9 +33,13 @@ import {
import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query';
import { useDebouncedValue } from '~hooks';
import { useTerminology } from "~/admin/hooks/useTerminology";
import { InvitedMembersDialog } from './invited-members-dialog';

const updateRoleDialogHandle = AlertDialog.createHandle<UpdateRolePayload>();

// 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,
Expand Down Expand Up @@ -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,
},
);
Comment on lines +121 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not render an invitation-query failure as an empty list.

When this query fails, invitations becomes NO_INVITATIONS. showInvitesBtn then hides the only pending-invites entry point. Render an error and retry state, and do not report that no invitations exist until the query succeeds.


const title = `${t.member({ plural: true, case: "capital" })} | ${organization?.title} | ${t.organization({ plural: true, case: "capital" })}`;

const [tableQuery, setTableQuery] = useState<DataTableQuery>(INITIAL_QUERY);
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -230,6 +265,15 @@ export function OrganizationMembersView() {
onClose={closeRemoveMemberDialog}
/>
) : null}

{isInvitesDialogOpen ? (
<InvitedMembersDialog
organizationId={organizationId}
invitations={invitations}
isLoading={isInvitationsLoading}
onClose={() => setIsInvitesDialogOpen(false)}
/>
) : null}
<Flex justify="center" className={styles["container"]}>
<PageTitle title={title} />
<DataTable
Expand All @@ -242,7 +286,30 @@ export function OrganizationMembersView() {
onLoadMore={fetchMore}
query={tableQuery}>
<Flex direction="column" style={{ width: "100%" }}>
<DataTable.Toolbar />
{/* DataTable.Toolbar takes no children, so the row is rebuilt from
its parts to seat the invites trigger left of Display. */}
{(showToolbar || showInvitesBtn) && (
<Flex
justify={showToolbar ? "between" : "end"}
align="start"
gap={3}
className={styles["toolbar"]}>
{showToolbar && <DataTable.Filters />}
<Flex align="center" gap={3}>
{showInvitesBtn && (
<Button
variant="text"
color="neutral"
size="small"
onClick={() => setIsInvitesDialogOpen(true)}
data-test-id="admin-org-members-pending-invites">
{invitations.length} Pending invite
</Button>
)}
{showToolbar && <DataTable.DisplayControls />}
</Flex>
</Flex>
)}
<DataTable.Content
emptyState={showZeroState ? <ZeroState /> : isError ? <ErrorState /> : <NoMembers />}
classNames={{
Expand Down
Loading
Loading