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
13 changes: 11 additions & 2 deletions web/sdk/admin/hooks/useOrganizationRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import {
} 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) => {
export const useOrganizationRoles = (
orgId?: string,
{ enabled = true }: UseOrganizationRolesOptions = {},
) => {
const {
data: defaultRoles = [],
isLoading: isDefaultRolesLoading,
Expand All @@ -23,6 +31,7 @@ export const useOrganizationRoles = (orgId?: string) => {
FrontierServiceQueries.listRoles,
create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }),
{
enabled,
select: (data) => data?.roles || [],
},
);
Expand All @@ -38,7 +47,7 @@ export const useOrganizationRoles = (orgId?: string) => {
scopes: [SCOPES.ORG],
}),
{
enabled: !!orgId,
enabled: enabled && !!orgId,
select: (data) => data?.roles || [],
},
);
Expand Down
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
92 changes: 88 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,24 @@ 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,
error: invitationsError,
} = useQuery(
FrontierServiceQueries.listOrganizationInvitations,
create(ListOrganizationInvitationsRequestSchema, {
orgId: organizationId,
}),
{
enabled: !!organizationId,
select: data => data?.invitations || NO_INVITATIONS,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 +186,16 @@ 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);
/*
* - an org always has at least one member, so the toolbar-less branch below
* is a safeguard, not a state to expect
* - the trigger is the count: a failed fetch leaves nothing to label, so it
* stays hidden and only logs
*/
const showInvitesBtn = invitations.length > 0;

const onTableQueryChange = (newQuery: DataTableQuery) => {
setTableQuery(newQuery);
};
Expand All @@ -165,6 +206,15 @@ export function OrganizationMembersView() {
}
};

useEffect(() => {
if (invitationsError) {
console.error(
"Failed to fetch organization invitations:",
invitationsError,
);
}
}, [invitationsError]);

useEffect(() => {
setSearchVisibility(true);
return () => {
Expand Down Expand Up @@ -230,6 +280,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 +301,32 @@ 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-invites">
{/* Expired invites come back too, so "Pending" would
overstate the count — Status labels each row. */}
{`${invitations.length} Invite${invitations.length === 1 ? "" : "s"}`}
</Button>
)}
{showToolbar && <DataTable.DisplayControls />}
</Flex>
</Flex>
)}
<DataTable.Content
emptyState={showZeroState ? <ZeroState /> : isError ? <ErrorState /> : <NoMembers />}
classNames={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
AlertDialog,
IconButton,
Menu,
type DataTableColumnDef,
} from "@raystack/apsara";
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import type { Invitation } from "@raystack/proton/frontier";
import { DeleteIcon } from "~/admin/assets/icons/DeleteIcon";
import {
formatInviteExpiry,
formatTimestamp,
type TimeStamp,
} from "~/admin/utils/connect-timestamp";
import type { RemoveInvitePayload } from "./remove-invite-dialog";
import styles from "./members.module.css";

interface GetColumnsOptions {
/** Role id → title, from useOrganizationRoles. */
roleTitleById: Map<string, string>;
removeInviteHandle: ReturnType<
typeof AlertDialog.createHandle<RemoveInvitePayload>
>;
}

const seconds = (timestamp?: TimeStamp) => Number(timestamp?.seconds ?? 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const getInvitedMembersColumns = ({
roleTitleById,
removeInviteHandle,
}: GetColumnsOptions): DataTableColumnDef<Invitation, unknown>[] => [
{
// Invitations carry no user record — user_id is the invited email.
accessorKey: "userId",
header: "Email",
classNames: {
header: styles["invites-email-column"],
cell: styles["invites-email-column"],
},
cell: ({ getValue }) => (getValue() as string) || "-",
enableSorting: true,
},
{
accessorKey: "roleIds",
header: "Role",
cell: ({ getValue }) => {
const titles = (getValue() as string[])
.map((id) => roleTitleById.get(id))
.filter(Boolean);
return titles.join(", ") || "-";
},
},
{
accessorKey: "expiresAt",
id: "status",
header: "Status",
cell: ({ row }) =>
formatInviteExpiry(row.original.expiresAt).isExpired
? "Expired"
: "Pending",
},
{
accessorKey: "createdAt",
header: "Invited on",
cell: ({ row }) => formatTimestamp(row.original.createdAt),
// Timestamps are objects, so the default comparator can't order them.
sortingFn: (a, b) =>
seconds(a.original.createdAt) - seconds(b.original.createdAt),
enableSorting: true,
},
{
accessorKey: "expiresAt",
header: "Expiry",
cell: ({ row }) => formatInviteExpiry(row.original.expiresAt).text,
sortingFn: (a, b) =>
seconds(a.original.expiresAt) - seconds(b.original.expiresAt),
enableSorting: true,
},
{
accessorKey: "id",
header: "",
// Its accessor is a string, so search would match invite ids without this.
enableGlobalFilter: false,
classNames: {
header: styles["invites-action-column"],
cell: styles["invites-action-column"],
},
cell: ({ row }) => {
// Offered on every row; only the confirmation copy differs.
const { isExpired } = formatInviteExpiry(row.original.expiresAt);

return (
<Menu>
<Menu.Trigger
render={
<IconButton
size={3}
data-test-id="admin-org-invites-action-menu">
<DotsHorizontalIcon />
</IconButton>
}
/>
<Menu.Content align="end">
<Menu.Item
leadingIcon={<DeleteIcon />}
className={styles["invites-remove-item"]}
onClick={() =>
removeInviteHandle.openWithPayload({
inviteId: row.original.id,
email: row.original.userId,
isExpired,
})
}
data-test-id="admin-org-invites-remove-action"
>
Remove
</Menu.Item>
</Menu.Content>
</Menu>
);
},
},
];
Loading
Loading