-
Notifications
You must be signed in to change notification settings - Fork 42
feat: export Organisations page as a component and consume it in apps/admin #1397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
paanSinghCoder
merged 8 commits into
feat/export-users-page
from
feat/export-organisations-page
Feb 20, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f8d8f1c
feat: move organisation files to lib
paanSinghCoder 5f4ab03
Merge branch 'main' into feat/export-organisations-page
paanSinghCoder 69edf58
Merge branch 'feat/export-users-page' into feat/export-organisations-…
paanSinghCoder 56cdc84
refactor: update imports to use relative path
paanSinghCoder 957cc0f
refactor: adjust AvatarUpload import paths to use relative references
paanSinghCoder e985944
Merge branch 'feat/export-users-page' into feat/export-organisations-…
paanSinghCoder 25568b8
Merge branch 'feat/export-users-page' into feat/export-organisations-…
paanSinghCoder e02612f
chore: rename web/lib to web/sdk (#1398)
paanSinghCoder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
315 changes: 54 additions & 261 deletions
315
web/apps/admin/src/pages/organizations/details/index.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,272 +1,65 @@ | ||
| import { useEffect, useMemo, useState } from "react"; | ||
| import { | ||
| useQuery, | ||
| createConnectQueryKey, | ||
| useTransport, | ||
| } from "@connectrpc/connect-query"; | ||
| import { Outlet, useParams } from "react-router-dom"; | ||
| OrganizationDetails, | ||
| } from "@raystack/frontier/admin"; | ||
| import { useCallback, useContext, useEffect, useState } from "react"; | ||
| import { useParams } from "react-router-dom"; | ||
| import { AppContext } from "~/contexts/App"; | ||
| import { clients } from "~/connect/clients"; | ||
| import { exportCsvFromStream } from "~/utils/helper"; | ||
|
|
||
| const adminClient = clients.admin({ useBinary: true }); | ||
|
|
||
| async function loadCountries(): Promise<string[]> { | ||
| const data = await import("~/assets/data/countries.json"); | ||
| return (data.default as { name: string }[]).map((c) => c.name); | ||
| } | ||
|
|
||
| export function OrganizationDetailsPage() { | ||
| const { organizationId } = useParams<{ organizationId: string }>(); | ||
| const { config } = useContext(AppContext); | ||
| const [countries, setCountries] = useState<string[]>([]); | ||
|
|
||
| import { OrganizationDetailsLayout } from "./layout"; | ||
| import { ORG_NAMESPACE } from "./types"; | ||
| import { OrganizationContext } from "./contexts/organization-context"; | ||
| import { | ||
| FrontierServiceQueries, | ||
| GetOrganizationKycResponseSchema, | ||
| type Organization, | ||
| type User, | ||
| } from "@raystack/proton/frontier"; | ||
| import { queryClient } from "~/contexts/ConnectProvider"; | ||
| import { create } from "@bufbuild/protobuf"; | ||
|
|
||
| export const OrganizationDetails = () => { | ||
| const [isSearchVisible, setIsSearchVisible] = useState(false); | ||
| const [searchQuery, setSearchQuery] = useState(""); | ||
|
|
||
| const { organizationId } = useParams(); | ||
| const transport = useTransport(); | ||
|
|
||
| // Use Connect RPC for fetching organization | ||
| const { | ||
| data: organization, | ||
| isLoading: isOrganizationLoading, | ||
| error: organizationError, | ||
| } = useQuery( | ||
| FrontierServiceQueries.getOrganization, | ||
| { id: organizationId }, | ||
| { | ||
| enabled: !!organizationId, | ||
| select: (data) => data?.organization, | ||
| }, | ||
| ); | ||
|
|
||
| const getOrganizationQueryKey = [ | ||
| FrontierServiceQueries.getOrganization, | ||
| { id: organizationId }, | ||
| ]; | ||
|
|
||
| async function updateOrganization(org: Organization) { | ||
| queryClient.setQueryData(getOrganizationQueryKey, { organization: org }); | ||
| } | ||
|
|
||
| // Fetch KYC details | ||
| const { | ||
| data: kycData, | ||
| isLoading: isKYCLoading, | ||
| error: kycError, | ||
| } = useQuery( | ||
| FrontierServiceQueries.getOrganizationKyc, | ||
| { orgId: organizationId || "" }, | ||
| { | ||
| enabled: !!organizationId, | ||
| }, | ||
| ); | ||
|
|
||
| const kycDetails = useMemo(() => kycData?.organizationKyc, [kycData]); | ||
| useEffect(() => { | ||
| loadCountries().then(setCountries); | ||
| }, []); | ||
|
|
||
| function updateKYCDetails(kyc: typeof kycDetails) { | ||
| const onExportMembers = useCallback(async () => { | ||
| if (!organizationId) return; | ||
| queryClient.setQueryData( | ||
| createConnectQueryKey({ | ||
| schema: FrontierServiceQueries.getOrganizationKyc, | ||
| transport, | ||
| input: { orgId: organizationId }, | ||
| cardinality: "finite", | ||
| }), | ||
| create(GetOrganizationKycResponseSchema, { organizationKyc: kyc }), | ||
| await exportCsvFromStream( | ||
| adminClient.exportOrganizationUsers, | ||
| { id: organizationId }, | ||
| "organization-members.csv", | ||
| ); | ||
| } | ||
| }, [organizationId]); | ||
|
|
||
| // Fetch default roles | ||
| const { | ||
| data: defaultRoles = [], | ||
| isLoading: isDefaultRolesLoading, | ||
| error: defaultRolesError, | ||
| } = useQuery( | ||
| FrontierServiceQueries.listRoles, | ||
| { scopes: [ORG_NAMESPACE] }, | ||
| { | ||
| enabled: !!organizationId, | ||
| select: (data) => data?.roles || [], | ||
| }, | ||
| ); | ||
|
|
||
| // 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 = {}, | ||
| isLoading: isOrgMembersMapLoading, | ||
| error: orgMembersError, | ||
| } = useQuery( | ||
| FrontierServiceQueries.listOrganizationUsers, | ||
| { id: organizationId || "" }, | ||
| { | ||
| enabled: !!organizationId, | ||
| select: (data) => { | ||
| const users = data?.users || []; | ||
| return users.reduce( | ||
| (acc, user) => { | ||
| const id = user.id || ""; | ||
| acc[id] = user; | ||
| return acc; | ||
| }, | ||
| {} as Record<string, User>, | ||
| ); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| // Fetch billing accounts list | ||
| const { data: firstBillingAccountId = "", error: billingAccountsError } = | ||
| useQuery( | ||
| FrontierServiceQueries.listBillingAccounts, | ||
| { orgId: organizationId || "" }, | ||
| { | ||
| enabled: !!organizationId, | ||
| select: (data) => data?.billingAccounts?.[0]?.id || "", | ||
| }, | ||
| const onExportProjects = useCallback(async () => { | ||
| if (!organizationId) return; | ||
| await exportCsvFromStream( | ||
| adminClient.exportOrganizationProjects, | ||
| { id: organizationId }, | ||
| "organization-projects.csv", | ||
| ); | ||
| }, [organizationId]); | ||
|
|
||
| // Fetch billing account details | ||
| const { | ||
| data: billingAccountData, | ||
| isLoading: isBillingAccountLoading, | ||
| error: billingAccountError, | ||
| refetch: fetchBillingAccountDetails, | ||
| } = useQuery( | ||
| FrontierServiceQueries.getBillingAccount, | ||
| { | ||
| orgId: organizationId || "", | ||
| id: firstBillingAccountId, | ||
| withBillingDetails: true, | ||
| }, | ||
| { | ||
| enabled: !!organizationId && !!firstBillingAccountId, | ||
| select: (data) => ({ | ||
| billingAccount: data?.billingAccount, | ||
| billingAccountDetails: data?.billingDetails, | ||
| }), | ||
| }, | ||
| ); | ||
|
|
||
| const billingAccount = billingAccountData?.billingAccount; | ||
| const billingAccountDetails = billingAccountData?.billingAccountDetails; | ||
|
|
||
| // Fetch billing balance | ||
| const { | ||
| data: tokenBalance = "0", | ||
| isLoading: isTokenBalanceLoading, | ||
| error: tokenBalanceError, | ||
| refetch: fetchTokenBalance, | ||
| } = useQuery( | ||
| FrontierServiceQueries.getBillingBalance, | ||
| { | ||
| orgId: organizationId || "", | ||
| id: firstBillingAccountId, | ||
| }, | ||
| { | ||
| enabled: !!organizationId && !!firstBillingAccountId, | ||
| select: (data) => String(data?.balance?.amount || "0"), | ||
| }, | ||
| ); | ||
|
|
||
| // Error handling | ||
| useEffect(() => { | ||
| if (organizationError) { | ||
| console.error("Failed to fetch organization:", organizationError); | ||
| } | ||
| 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); | ||
| } | ||
| if (billingAccountsError) { | ||
| console.error("Failed to fetch billing accounts:", billingAccountsError); | ||
| } | ||
| if (billingAccountError) { | ||
| console.error( | ||
| "Failed to fetch billing account details:", | ||
| billingAccountError, | ||
| ); | ||
| } | ||
| if (tokenBalanceError) { | ||
| console.error("Failed to fetch token balance:", tokenBalanceError); | ||
| } | ||
| }, [ | ||
| organizationError, | ||
| kycError, | ||
| defaultRolesError, | ||
| orgRolesError, | ||
| orgMembersError, | ||
| billingAccountsError, | ||
| billingAccountError, | ||
| tokenBalanceError, | ||
| ]); | ||
| const onExportTokens = useCallback(async () => { | ||
| if (!organizationId) return; | ||
| await exportCsvFromStream( | ||
| adminClient.exportOrganizationTokens, | ||
| { id: organizationId }, | ||
| "organization-tokens.csv", | ||
| ); | ||
| }, [organizationId]); | ||
|
|
||
| const isLoading = | ||
| isOrganizationLoading || | ||
| isDefaultRolesLoading || | ||
| isOrgRolesLoading || | ||
| isBillingAccountLoading; | ||
| return ( | ||
| <OrganizationContext.Provider | ||
| value={{ | ||
| organization: organization, | ||
| updateOrganization, | ||
| roles, | ||
| billingAccount, | ||
| billingAccountDetails, | ||
| isBillingAccountLoading, | ||
| fetchBillingAccountDetails, | ||
| tokenBalance, | ||
| isTokenBalanceLoading, | ||
| fetchTokenBalance, | ||
| orgMembersMap, | ||
| isOrgMembersMapLoading, | ||
| updateKYCDetails, | ||
| kycDetails, | ||
| isKYCLoading, | ||
| search: { | ||
| isVisible: isSearchVisible, | ||
| setVisibility: setIsSearchVisible, | ||
| query: searchQuery, | ||
| onChange: setSearchQuery, | ||
| }, | ||
| }} | ||
| > | ||
| <OrganizationDetailsLayout | ||
| organization={organization} | ||
| isLoading={isLoading} | ||
| > | ||
| {organization?.id ? ( | ||
| <Outlet | ||
| context={{ | ||
| organization, | ||
| }} | ||
| /> | ||
| ) : null} | ||
| </OrganizationDetailsLayout> | ||
| </OrganizationContext.Provider> | ||
| <OrganizationDetails | ||
| organizationId={organizationId} | ||
| appUrl={config?.app_url} | ||
| tokenProductId={config?.token_product_id} | ||
| countries={countries} | ||
| organizationTypes={config?.organization_types} | ||
| onExportMembers={onExportMembers} | ||
| onExportProjects={onExportProjects} | ||
| onExportTokens={onExportTokens} | ||
| /> | ||
| ); | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Export callbacks have no error handling — failures are silent.
If
exportCsvFromStreamthrows (network error, streaming failure, etc.), the promise rejection is unhandled. No user-facing feedback is shown.🐛 Proposed fix for each callback (pattern shown for `onExportMembers`)
const onExportMembers = useCallback(async () => { if (!organizationId) return; - await exportCsvFromStream( - adminClient.exportOrganizationUsers, - { id: organizationId }, - "organization-members.csv", - ); + try { + await exportCsvFromStream( + adminClient.exportOrganizationUsers, + { id: organizationId }, + "organization-members.csv", + ); + } catch (err) { + console.error("Failed to export members:", err); + // show toast or surface error to user + } }, [organizationId]);📝 Committable suggestion