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
66 changes: 53 additions & 13 deletions web/apps/admin/src/pages/organizations/details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
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 });

Expand All @@ -13,46 +16,83 @@ async function loadCountries(): Promise<string[]> {
}

export default function OrganizationDetailsPage() {
const { organizationId } = useParams<{ organizationId: string }>();
/*
* 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<string[]>([]);

const [stateOrgId, setStateOrgId] = useState<string | undefined>(
(location.state as { orgId?: string } | null)?.orgId,
);

// New id on org switch; keep the current one on tab switches.
useEffect(() => {
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;
Comment on lines +32 to +50

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

Key the cached organization ID to the current URL parameter.

On an organization switch, the previous stateOrgId is used for the first render because the effect runs after commit. If the new location has no orgId state, the old ID is never cleared and URL resolution remains disabled, potentially loading organization A under organization B’s URL.

Cache both the URL parameter and ID, and only reuse the ID when the cached parameter still matches.

Proposed fix
-  const [stateOrgId, setStateOrgId] = useState<string | undefined>(
-    (location.state as { orgId?: string } | null)?.orgId,
-  );
+  const navigationOrgId =
+    (location.state as { orgId?: string } | null)?.orgId;
+  const [cachedNavigation, setCachedNavigation] = useState(() => ({
+    urlParam,
+    orgId: navigationOrgId,
+  }));

-  useEffect(() => {
-    const next = (location.state as { orgId?: string } | null)?.orgId;
-    if (next && next !== stateOrgId) setStateOrgId(next);
-  }, [location.state, stateOrgId]);
+  useEffect(() => {
+    if (navigationOrgId) {
+      setCachedNavigation({ urlParam, orgId: navigationOrgId });
+    }
+  }, [navigationOrgId, urlParam]);
+
+  const stateOrgId =
+    navigationOrgId ??
+    (cachedNavigation.urlParam === urlParam
+      ? cachedNavigation.orgId
+      : undefined);
📝 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
const [stateOrgId, setStateOrgId] = useState<string | undefined>(
(location.state as { orgId?: string } | null)?.orgId,
);
// New id on org switch; keep the current one on tab switches.
useEffect(() => {
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;
const navigationOrgId =
(location.state as { orgId?: string } | null)?.orgId;
const [cachedNavigation, setCachedNavigation] = useState(() => ({
urlParam,
orgId: navigationOrgId,
}));
useEffect(() => {
if (navigationOrgId) {
setCachedNavigation({ urlParam, orgId: navigationOrgId });
}
}, [navigationOrgId, urlParam]);
const stateOrgId =
navigationOrgId ??
(cachedNavigation.urlParam === urlParam
? cachedNavigation.orgId
: undefined);
// 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);
}, []);

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, or the URL didn't resolve → list.
if (!urlParam || resolveError) {
return <Navigate to={`/${paths.organizations}`} replace />;
}

// Still resolving the cold-load id — show a loader, don't flash a redirect.
if (!orgId) {
return <LoadingState />;
}

return (
<OrganizationDetailsView
organizationId={organizationId}
organizationId={orgId}
appUrl={config?.app_url}
tokenProductId={config?.token_product_id}
countries={countries}
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 (a disabled org's slug won't resolve).
(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,
},
);
32 changes: 24 additions & 8 deletions web/sdk/admin/views/admins/columns.tsx
Original file line number Diff line number Diff line change
@@ -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";

// 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"
onClick={() => 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 28 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 29 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 29 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 +72,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}
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
25 changes: 23 additions & 2 deletions web/sdk/admin/views/organizations/details/layout/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,17 @@ const NavLinks = ({
}) => {
const t = useTerminology();
const paths = useAdminPaths();
const basePath = `/${paths.organizations}/${organizationId}`;
/*
* 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]
: 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}` },
Expand Down Expand Up @@ -307,13 +317,24 @@ export const OrganizationsDetailsNavabar = ({
<Breadcrumb size="small">
<Breadcrumb.Item
href={`/${adminPaths.organizations}`}
onClick={(e) => {
e.preventDefault();
onNavigate(`/${adminPaths.organizations}`);
}}
leadingIcon={<OrganizationIcon />}
>
{t.organization({ plural: true, case: "capital" })}
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/* Navigate client-side so we don't full-reload and lose the held org id. */}
<Breadcrumb.Item
href={`/${adminPaths.organizations}/${organization?.id}`}
href={`/${adminPaths.organizations}/${organization?.name || organization?.id}`}
onClick={(e) => {
e.preventDefault();
onNavigate(
`/${adminPaths.organizations}/${organization?.name || organization?.id}`,
);
}}
leadingIcon={
<Avatar
color={getAvatarColor(organization?.id || "")}
Expand Down
5 changes: 3 additions & 2 deletions web/sdk/admin/views/organizations/list/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type CreateOrganizationPanelProps = {
organizationTypes?: string[];
appUrl?: string;
countries?: string[];
onSuccess?: (orgId: string) => void;
onSuccess?: (slug: string, orgId: string) => void;
};

export function CreateOrganizationPanel({
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 6 additions & 5 deletions web/sdk/admin/views/organizations/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** List of allowed organization types for filtering / creation. */
Expand Down Expand Up @@ -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 (
<>
Expand All @@ -191,9 +192,9 @@ export const OrganizationListView = ({
organizationTypes={organizationTypes}
appUrl={appUrl}
countries={countries}
onSuccess={(id) => {
onSuccess={(slug, orgId) => {
closeCreateOrgPanel();
onNavigateToOrg?.(id);
onNavigateToOrg?.(slug, orgId);
}}
/>
<PageTitle title={t.organization({ plural: true, case: "capital" })} appName={appName} />
Expand Down
Loading