From 15fb23570427713ef8c4d5e9d0ffa29a70c0ed47 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 16 Jul 2026 16:15:20 +0530 Subject: [PATCH 1/3] feat(admin): show org slug in detail URLs instead of UUID --- .../admin/src/pages/admins/AdminsPage.tsx | 4 +- .../src/pages/audit-logs/AuditLogsPage.tsx | 7 +++- .../src/pages/organizations/details/index.tsx | 42 +++++++++++++------ .../src/pages/organizations/list/index.tsx | 4 +- web/sdk/admin/hooks/useOrganizationLookup.ts | 21 ++++++++++ web/sdk/admin/views/admins/columns.tsx | 32 ++++++++++---- web/sdk/admin/views/admins/index.tsx | 2 +- web/sdk/admin/views/audit-logs/index.tsx | 4 +- .../views/audit-logs/sidepanel-details.tsx | 13 ++++-- .../views/audit-logs/sidepanel-list-link.tsx | 7 +++- .../organizations/details/layout/navbar.tsx | 21 +++++++++- .../admin/views/organizations/list/create.tsx | 5 ++- .../admin/views/organizations/list/index.tsx | 11 ++--- 13 files changed, 132 insertions(+), 41 deletions(-) create mode 100644 web/sdk/admin/hooks/useOrganizationLookup.ts diff --git a/web/apps/admin/src/pages/admins/AdminsPage.tsx b/web/apps/admin/src/pages/admins/AdminsPage.tsx index af33228c6..7874e65f9 100644 --- a/web/apps/admin/src/pages/admins/AdminsPage.tsx +++ b/web/apps/admin/src/pages/admins/AdminsPage.tsx @@ -8,7 +8,9 @@ export default function AdminsPage() { return ( navigate(`/${paths.organizations}/${orgId}`)} + onNavigateToOrg={(slug: string, orgId: string) => + navigate(`/${paths.organizations}/${slug}`, { state: { orgId } }) + } icon={} /> ); diff --git a/web/apps/admin/src/pages/audit-logs/AuditLogsPage.tsx b/web/apps/admin/src/pages/audit-logs/AuditLogsPage.tsx index a89e21266..093dc0e81 100644 --- a/web/apps/admin/src/pages/audit-logs/AuditLogsPage.tsx +++ b/web/apps/admin/src/pages/audit-logs/AuditLogsPage.tsx @@ -16,7 +16,12 @@ export default function AuditLogsPage() { "audit-logs.csv", ); }, []); - const onNavigate = useCallback((path: string) => navigate(path), [navigate]); + const onNavigate = useCallback( + // Forward router state (e.g. the org id) to the destination page. + (path: string, state?: { orgId?: string }) => + navigate(path, state ? { state } : undefined), + [navigate], + ); return ( diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index c7b84e8e7..e0913d513 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -1,6 +1,6 @@ -import { OrganizationDetailsView } from '@raystack/frontier/admin'; +import { OrganizationDetailsView, useAdminPaths } from '@raystack/frontier/admin'; import { useCallback, useContext, useEffect, useState } from 'react'; -import { useLocation, useNavigate, useParams, Outlet } from 'react-router-dom'; +import { useLocation, useNavigate, Outlet, Navigate } from 'react-router-dom'; import { AppContext } from '~/contexts/App'; import { clients } from '~/connect/clients'; import { exportCsvFromStream } from '~/utils/helper'; @@ -13,46 +13,62 @@ async function loadCountries(): Promise { } export default function OrganizationDetailsPage() { - const { organizationId } = useParams<{ organizationId: string }>(); + // URL shows the slug, but RPCs need the id (a disabled org's slug won't + // resolve). The id comes in via router state and we hold it across tab + // switches (which clear that state). No id (direct load/refresh) → go to list. const location = useLocation(); const navigate = useNavigate(); + const paths = useAdminPaths(); const { config } = useContext(AppContext); const [countries, setCountries] = useState([]); + const [orgId, setOrgId] = useState( + (location.state as { orgId?: string } | null)?.orgId, + ); + + // Pick up a new id when switching orgs; keep the current one on tab switches. + useEffect(() => { + const stateOrgId = (location.state as { orgId?: string } | null)?.orgId; + if (stateOrgId && stateOrgId !== orgId) setOrgId(stateOrgId); + }, [location.state, orgId]); useEffect(() => { loadCountries().then(setCountries); }, []); const onExportMembers = useCallback(async () => { - if (!organizationId) return; + if (!orgId) return; await exportCsvFromStream( adminClient.exportOrganizationUsers, - { id: organizationId }, + { id: orgId }, "organization-members.csv", ); - }, [organizationId]); + }, [orgId]); const onExportProjects = useCallback(async () => { - if (!organizationId) return; + if (!orgId) return; await exportCsvFromStream( adminClient.exportOrganizationProjects, - { id: organizationId }, + { id: orgId }, "organization-projects.csv", ); - }, [organizationId]); + }, [orgId]); const onExportTokens = useCallback(async () => { - if (!organizationId) return; + if (!orgId) return; await exportCsvFromStream( adminClient.exportOrganizationTokens, - { id: organizationId }, + { id: orgId }, "organization-tokens.csv", ); - }, [organizationId]); + }, [orgId]); + + if (!orgId) { + return ; + } return ( navigate(`/${paths.organizations}/${id}`), + // Slug in the URL, id in router state (a disabled org's slug won't resolve). + (slug: string, orgId: string) => + navigate(`/${paths.organizations}/${slug}`, { state: { orgId } }), [navigate, paths.organizations], ); diff --git a/web/sdk/admin/hooks/useOrganizationLookup.ts b/web/sdk/admin/hooks/useOrganizationLookup.ts new file mode 100644 index 000000000..f3d93d464 --- /dev/null +++ b/web/sdk/admin/hooks/useOrganizationLookup.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@connectrpc/connect-query"; +import { FrontierServiceQueries } from "@raystack/proton/frontier"; + +/** + * Fetches an organization by id — for display (its title) and to build a slug + * link. Call it with the id: a disabled org's slug no longer resolves + * server-side, so the id is the reliable key. react-query dedupes/caches repeat + * lookups of the same id across callers. + * + * Pass `undefined`/empty to disable the query (e.g. when there's no org yet). + */ +export const useOrganizationLookup = (orgId?: string) => + useQuery( + FrontierServiceQueries.getOrganization, + { id: orgId || "" }, + { + enabled: !!orgId, + staleTime: 5 * 60 * 1000, + select: (data) => data?.organization, + }, + ); diff --git a/web/sdk/admin/views/admins/columns.tsx b/web/sdk/admin/views/admins/columns.tsx index 5170814c5..569a36338 100644 --- a/web/sdk/admin/views/admins/columns.tsx +++ b/web/sdk/admin/views/admins/columns.tsx @@ -1,10 +1,32 @@ import { Button, type DataTableColumnDef } from "@raystack/apsara"; import type { ServiceUser, User } from "@raystack/proton/frontier"; import { TerminologyEntity } from "../../hooks/useTerminology"; +import { useOrganizationLookup } from "../../hooks/useOrganizationLookup"; import styles from "./admins.module.css"; +// Looks the org up by id to show its title and link by slug, not the raw UUID. +const OrgCell = ({ + orgId, + onNavigateToOrg, +}: { + orgId: string; + onNavigateToOrg?: (slug: string, orgId: string) => void; +}) => { + const { data: org } = useOrganizationLookup(orgId); + + return ( + + ); +}; + export const getColumns: (options?: { - onNavigateToOrg?: (orgId: string) => void; + onNavigateToOrg?: (slug: string, orgId: string) => void; t?: { organization: TerminologyEntity; }; @@ -50,13 +72,7 @@ export const getColumns: (options?: { cell: (info) => { const org_id = info.getValue() as string; return org_id ? ( - + ) : ( "-" ); diff --git a/web/sdk/admin/views/admins/index.tsx b/web/sdk/admin/views/admins/index.tsx index aac806354..8f88c5cba 100644 --- a/web/sdk/admin/views/admins/index.tsx +++ b/web/sdk/admin/views/admins/index.tsx @@ -24,7 +24,7 @@ const NoAdmins = () => { }; export type AdminsViewProps = { - onNavigateToOrg?: (orgId: string) => void; + onNavigateToOrg?: (slug: string, orgId: string) => void; /** Icon rendered in the page header next to the title. */ icon?: ReactNode; }; diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index bed2be3a5..09a1d788d 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -66,8 +66,8 @@ export type AuditLogsViewProps = { appName?: string; /** Callback to export audit logs as CSV with the current query filters applied. */ onExportCsv?: (query: RQLRequest) => Promise; - /** Navigation callback for links within audit log entries (e.g. to org/user pages). */ - onNavigate?: (path: string) => void; + /** Navigate to a link in an audit entry (e.g. org/user page). `state` carries the org id. */ + onNavigate?: (path: string, state?: { orgId?: string }) => void; }; export default function AuditLogsView({ appName, onExportCsv, onNavigate }: AuditLogsViewProps = {}) { diff --git a/web/sdk/admin/views/audit-logs/sidepanel-details.tsx b/web/sdk/admin/views/audit-logs/sidepanel-details.tsx index 48835a68b..4af469fa8 100644 --- a/web/sdk/admin/views/audit-logs/sidepanel-details.tsx +++ b/web/sdk/admin/views/audit-logs/sidepanel-details.tsx @@ -19,10 +19,11 @@ import { isZeroUUID } from "../../utils/helper"; import SidepanelListId from "./sidepanel-list-id"; import { useTerminology } from "../../hooks/useTerminology"; import { useAdminPaths } from "../../hooks/useAdminPaths"; +import { useOrganizationLookup } from "../../hooks/useOrganizationLookup"; type SidePanelDetailsProps = Partial & { onClose: () => void; - onNavigate?: (path: string) => void; + onNavigate?: (path: string, state?: { orgId?: string }) => void; }; type AuditSessionContext = { @@ -50,6 +51,11 @@ export default function SidePanelDetails({ const location = (session && `${session.Location.City}, ${session.Location.Country}`) || "-"; + // The record only has the org title, not the slug — look the org up (by id) + // to get its `name` for the link; fall back to the id while loading. + const isOrgLink = !!orgId && !isZeroUUID(orgId); + const { data: org } = useOrganizationLookup(isOrgLink ? orgId : undefined); + return ( diff --git a/web/sdk/admin/views/audit-logs/sidepanel-list-link.tsx b/web/sdk/admin/views/audit-logs/sidepanel-list-link.tsx index b9089e3d1..664d9e413 100644 --- a/web/sdk/admin/views/audit-logs/sidepanel-list-link.tsx +++ b/web/sdk/admin/views/audit-logs/sidepanel-list-link.tsx @@ -7,7 +7,9 @@ type SidepanelListItemLinkProps = { children: ReactNode; href: string; label: string; - onNavigate?: (path: string) => void; + onNavigate?: (path: string, state?: { orgId?: string }) => void; + /** Router state to carry along (e.g. the org id). */ + state?: { orgId?: string }; "data-test-id"?: string; }; @@ -17,6 +19,7 @@ export default function SidepanelListItemLink({ href, label, onNavigate, + state, "data-test-id": dataTestId, }: SidepanelListItemLinkProps) { if (isLink && onNavigate) { @@ -29,7 +32,7 @@ export default function SidepanelListItemLink({ color="neutral" data-test-id={dataTestId} className={styles["sidepanel-link-trigger"]} - onClick={() => onNavigate(href)}> + onClick={() => onNavigate(href, state)}> {children} diff --git a/web/sdk/admin/views/organizations/details/layout/navbar.tsx b/web/sdk/admin/views/organizations/details/layout/navbar.tsx index 4fb098213..e00272465 100644 --- a/web/sdk/admin/views/organizations/details/layout/navbar.tsx +++ b/web/sdk/admin/views/organizations/details/layout/navbar.tsx @@ -231,7 +231,13 @@ const NavLinks = ({ }) => { const t = useTerminology(); const paths = useAdminPaths(); - const basePath = `/${paths.organizations}/${organizationId}`; + // Reuse the org segment from the current URL so tab links keep the same slug + // and active-tab matching against `currentPath` works. Fall back to the prop. + const orgPrefix = `/${paths.organizations}/`; + const orgSegment = currentPath.startsWith(orgPrefix) + ? currentPath.slice(orgPrefix.length).split("/")[0] + : organizationId; + const basePath = `/${paths.organizations}/${orgSegment}`; const links = [ { name: t.member({ plural: true, case: "capital" }), path: `${basePath}/${paths.members}` }, { name: t.project({ plural: true, case: "capital" }), path: `${basePath}/${paths.projects}` }, @@ -307,13 +313,24 @@ export const OrganizationsDetailsNavabar = ({ { + e.preventDefault(); + onNavigate(`/${adminPaths.organizations}`); + }} leadingIcon={} > {t.organization({ plural: true, case: "capital" })} + {/* Navigate client-side so we don't full-reload and lose the held org id. */} { + e.preventDefault(); + onNavigate( + `/${adminPaths.organizations}/${organization?.name || organization?.id}`, + ); + }} leadingIcon={ void; + onSuccess?: (slug: string, orgId: string) => void; }; export function CreateOrganizationPanel({ @@ -138,7 +138,8 @@ export function CreateOrganizationPanel({ const orgResp = await createOrganization({ body: payload }); const organization = orgResp.organization; if (organization?.id) { - onSuccess?.(organization.id); + // Slug for the URL, id for the lookup. + onSuccess?.(organization.name || organization.id, organization.id); } } catch (err: unknown) { console.error("Unable to create new org:", err); diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index 07961ea3e..894b4df0c 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -50,8 +50,8 @@ const INITIAL_QUERY: DataTableQuery = { export type OrganizationListViewProps = { /** App name displayed in the page title (e.g. "Frontier Admin"). */ appName?: string; - /** Called when a user clicks on an organization row. Use to navigate to the org detail page. */ - onNavigateToOrg?: (id: string) => void; + /** Row click → navigate to the org. `slug` for the URL, `orgId` for the lookup. */ + onNavigateToOrg?: (slug: string, orgId: string) => void; /** Callback to export organizations list as CSV. Shown in navbar when provided. */ onExportCsv?: () => Promise; /** List of allowed organization types for filtering / creation. */ @@ -181,7 +181,8 @@ export const OrganizationListView = ({ data.length || loading ? styles["table"] : styles["table-empty"]; function onRowClick(row: SearchOrganizationsResponse_OrganizationResult) { - if (row.id && onNavigateToOrg) onNavigateToOrg(row.id); + // Slug for the URL, id for the lookup. + if (row.id && onNavigateToOrg) onNavigateToOrg(row.name || row.id, row.id); } return ( <> @@ -191,9 +192,9 @@ export const OrganizationListView = ({ organizationTypes={organizationTypes} appUrl={appUrl} countries={countries} - onSuccess={(id) => { + onSuccess={(slug, orgId) => { closeCreateOrgPanel(); - onNavigateToOrg?.(id); + onNavigateToOrg?.(slug, orgId); }} /> From 1b262a8a8efa5023475814b4cea1b63694b54893 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 16 Jul 2026 16:25:31 +0530 Subject: [PATCH 2/3] fixed comment --- web/sdk/admin/views/admins/columns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sdk/admin/views/admins/columns.tsx b/web/sdk/admin/views/admins/columns.tsx index 569a36338..58708f1b4 100644 --- a/web/sdk/admin/views/admins/columns.tsx +++ b/web/sdk/admin/views/admins/columns.tsx @@ -4,7 +4,7 @@ import { TerminologyEntity } from "../../hooks/useTerminology"; import { useOrganizationLookup } from "../../hooks/useOrganizationLookup"; import styles from "./admins.module.css"; -// Looks the org up by id to show its title and link by slug, not the raw UUID. +// The cell only has the org id — fetch the org to show its title and slug link. const OrgCell = ({ orgId, onNavigateToOrg, From 37fdcad75d6a33d07d5bb5927dfbb9910f088591 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 16 Jul 2026 16:58:29 +0530 Subject: [PATCH 3/3] fix(admin): resolve org id from URL on cold load so refresh/deep-links work --- .../src/pages/organizations/details/index.tsx | 44 ++++++++++++++----- .../organizations/details/layout/navbar.tsx | 8 +++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index e0913d513..500689ef8 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -1,9 +1,12 @@ import { OrganizationDetailsView, useAdminPaths } from '@raystack/frontier/admin'; import { useCallback, useContext, useEffect, useState } from 'react'; -import { useLocation, useNavigate, Outlet, Navigate } from 'react-router-dom'; +import { useLocation, useNavigate, useParams, Outlet, Navigate } from 'react-router-dom'; +import { useQuery } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { AppContext } from '~/contexts/App'; import { clients } from '~/connect/clients'; import { exportCsvFromStream } from '~/utils/helper'; +import LoadingState from '~/components/states/Loading'; const adminClient = clients.admin({ useBinary: true }); @@ -13,23 +16,38 @@ async function loadCountries(): Promise { } export default function OrganizationDetailsPage() { - // URL shows the slug, but RPCs need the id (a disabled org's slug won't - // resolve). The id comes in via router state and we hold it across tab - // switches (which clear that state). No id (direct load/refresh) → go to list. + /* + * RPCs run by id: + * - in-app nav passes the id via router state (fast path, works for any org) + * - cold load (refresh/bookmark) → resolve the URL id/slug instead + * - a disabled org's slug can't resolve → redirect to the list + */ + const { organizationId: urlParam } = useParams<{ organizationId: string }>(); const location = useLocation(); const navigate = useNavigate(); const paths = useAdminPaths(); const { config } = useContext(AppContext); const [countries, setCountries] = useState([]); - const [orgId, setOrgId] = useState( + + const [stateOrgId, setStateOrgId] = useState( (location.state as { orgId?: string } | null)?.orgId, ); - // Pick up a new id when switching orgs; keep the current one on tab switches. + // New id on org switch; keep the current one on tab switches. useEffect(() => { - const stateOrgId = (location.state as { orgId?: string } | null)?.orgId; - if (stateOrgId && stateOrgId !== orgId) setOrgId(stateOrgId); - }, [location.state, orgId]); + const next = (location.state as { orgId?: string } | null)?.orgId; + if (next && next !== stateOrgId) setStateOrgId(next); + }, [location.state, stateOrgId]); + + // Cold-load fallback: resolve from the URL only when state has no id. + const needsResolve = !stateOrgId && !!urlParam; + const { data: resolvedId, error: resolveError } = useQuery( + FrontierServiceQueries.getOrganization, + { id: urlParam || "" }, + { enabled: needsResolve, select: (data) => data?.organization?.id }, + ); + + const orgId = stateOrgId || resolvedId; useEffect(() => { loadCountries().then(setCountries); @@ -62,10 +80,16 @@ export default function OrganizationDetailsPage() { ); }, [orgId]); - if (!orgId) { + // No id, or the URL didn't resolve → list. + if (!urlParam || resolveError) { return ; } + // Still resolving the cold-load id — show a loader, don't flash a redirect. + if (!orgId) { + return ; + } + return ( { const t = useTerminology(); const paths = useAdminPaths(); - // Reuse the org segment from the current URL so tab links keep the same slug - // and active-tab matching against `currentPath` works. Fall back to the prop. + /* + * Reuse the org segment from the current URL so that: + * - tab links keep the same slug + * - active-tab matching against `currentPath` works + * Fall back to the prop before the path is available. + */ const orgPrefix = `/${paths.organizations}/`; const orgSegment = currentPath.startsWith(orgPrefix) ? currentPath.slice(orgPrefix.length).split("/")[0]