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
7 changes: 7 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,13 @@ select:disabled,
color: var(--shell-panel-faint);
}

.settings-nav-access-error {
margin: 0 0 0.3rem;
padding: 0 0.42rem;
font-size: 0.6875rem;
color: var(--shell-panel-faint);
}

/* Mock .ch rows in the sidebar: pad 0.32/0.42, square. */
.shell-ch-row {
display: grid;
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/pages/settings-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export function SettingsNav({
return (
<div key={group.id} className="settings-nav-group">
<p className="settings-nav-heading">{group.label}</p>
{group.accessProbeFailed === true && (
<p className="settings-nav-access-error">
{SETTINGS_STRINGS.accessProbeFailedHint}
</p>
)}
{primary.map((section) => {
const Icon = section.icon;
return (
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/pages/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export function SettingsRoute({
// `sections` while its probe is still resolving, same as when it's
// genuinely denied — wait for every gate to settle before treating a
// miss as final, or a deep link to an about-to-be-allowed section would
// bounce away before its probe finishes.
// bounce away before its probe finishes. `error` is settled: the
// registry withholds the section and the nav shows a couldn't-check
// state rather than pretending the principal is unauthorized.
const accessSettled =
access.people !== "loading" &&
access.roles !== "loading" &&
Expand Down
69 changes: 41 additions & 28 deletions apps/web/src/settings-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,70 @@
// QueryClient — instead of each mount calling `@corbits/settings-ui`'s bare
// `useTenancyAccess`, which fetches on every mount with no cache — means
// two mounted consumers share one in-flight request and one cached result.
// The package stays free of TanStack Query: this only injects its own
// `evaluate` primitive into the app's cache, the package never imports it.
// The package stays free of TanStack Query: this only injects the
// package's probe into the app's cache, the package never imports Query.

import { evaluate } from "@corbits/settings-ui";
import type { SectionAccess, TenancyAccess } from "@corbits/settings-ui";
import { useQuery } from "@tanstack/react-query";
import {
coalesceSectionAccess,
probeSectionAccess,
} from "@corbits/settings-ui";
import type { TenancyAccess } from "@corbits/settings-ui";
import { useQuery, useQueryClient } from "@tanstack/react-query";

import { tenantKeys } from "./query-client";

async function probe(
tenantId: string,
principalId: string,
resource: string,
): Promise<SectionAccess> {
try {
const result = await evaluate(tenantId, principalId, resource, "read");
return result.effect === "allow" ? "allowed" : "denied";
} catch {
return "denied";
}
}

const LOADING_ACCESS: TenancyAccess = {
people: "loading",
roles: "loading",
grants: "loading",
credentials: "loading",
};

function coalesceTenancyAccess(
previous: TenancyAccess | undefined,
next: TenancyAccess,
): TenancyAccess {
const prior = previous ?? LOADING_ACCESS;
return {
people: coalesceSectionAccess(prior.people, next.people),
roles: coalesceSectionAccess(prior.roles, next.roles),
grants: coalesceSectionAccess(prior.grants, next.grants),
credentials: coalesceSectionAccess(prior.credentials, next.credentials),
};
}

/** One shared probe per (tenant, principal), not one per mounted consumer.
* `null` ids report `loading` — the same "not shown yet, never disabled"
* contract `@corbits/settings-ui`'s own hook holds. */
* contract `@corbits/settings-ui`'s own hook holds. A thrown evaluate
* (network/5xx) is `error`, not `denied`; a refetch failure keeps the
* last allow/deny so gated nav does not vanish as if unauthorized. */
export function useSettingsAccess(
tenantId: string | null,
principalId: string | null,
): TenancyAccess {
const queryClient = useQueryClient();
const enabled = tenantId !== null && principalId !== null;
const queryKey =
tenantId !== null && principalId !== null
? tenantKeys.settingsAccess(tenantId, principalId)
: tenantKeys.settingsAccess("none", "none");
const query = useQuery({
queryKey:
tenantId !== null && principalId !== null
? tenantKeys.settingsAccess(tenantId, principalId)
: tenantKeys.settingsAccess("none", "none"),
queryKey,
queryFn: async (): Promise<TenancyAccess> => {
if (tenantId === null || principalId === null) return LOADING_ACCESS;
const previous = queryClient.getQueryData<TenancyAccess>(queryKey);
const [people, roles, grants, credentials] = await Promise.all([
probe(tenantId, principalId, "principal"),
probe(tenantId, principalId, "role"),
probe(tenantId, principalId, "grant"),
probe(tenantId, principalId, "credential"),
probeSectionAccess(tenantId, principalId, "principal"),
probeSectionAccess(tenantId, principalId, "role"),
probeSectionAccess(tenantId, principalId, "grant"),
probeSectionAccess(tenantId, principalId, "credential"),
]);
return { people, roles, grants, credentials };
return coalesceTenancyAccess(previous, {
people,
roles,
grants,
credentials,
});
},
enabled,
});
Expand Down
108 changes: 108 additions & 0 deletions apps/web/test/settings-access.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// The app's cached settings-access probe must agree with
// `@corbits/settings-ui`'s mapping: evaluate effect !== allow is deny;
// a thrown probe is error, not deny (CL-6829).

import { afterEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { TenancyAccess } from "@corbits/settings-ui";

import { useSettingsAccess } from "../src/settings-access";
import {
createTestQueryClient,
TestQueryProvider,
} from "./test-query-provider";

const realFetch = globalThis.fetch;

let container: HTMLDivElement | null = null;
let root: Root | null = null;

afterEach(() => {
globalThis.fetch = realFetch;
if (root !== null) {
act(() => {
root?.unmount();
});
root = null;
}
container?.remove();
container = null;
});

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});

function AccessProbe({
onAccess,
}: {
readonly onAccess: (access: TenancyAccess) => void;
}) {
const access = useSettingsAccess("tnt_1", "prn_1");
onAccess(access);
return null;
}

async function mountProbe(): Promise<TenancyAccess[]> {
const seen: TenancyAccess[] = [];
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const client = createTestQueryClient();
await act(async () => {
root?.render(
<TestQueryProvider client={client}>
<AccessProbe onAccess={(access) => seen.push(access)} />
</TestQueryProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
return seen;
}

describe("useSettingsAccess", () => {
test("a deny effect is denied", async () => {
globalThis.fetch = (async () =>
json({ effect: "deny", matchingGrants: [] })) as unknown as typeof fetch;
const seen = await mountProbe();
const last = seen[seen.length - 1];
expect(last).toEqual({
people: "denied",
roles: "denied",
grants: "denied",
credentials: "denied",
});
});

test("a 5xx is error, not denied", async () => {
globalThis.fetch = (async () => json({}, 500)) as unknown as typeof fetch;
const seen = await mountProbe();
const last = seen[seen.length - 1];
expect(last).toEqual({
people: "error",
roles: "error",
grants: "error",
credentials: "error",
});
});

test("a network failure is error, not denied", async () => {
globalThis.fetch = (() =>
Promise.reject(
new TypeError("Failed to fetch"),
)) as unknown as typeof fetch;
const seen = await mountProbe();
const last = seen[seen.length - 1];
expect(last).toEqual({
people: "error",
roles: "error",
grants: "error",
credentials: "error",
});
});
});
43 changes: 37 additions & 6 deletions apps/web/test/settings-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient } from "@tanstack/react-query";
import { SETTINGS_STRINGS, type TenancyAccess } from "@corbits/settings-ui";

import type { PrincipalsPage } from "../src/api";
import { BenchProvider } from "../src/bench-context";
Expand All @@ -29,16 +30,23 @@ const principalsPage: PrincipalsPage = {
nextCursor: null,
};

const deniedAccess = {
const deniedAccess: TenancyAccess = {
people: "denied",
roles: "denied",
grants: "denied",
credentials: "denied",
} as const;
};

const errorAccess: TenancyAccess = {
people: "error",
roles: "error",
grants: "error",
credentials: "error",
};

/** A client pre-seeded so both the bench and the settings-access probe are
* already resolved on first render — deterministic, no real network. */
function seededClient(): QueryClient {
function seededClient(access: TenancyAccess = deniedAccess): QueryClient {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
Expand All @@ -47,7 +55,7 @@ function seededClient(): QueryClient {
client.setQueryData(meKeys.principals, principalsPage);
client.setQueryData(
tenantKeys.settingsAccess("tenant_1", "principal_1"),
deniedAccess,
access,
);
return client;
}
Expand All @@ -67,13 +75,17 @@ describe("SettingsRoute section-id redirect", () => {
container = null;
});

async function mount(path: string, navigated: string[]) {
async function mount(
path: string,
navigated: string[],
access: TenancyAccess = deniedAccess,
) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestQueryProvider client={seededClient()}>
<TestQueryProvider client={seededClient(access)}>
<BenchProvider>
<SettingsRoute path={path} navigate={(to) => navigated.push(to)} />
</BenchProvider>
Expand All @@ -99,4 +111,23 @@ describe("SettingsRoute section-id redirect", () => {
await mount("/settings/account", navigated);
expect(navigated).toEqual([]);
});

test("a probe failure is not presented as a gate deny", async () => {
const navigated: string[] = [];
await mount("/settings/account", navigated, errorAccess);
expect(container?.textContent).toContain(
SETTINGS_STRINGS.accessProbeFailedHint,
);
expect(container?.textContent).not.toContain(
SETTINGS_STRINGS.peopleSectionTitle,
);
});

test("a gate deny does not claim the access check failed", async () => {
const navigated: string[] = [];
await mount("/settings/account", navigated);
expect(container?.textContent).not.toContain(
SETTINGS_STRINGS.accessProbeFailedHint,
);
});
});
Loading
Loading