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
4 changes: 3 additions & 1 deletion web/apps/admin/src/pages/admins/AdminsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export default function AdminsPage() {

return (
<AdminsView
onNavigateToOrg={(orgId: string) => navigate(`/${paths.organizations}/${orgId}`)}
onNavigateToOrg={(slug: string, orgId: string) =>
navigate(`/${paths.organizations}/${slug}`, { state: { orgId } })
}
icon={<AdminsIcon />}
/>
);
Expand Down
7 changes: 6 additions & 1 deletion web/apps/admin/src/pages/audit-logs/AuditLogsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AuditLogsView onExportCsv={onExportCsv} onNavigate={onNavigate} />
Expand Down
130 changes: 116 additions & 14 deletions web/apps/admin/src/pages/organizations/details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,160 @@
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, 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 });

/* Old bookmarks use the id; new URLs use the slug — tell them apart. */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

async function loadCountries(): Promise<string[]> {
const data = await import("~/assets/data/countries.json");
return (data.default as { name: string }[]).map((c) => c.name);
}

export default function OrganizationDetailsPage() {
const { organizationId } = useParams<{ organizationId: string }>();
/*
* View runs by org id:
* - in-app nav: id via router state (fast path)
* - cold load: resolve the URL id/slug via getOrganization (accepts either)
* - old UUID URL: rewrite to the canonical slug once resolved
* - unresolvable URL: redirect to list
*/
const { organizationId: urlParam } = useParams<{ organizationId: string }>();
const location = useLocation();
const navigate = useNavigate();
const paths = useAdminPaths();
const { config } = useContext(AppContext);
const [countries, setCountries] = useState<string[]>([]);

const incomingOrgId = (location.state as { orgId?: string } | null)?.orgId;

/*
* Hold the id keyed to its URL, recomputed during render:
* - a urlParam change invalidates a stale id, re-arming the resolve
* - setState-during-render keeps children from seeing the old id (no flash,
* no stale org under a new URL)
*/
const [held, setHeld] = useState<{ param?: string; orgId?: string }>({
param: urlParam,
orgId: incomingOrgId,
});
if (held.param !== urlParam || (incomingOrgId && incomingOrgId !== held.orgId)) {
setHeld({ param: urlParam, orgId: incomingOrgId });
}
const stateOrgId = held.param === urlParam ? held.orgId : undefined;

/*
* Cold-load resolve (only when state carries no id):
* - getOrganization takes an id OR a slug and returns disabled orgs too,
* so a single call covers every URL form (server GetRaw branches on UUID)
* - a UUID param is already the id, but we still resolve to read the slug +
* state for the canonical-URL rewrite below
*/
const paramIsId = !!urlParam && UUID_RE.test(urlParam);
const needsResolve = !stateOrgId && !!urlParam;
const {
data: org,
error: resolveError,
isSuccess,
} = useQuery(
FrontierServiceQueries.getOrganization,
{ id: urlParam ?? '' },
{
enabled: needsResolve,
staleTime: 5 * 60 * 1000,
select: (d) => d?.organization,
},
);

const orgId = stateOrgId || (paramIsId ? urlParam : org?.id);
const notFound = needsResolve && isSuccess && !org?.id;

/*
* Old UUID bookmark → canonical slug URL:
* - one live URL per org; replace keeps the back-button sane
* - preserve the sub-tab (rest of the path) across the swap
* - skip disabled orgs — keep them on the stable id URL
*/
useEffect(() => {
if (paramIsId && org?.name && org.state !== 'disabled') {
const rest = location.pathname.slice(
location.pathname.indexOf(urlParam!) + urlParam!.length,
);
navigate(`/${paths.organizations}/${org.name}${rest}`, {
replace: true,
state: { orgId },
});
}
}, [
paramIsId,
org?.name,
org?.state,
orgId,
urlParam,
location.pathname,
navigate,
paths.organizations,
]);
Comment on lines +86 to +105

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 | 🟡 Minor | ⚡ Quick win

Canonical-URL rewrite drops the query string and hash.

rest is sliced from location.pathname only, so a UUID bookmark like /organizations/{id}/members?page=2 rewrites to /organizations/{slug}/members, silently losing ?page=2. Safe fallback (page still loads), but any tab relying on query-string state (filters, pagination) loses it on this one-time redirect.

Proposed fix
   useEffect(() => {
     if (paramIsId && org?.name && org.state !== 'disabled') {
-      const rest = location.pathname.slice(
-        location.pathname.indexOf(urlParam!) + urlParam!.length,
-      );
-      navigate(`/${paths.organizations}/${org.name}${rest}`, {
+      const restPath = location.pathname.slice(
+        location.pathname.indexOf(urlParam!) + urlParam!.length,
+      );
+      navigate(
+        `/${paths.organizations}/${org.name}${restPath}${location.search}${location.hash}`,
+        {
           replace: true,
           state: { orgId },
-      });
+        },
+      );
     }
   }, [
     paramIsId,
     org?.name,
     org?.state,
     orgId,
     urlParam,
     location.pathname,
+    location.search,
+    location.hash,
     navigate,
     paths.organizations,
   ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (paramIsId && org?.name && org.state !== 'disabled') {
const rest = location.pathname.slice(
location.pathname.indexOf(urlParam!) + urlParam!.length,
);
navigate(`/${paths.organizations}/${org.name}${rest}`, {
replace: true,
state: { orgId },
});
}
}, [
paramIsId,
org?.name,
org?.state,
orgId,
urlParam,
location.pathname,
navigate,
paths.organizations,
]);
useEffect(() => {
if (paramIsId && org?.name && org.state !== 'disabled') {
const restPath = location.pathname.slice(
location.pathname.indexOf(urlParam!) + urlParam!.length,
);
navigate(
`/${paths.organizations}/${org.name}${restPath}${location.search}${location.hash}`,
{
replace: true,
state: { orgId },
},
);
}
}, [
paramIsId,
org?.name,
org?.state,
orgId,
urlParam,
location.pathname,
location.search,
location.hash,
navigate,
paths.organizations,
]);


/* Carry the org id in router state so breadcrumb/tab navs skip the resolve. */
const onNavigate = useCallback(
(path: string, state?: { orgId?: string }) =>
navigate(path, state ? { state } : undefined),
[navigate],
);

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]);

/* No id, error, or empty result (also guards the infinite loader) → list. */
if (!urlParam || resolveError || notFound) {
return <Navigate to={`/${paths.organizations}`} replace />;
}

/* Still resolving — loader, not a redirect flash. */
if (!orgId) {
return <LoadingState />;
}

return (
<OrganizationDetailsView
organizationId={organizationId}
organizationId={orgId}
appUrl={config?.app_url}
tokenProductId={config?.token_product_id}
countries={countries}
Expand All @@ -61,7 +163,7 @@ export default function OrganizationDetailsPage() {
onExportProjects={onExportProjects}
onExportTokens={onExportTokens}
currentPath={location.pathname}
onNavigate={navigate}
onNavigate={onNavigate}
>
<Outlet />
</OrganizationDetailsView>
Expand Down
4 changes: 3 additions & 1 deletion web/apps/admin/src/pages/organizations/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default function OrganizationListPage() {
}, []);

const onNavigateToOrg = useCallback(
(id: string) => navigate(`/${paths.organizations}/${id}`),
// Slug in the URL, id in router state: id is the stable key; the slug is display-only.
(slug: string, orgId: string) =>
navigate(`/${paths.organizations}/${slug}`, { state: { orgId } }),
[navigate, paths.organizations],
);

Expand Down
21 changes: 21 additions & 0 deletions web/sdk/admin/hooks/useOrganizationLookup.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
33 changes: 25 additions & 8 deletions web/sdk/admin/views/admins/columns.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
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";

// The cell only has the org id — fetch the org to show its title and slug link.
const OrgCell = ({
orgId,
onNavigateToOrg,
}: {
orgId: string;
onNavigateToOrg?: (slug: string, orgId: string) => void;

Check warning on line 13 in web/sdk/admin/views/admins/columns.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'orgId' is defined but never used

Check warning on line 13 in web/sdk/admin/views/admins/columns.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'slug' is defined but never used
}) => {
const { data: org } = useOrganizationLookup(orgId);

return (
<Button
variant="text"
disabled={!org}
onClick={() => org && onNavigateToOrg?.(org.name || orgId, orgId)}
data-test-id="frontier-admin-navigate-to-org-btn"
>
{org?.title || org?.name || orgId}
</Button>
);
};

export const getColumns: (options?: {

Check warning on line 29 in web/sdk/admin/views/admins/columns.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'options' is defined but never used
onNavigateToOrg?: (orgId: string) => void;
onNavigateToOrg?: (slug: string, orgId: string) => void;

Check warning on line 30 in web/sdk/admin/views/admins/columns.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'orgId' is defined but never used

Check warning on line 30 in web/sdk/admin/views/admins/columns.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'slug' is defined but never used
t?: {
organization: TerminologyEntity;
};
Expand Down Expand Up @@ -50,13 +73,7 @@
cell: (info) => {
const org_id = info.getValue() as string;
return org_id ? (
<Button
variant="text"
onClick={() => onNavigateToOrg?.(org_id)}
data-test-id="frontier-admin-navigate-to-org-btn"
>
{org_id}
</Button>
<OrgCell orgId={org_id} onNavigateToOrg={onNavigateToOrg} />
) : (
"-"
);
Expand Down
2 changes: 1 addition & 1 deletion web/sdk/admin/views/admins/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
};

export type AdminsViewProps = {
onNavigateToOrg?: (orgId: string) => void;
onNavigateToOrg?: (slug: string, orgId: string) => void;

Check warning on line 27 in web/sdk/admin/views/admins/index.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'orgId' is defined but never used

Check warning on line 27 in web/sdk/admin/views/admins/index.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'slug' is defined but never used
/** Icon rendered in the page header next to the title. */
icon?: ReactNode;
};
Expand Down
4 changes: 2 additions & 2 deletions web/sdk/admin/views/audit-logs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@
/** App name displayed in the page title. */
appName?: string;
/** Callback to export audit logs as CSV with the current query filters applied. */
onExportCsv?: (query: RQLRequest) => Promise<void>;

Check warning on line 68 in web/sdk/admin/views/audit-logs/index.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'query' is defined but never used
/** 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 = {}) {
Expand Down
13 changes: 10 additions & 3 deletions web/sdk/admin/views/audit-logs/sidepanel-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditRecord> & {
onClose: () => void;
onNavigate?: (path: string) => void;
onNavigate?: (path: string, state?: { orgId?: string }) => void;
};

type AuditSessionContext = {
Expand Down Expand Up @@ -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 (
<SidePanel
data-test-id="admin-user-details-sidepanel"
Expand Down Expand Up @@ -79,8 +85,9 @@ export default function SidePanelDetails({
<ActorCell value={actor!} size="small" maxLength={12} />
</SidepanelListItemLink>
<SidepanelListItemLink
isLink={!!orgId && !isZeroUUID(orgId)}
href={`/${paths.organizations}/${orgId}`}
isLink={isOrgLink && !!org}
href={`/${paths.organizations}/${org?.name || orgId}`}
state={orgId ? { orgId } : undefined}
label={t.organization({ case: "capital" })}
onNavigate={onNavigate}
data-test-id="actor-link">
Expand Down
7 changes: 5 additions & 2 deletions web/sdk/admin/views/audit-logs/sidepanel-list-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -17,6 +19,7 @@ export default function SidepanelListItemLink({
href,
label,
onNavigate,
state,
"data-test-id": dataTestId,
}: SidepanelListItemLinkProps) {
if (isLink && onNavigate) {
Expand All @@ -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}
</Button>
</List.Value>
Expand Down
4 changes: 2 additions & 2 deletions web/sdk/admin/views/organizations/details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export type OrganizationDetailsViewProps = {
organizationTypes?: string[];
/** Current browser path (e.g. `location.pathname`) for highlighting active nav tabs. */
currentPath: string;
/** Navigation callback for tab switches (e.g. `useNavigate()` from react-router-dom). */
onNavigate: (path: string) => void;
/** Navigation for tabs/breadcrumbs; `state` carries the org id (fast path). */
onNavigate: (path: string, state?: { orgId?: string }) => void;
children?: React.ReactNode;
};

Expand Down
2 changes: 1 addition & 1 deletion web/sdk/admin/views/organizations/details/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface OrganizationDetailsLayoutProps {
onExportProjects?: () => Promise<void>;
onExportTokens?: () => Promise<void>;
currentPath: string;
onNavigate: (path: string) => void;
onNavigate: (path: string, state?: { orgId?: string }) => void;
}

export const OrganizationDetailsLayout = ({
Expand Down
Loading
Loading