diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index ee210cd9f..a75f148f4 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15426,7 +15426,7 @@ "license": "Apache-2.0", "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.9", + "@codefly-dev/saas-sdk": "^0.3.10", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", @@ -15443,7 +15443,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.9", + "@codefly-dev/saas-sdk": "^0.3.10", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 6ad5c4f1a..553534306 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -80,6 +80,80 @@ components: `useViewerEpoch(getAccessToken)` — who the current credential speaks for, stable across a refresh and changing with the viewer. They partition local UI state; they never authorize anything (the claims are read unverified). +- `viewerPrincipal(token)` — the principal reading (the token's `sub`). On an + impersonation token it is still the real actor, never the user being viewed + as. It decides what reads as "You", nothing else. + +### Who did something: principal names + +Every module records who did something as a principal id. The host holds the +directory that names them, so a solution asks the kit rather than listing the +directory itself: + +```tsx + + + + +// anywhere beneath it + +const names = usePrincipalNames(ids); // Record +const nameOf = useNameOf(); // (id) => string | undefined, for a kit's `nameOf` prop +const directory = usePrincipalDirectory(); // loading | ready | refused | failed +``` + +- **One read per page.** `` reads the viewer's + organization directory (`DirectoryService.ListOrganizationMembers`) the first + time anything beneath it asks, once however many cards ask, and keeps the + answer until the viewer changes (the change `useViewerEpoch` counts). A new + viewer's page never sees the last viewer's names, not for one render, and an + answer that arrives after the switch is dropped. Its binding needs + `getAccessToken`: names are kept per viewer, and a binding that cannot say who + the viewer is cannot keep them apart. +- **Only the viewer's organization.** The host answers the directory only for + an organization the viewer belongs to, and the kit also ignores any row naming + another one. A principal outside it resolves to `undefined`, never to another + tenant's member. +- **`PrincipalName = { display, email?, avatarUrl?, you }`.** `display` is the + directory's label for the member. The host's tenant directory carries a + member's email and no display name or avatar, so today `display` is the email + and `avatarUrl` is never set; a richer directory changes what is shown without + changing a consumer. A member the directory gives no label (a deleted + account) is unknown, never `""`. `you` is an exact match with + `viewerPrincipal`: a near match (another case, the same email) is never "You". + `you` is set only on a listed principal, so the viewer is `undefined` from + `usePrincipalNames` whenever the directory does not list them (while it loads, + when it was refused, or when an administrator views as someone outside it). A + caller deciding "You" compares against `viewerPrincipal` itself, as + `` does. +- **States are said once.** A name is `undefined` while loading, when the read + was refused, or when the principal is not listed. Which of those it is, + `usePrincipalDirectory()` says once for the page: + - `refused` is kept for the viewer. It means the host answered permission + denied, the credential names no organization, or there is no credential at + all (a signed-out viewer). + - `failed` is anything else, and it stays `failed` while a retry is in flight. + A page never goes back to `loading` once the read has settled, so a page that + renders names only after it settles cannot remount itself into a request + loop. When a later read happens depends on the failure: + - unauthenticated: on the next token the host hands over; + - a gateway or network failure: by the next component that asks after a + backoff (1 s, doubling, at most a minute); + - invalid argument, not found, unimplemented, or failed precondition: not + again for this viewer, because the request itself is wrong. +- **Late answers are dropped.** An answer is stored only if the host's current + token still names the viewer who asked, so a switch the page has not noticed + yet (a host without `subscribeToken` is polled every 250 ms) never shows the + previous viewer's names. +- **``** renders "You" on an exact match, the + directory's label once known, and otherwise the id shortened to its ends + (`0000…00a1`). The full id is always its `title`, and `data-state` (`you`, + `named`, `loading`, `unknown`, `refused`, `failed`) says which it is. An empty + principal reads "someone", with `data-state="none"` and a title saying no + principal is recorded. +- **`useNameOf()`** is the function form a module kit takes as a prop. It + answers the directory's label or `undefined`, never "You" — a kit decides that + from the viewer it is given — and never `""`. ## Datasources diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index c28f0e1f6..7f9223792 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -34,7 +34,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.9", + "@codefly-dev/saas-sdk": "^0.3.10", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", "@hookform/resolvers": ">=3", @@ -46,7 +46,7 @@ }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.9", + "@codefly-dev/saas-sdk": "^0.3.10", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", "@hookform/resolvers": "^3.9.0", diff --git a/module/services/frontend/code/packages/saas-ui/src/index.ts b/module/services/frontend/code/packages/saas-ui/src/index.ts index abf894261..9d8d29e4b 100644 --- a/module/services/frontend/code/packages/saas-ui/src/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/index.ts @@ -106,8 +106,14 @@ export { parsePaths } from "./datasources/util.js"; export { type AccessibleScopeState, COLLECTION_ACCESS_PATH, + type NameOf, NoReadableCollection, type NoReadableCollectionProps, + PrincipalName, + type PrincipalDirectoryState, + type PrincipalNameProps, + PrincipalNamesProvider, + type PrincipalNamesProviderProps, requestBinding, type SolutionBinding, SolutionBindingError, @@ -120,11 +126,15 @@ export { solutionTransport, useAccessibleScope, useAccessToken, + useNameOf, + usePrincipalDirectory, + usePrincipalNames, useSolutionJson, useViewerEpoch, viewerAdministersOrganization, viewerIdentity, viewerOrganization, + viewerPrincipal, } from "./solution/index.js"; // When someone last signed in, said as a person reads it and kept true as time // passes; relativeTime is its words alone, for a consumer's own shell. diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/__tests__/principal-names.test.tsx b/module/services/frontend/code/packages/saas-ui/src/solution/__tests__/principal-names.test.tsx new file mode 100644 index 000000000..7c6eb54ab --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/solution/__tests__/principal-names.test.tsx @@ -0,0 +1,589 @@ +import { + act, + cleanup, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type NameOf, + type PrincipalNamesProviderProps, + PrincipalName, + PrincipalNamesProvider, + useNameOf, + usePrincipalDirectory, + usePrincipalNames, + viewerPrincipal, +} from "../index.js"; + +afterEach(cleanup); + +function token(claims: Record): string { + return `header.${btoa(JSON.stringify(claims)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}.signature`; +} + +function body(init?: RequestInit): unknown { + return JSON.parse(new TextDecoder().decode(init?.body as Uint8Array)); +} + +const ALICE = "00000000-0000-7000-8000-0000000000a1"; +const BOB = "00000000-0000-7000-8000-0000000000b2"; +const CAROL = "00000000-0000-7000-8000-0000000000c3"; +const STRANGER = "00000000-0000-7000-8000-0000000000ff"; +const ORG_A = "00000000-0000-7000-8000-00000000000a"; +const ORG_B = "00000000-0000-7000-8000-00000000000b"; + +const aliceToken = token({ iss: "host", sub: ALICE, org: ORG_A, auth_time: 1 }); +const bobToken = token({ iss: "host", sub: BOB, org: ORG_B, auth_time: 2 }); + +function member(orgId: string, userId: string, userEmail: string) { + return { orgId, userId, userEmail }; +} + +const orgA = [ + member(ORG_A, ALICE, "alice@example.com"), + member(ORG_A, CAROL, "carol@example.com"), +]; + +/** A directory that answers every read with `members`, counting the reads. */ +function directory(members: ReturnType[]) { + return vi.fn(async () => Response.json({ members })); +} + +function host( + authedFetch: typeof fetch, + getAccessToken: () => string | null = () => aliceToken, +): PrincipalNamesProviderProps["binding"] { + return { apiBase: "/api", getAccessToken, authedFetch }; +} + +function wrapper(binding: PrincipalNamesProviderProps["binding"]) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +/** The page's one directory state, as text. */ +function Status() { + const directory = usePrincipalDirectory(); + return ( +

+ {directory.status === "refused" || directory.status === "failed" + ? `${directory.status}: ${directory.reason}` + : directory.status} +

+ ); +} + +describe("viewerPrincipal", () => { + it("is the token's subject, the real actor even when acting as someone", () => { + expect(viewerPrincipal(aliceToken)).toBe(ALICE); + expect(viewerPrincipal(token({ sub: ALICE, acting: BOB }))).toBe(ALICE); + expect(viewerPrincipal(null)).toBe(""); + expect(viewerPrincipal("opaque")).toBe(""); + }); +}); + +describe("usePrincipalNames", () => { + it("reads the viewer's organization once for ten cards", async () => { + const authedFetch = directory(orgA); + render( + + {Array.from({ length: 10 }, (_, card) => ( + + ))} + , + ); + await waitFor(() => + expect(screen.getAllByText("carol@example.com")).toHaveLength(10), + ); + expect(authedFetch).toHaveBeenCalledTimes(1); + const [url, init] = authedFetch.mock.calls[0]!; + expect(String(url)).toBe( + "/api/saas.accounts.v1.DirectoryService/ListOrganizationMembers", + ); + expect(body(init)).toEqual({ orgId: ORG_A }); + expect(new Headers(init?.headers).get("authorization")).toBe( + `Bearer ${aliceToken}`, + ); + }); + + it("keeps the answer across re-renders and for components mounted later", async () => { + const authedFetch = directory(orgA); + const binding = host(authedFetch); + const { result, rerender } = renderHook( + ({ principals }) => usePrincipalNames(principals), + { wrapper: wrapper(binding), initialProps: { principals: [CAROL] } }, + ); + await waitFor(() => + expect(result.current[CAROL]?.display).toBe("carol@example.com"), + ); + const first = result.current; + rerender({ principals: [CAROL] }); + expect(result.current).toBe(first); + rerender({ principals: [CAROL, ALICE] }); + expect(result.current[ALICE]?.display).toBe("alice@example.com"); + + render( + + + , + ); + await screen.findByText("carol@example.com"); + // A second provider is a second page; within the first, nothing re-read. + expect(authedFetch).toHaveBeenCalledTimes(2); + rerender({ principals: [CAROL] }); + expect(authedFetch).toHaveBeenCalledTimes(2); + }); + + it("marks only an exact match with the viewer as you", async () => { + const near = ALICE.toUpperCase(); + const authedFetch = directory([ + ...orgA, + member(ORG_A, near, "alice-again@example.com"), + ]); + const { result } = renderHook( + () => usePrincipalNames([ALICE, near, CAROL]), + { wrapper: wrapper(host(authedFetch)) }, + ); + await waitFor(() => expect(result.current[ALICE]).toBeDefined()); + expect(result.current[ALICE]).toEqual({ + display: "alice@example.com", + email: "alice@example.com", + you: true, + }); + expect(result.current[near]?.you).toBe(false); + expect(result.current[CAROL]?.you).toBe(false); + + render( + + + + , + ); + expect(screen.getByText("You").getAttribute("title")).toBe(ALICE); + await screen.findByText("alice-again@example.com"); + expect(screen.getAllByText("You")).toHaveLength(1); + }); + + it("never names a principal outside the viewer's organization", async () => { + // Even a row the directory should never have sent, naming another + // organization, is not taken as a name. + const authedFetch = directory([ + ...orgA, + member(ORG_B, STRANGER, "stranger@example.com"), + member(ORG_A, BOB, ""), + ]); + const { result } = renderHook( + () => usePrincipalNames([CAROL, STRANGER, BOB, "nobody"]), + { wrapper: wrapper(host(authedFetch)) }, + ); + await waitFor(() => expect(result.current[CAROL]).toBeDefined()); + expect(result.current[STRANGER]).toBeUndefined(); + expect(result.current[BOB]).toBeUndefined(); + expect(result.current.nobody).toBeUndefined(); + }); + + it("shows an unknown principal as its shortened id, with the full id in the title", async () => { + const authedFetch = directory(orgA); + render( + + + + , + ); + const loading = screen.getByTitle(STRANGER); + expect(loading.textContent).toBe("0000…00ff"); + expect(loading.getAttribute("data-state")).toBe("loading"); + expect(loading.getAttribute("aria-busy")).toBe("true"); + + await screen.findByText("carol@example.com"); + const unknown = screen.getByTitle(STRANGER); + expect(unknown.textContent).toBe("0000…00ff"); + expect(unknown.getAttribute("data-state")).toBe("unknown"); + expect(unknown.getAttribute("aria-busy")).toBeNull(); + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe("named"); + }); + + it("leaves every name undefined when the directory is refused, and says so once", async () => { + const authedFetch = vi.fn(async () => + Response.json( + { code: "permission_denied", message: "not a member" }, + { status: 403 }, + ), + ); + const { result } = renderHook( + () => ({ + names: usePrincipalNames([ALICE, CAROL]), + directory: usePrincipalDirectory(), + nameOf: useNameOf(), + }), + { wrapper: wrapper(host(authedFetch)) }, + ); + expect(result.current.directory).toEqual({ status: "loading" }); + await waitFor(() => + expect(result.current.directory).toEqual({ + status: "refused", + reason: "not a member", + }), + ); + expect(result.current.names[ALICE]).toBeUndefined(); + expect(result.current.names[CAROL]).toBeUndefined(); + expect(result.current.nameOf(CAROL)).toBeUndefined(); + + render( + + + + , + ); + await waitFor(() => + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe( + "refused", + ), + ); + expect(screen.getByTitle(CAROL).textContent).toBe("0000…00c3"); + // The viewer still knows who they are. + expect(screen.getByTitle(ALICE).textContent).toBe("You"); + }); + + it("refuses without asking when the credential names no organization", async () => { + const authedFetch = directory(orgA); + const { result } = renderHook(() => usePrincipalDirectory(), { + wrapper: wrapper(host(authedFetch, () => token({ sub: ALICE }))), + }); + await waitFor(() => expect(result.current.status).toBe("refused")); + expect(authedFetch).not.toHaveBeenCalled(); + }); + + it("keeps a transient failure through its backoff, however many components ask", async () => { + let reply = () => + Response.json({ code: "unavailable", message: "down" }, { status: 503 }); + const authedFetch = vi.fn(async () => reply()); + const binding = host(authedFetch); + const page = (later: boolean) => ( + + + {later && } + + ); + const { rerender } = render(page(false)); + await screen.findByText(/^failed: /); + reply = () => Response.json({ members: orgA }); + rerender(page(true)); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe("failed"); + expect(authedFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps a refusal for the viewer rather than asking again", async () => { + const authedFetch = vi.fn(async () => + Response.json({ code: "permission_denied" }, { status: 403 }), + ); + const binding = host(authedFetch); + const page = (later: boolean) => ( + + + {later && } + + ); + const { rerender } = render(page(false)); + await screen.findByText(/^refused: /); + rerender(page(true)); + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe("refused"); + expect(authedFetch).toHaveBeenCalledTimes(1); + }); + + it("never shows one viewer's names to the next, not even for one render", async () => { + let current = aliceToken; + const pending: ((reply: Response) => void)[] = []; + const authedFetch = vi.fn( + () => new Promise((resolve) => pending.push(resolve)), + ); + const seen: { viewer: string; carol?: string }[] = []; + function Names() { + const names = usePrincipalNames([CAROL]); + seen.push({ viewer: current, carol: names[CAROL]?.display }); + return null; + } + render( + current)}> + + , + ); + await waitFor(() => expect(pending).toHaveLength(1)); + await act(async () => { + pending[0]!(Response.json({ members: orgA })); + }); + expect(seen.at(-1)?.carol).toBe("carol@example.com"); + + current = bobToken; + await act(async () => { + window.dispatchEvent(new Event("codefly:auth-changed")); + }); + await waitFor(() => expect(pending).toHaveLength(2)); + expect(body(authedFetch.mock.calls[1]![1])).toEqual({ orgId: ORG_B }); + const bobs = seen.filter((render) => render.viewer === bobToken); + expect(bobs.length).toBeGreaterThan(0); + expect(bobs.filter((render) => render.carol)).toEqual([]); + }); + + it("drops an answer that arrives after the viewer changed", async () => { + let current = aliceToken; + const pending: ((reply: Response) => void)[] = []; + const authedFetch = vi.fn( + () => new Promise((resolve) => pending.push(resolve)), + ); + const binding = host(authedFetch, () => current); + function Names() { + const nameOf: NameOf = useNameOf(); + const names = usePrincipalNames([CAROL, BOB]); + return ( + <> + + {`${names[CAROL]?.display ?? "-"} ${names[BOB]?.display ?? "-"} ${names[BOB]?.you}`} + + + {`${nameOf(CAROL) ?? "-"} ${nameOf(BOB) ?? "-"}`} + + + ); + } + const page = (later: boolean) => ( + + + {later && } + + ); + const { rerender } = render(page(false)); + await waitFor(() => expect(pending).toHaveLength(1)); + + current = bobToken; + await act(async () => { + window.dispatchEvent(new Event("codefly:auth-changed")); + }); + await waitFor(() => expect(pending).toHaveLength(2)); + + // Alice's answer arrives after the switch: it reaches neither Bob's page + // nor displaces Bob's read still in flight. + await act(async () => { + pending[0]!(Response.json({ members: orgA })); + }); + expect(screen.getByTestId("names").textContent).toBe("- - undefined"); + expect(screen.getByTestId("name-of").textContent).toBe("- -"); + rerender(page(true)); + expect(authedFetch).toHaveBeenCalledTimes(2); + + await act(async () => { + pending[1]!( + Response.json({ members: [member(ORG_B, BOB, "bob@example.com")] }), + ); + }); + expect(screen.getByTestId("names").textContent).toBe( + "- bob@example.com true", + ); + expect(screen.getByTestId("name-of").textContent).toBe("- bob@example.com"); + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe("unknown"); + }); + + describe("a page that renders names only once the directory settled", () => { + function Settled() { + const directory = usePrincipalDirectory(); + return directory.status === "loading" ? ( +

spinner

+ ) : ( + + ); + } + + /** A directory that fails after `latency`, refusing to be called past 50 times. */ + function failing(reply: () => Response, latency = 10) { + return vi.fn(async () => { + if (authedFetchCalls() > 50) throw new Error("runaway retry"); + await new Promise((resolve) => setTimeout(resolve, latency)); + return reply(); + }); + } + let authedFetchCalls = () => 0; + + it.each([ + ["a gateway that keeps answering 502", 502, "unavailable", 2], + ["a malformed organization claim", 400, "invalid_argument", 1], + ["a missing proxy route", 404, "not_found", 1], + ["an unimplemented procedure", 501, "unimplemented", 1], + ["a failed precondition", 400, "failed_precondition", 1], + ])( + "makes a bounded number of reads against %s", + async (_, status, code, afterBackoff) => { + const authedFetch = failing(() => + Response.json({ code, message: "no" }, { status }), + ); + authedFetchCalls = () => authedFetch.mock.calls.length; + const binding = host(authedFetch); + const page = (extra: boolean) => ( + + + {extra && } + + ); + const { rerender } = render(page(false)); + await waitFor(() => + expect(screen.getByTitle(CAROL).getAttribute("data-state")).toBe( + "failed", + ), + ); + await act(() => new Promise((resolve) => setTimeout(resolve, 300))); + expect(authedFetch).toHaveBeenCalledTimes(1); + expect(screen.queryByText("spinner")).toBeNull(); + + // Past the longest backoff, a newly mounted name asks again only + // when the failure could have healed by itself. + const clock = vi + .spyOn(Date, "now") + .mockReturnValue(Date.now() + 120_000); + try { + rerender(page(true)); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(authedFetch).toHaveBeenCalledTimes(afterBackoff); + expect(screen.queryByText("spinner")).toBeNull(); + } finally { + clock.mockRestore(); + } + }, + ); + + it("retries a transient failure after a backoff without going back to loading", async () => { + let reply = () => + Response.json( + { code: "unavailable", message: "down" }, + { status: 503 }, + ); + const authedFetch = vi.fn(async () => reply()); + const binding = host(authedFetch); + const page = (extra: boolean) => ( + + + {extra && } + + ); + const { rerender } = render(page(false)); + await screen.findByTitle(CAROL); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now + 60_000); + try { + reply = () => Response.json({ members: orgA }); + rerender(page(true)); + // The retry is in flight: the page keeps what it showed. + expect(screen.queryByText("spinner")).toBeNull(); + await screen.findByText("carol@example.com"); + expect(authedFetch).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }); + }); + + it("drops an answer that arrives after the token changed but before the kit noticed", async () => { + let current = aliceToken; + const pending: ((reply: Response) => void)[] = []; + const authedFetch = vi.fn( + () => new Promise((resolve) => pending.push(resolve)), + ); + const seen: { viewer: string; carol?: string }[] = []; + function Names() { + const names = usePrincipalNames([CAROL]); + seen.push({ viewer: current, carol: names[CAROL]?.display }); + return null; + } + // No subscribeToken and no event: the kit learns of the switch only on + // its next poll. + render( + current)}> + + , + ); + await waitFor(() => expect(pending).toHaveLength(1)); + current = bobToken; + await act(async () => { + pending[0]!(Response.json({ members: orgA })); + }); + expect(seen.filter((render) => render.carol)).toEqual([]); + }); + + it("treats an unauthenticated answer as recoverable on the next token", async () => { + let current = aliceToken; + let reply = () => + Response.json( + { code: "unauthenticated", message: "expired" }, + { status: 401 }, + ); + const authedFetch = vi.fn(async () => reply()); + const binding = host(authedFetch, () => current); + const page = (extra: boolean) => ( + + + + {extra && } + + ); + const { rerender } = render(page(false)); + await screen.findByText(/^failed: /); + rerender(page(true)); + expect(authedFetch).toHaveBeenCalledTimes(1); + + // The host refreshes: same viewer (auth_time kept), a new token. + current = token({ + iss: "host", + sub: ALICE, + org: ORG_A, + auth_time: 1, + sid: "s2", + }); + reply = () => Response.json({ members: orgA }); + await act(async () => { + window.dispatchEvent(new Event("codefly:auth-changed")); + }); + await screen.findByText("carol@example.com"); + expect(screen.getByText("ready")).toBeTruthy(); + expect(authedFetch).toHaveBeenCalledTimes(2); + }); + + it("says a signed-out viewer has no credential instead of loading forever", () => { + const authedFetch = directory(orgA); + render( + null)}> + + + , + ); + expect(screen.getByText("refused: no credential")).toBeTruthy(); + const name = screen.getByTitle(CAROL); + expect(name.getAttribute("data-state")).toBe("refused"); + expect(name.getAttribute("aria-busy")).toBeNull(); + expect(authedFetch).not.toHaveBeenCalled(); + }); + + it("gives an empty principal a state and a title", () => { + render( + + + , + ); + const none = screen.getByText("someone"); + expect(none.getAttribute("data-state")).toBe("none"); + expect(none.getAttribute("title")).toBe("No principal is recorded"); + }); + + it("needs a provider", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => usePrincipalNames([ALICE]))).toThrow( + /PrincipalNamesProvider/, + ); + vi.restoreAllMocks(); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/index.ts b/module/services/frontend/code/packages/saas-ui/src/solution/index.ts index 433c04c0c..622d9ca3a 100644 --- a/module/services/frontend/code/packages/saas-ui/src/solution/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/solution/index.ts @@ -12,6 +12,17 @@ export { NoReadableCollection, type NoReadableCollectionProps, } from "./no-readable-collection.js"; +export { + type NameOf, + PrincipalName, + type PrincipalDirectoryState, + type PrincipalNameProps, + PrincipalNamesProvider, + type PrincipalNamesProviderProps, + useNameOf, + usePrincipalDirectory, + usePrincipalNames, +} from "./principal-names.js"; export { SolutionRequestError, solutionFetch, @@ -26,4 +37,5 @@ export { viewerAdministersOrganization, viewerIdentity, viewerOrganization, + viewerPrincipal, } from "./viewer.js"; diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/principal-names.tsx b/module/services/frontend/code/packages/saas-ui/src/solution/principal-names.tsx new file mode 100644 index 000000000..c336b62b2 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/solution/principal-names.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { DirectoryService } from "@codefly-dev/saas-sdk"; +import { Code, ConnectError, createClient } from "@connectrpc/connect"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import { + requestBinding, + type SolutionBinding, + type SolutionRequestBinding, +} from "./binding.js"; +import { solutionTransport } from "./transport.js"; +import { + useAccessToken, + viewerIdentity, + viewerOrganization, + viewerPrincipal, +} from "./viewer.js"; + +/** + * What a reader calls a principal, from the directory the viewer may read. + * + * `display` is the directory's label for the member. The host's tenant + * directory carries a member's email and nothing else, so today `display` IS + * the email; it is its own field so a richer directory changes what a reader + * sees without changing a consumer. `you` is an exact match with the principal + * reading (`viewerPrincipal`), never a near one. + */ +export interface PrincipalName { + display: string; + email?: string; + avatarUrl?: string; + you: boolean; +} + +/** + * The function form a module kit takes as a prop: the directory's label for a + * principal, or `undefined` when the directory is still loading, refused, or + * does not list that principal. It never answers "You" — a kit decides that + * from the viewer it is given, exactly — and never answers an empty string. + */ +export type NameOf = (principal: string) => string | undefined; + +/** + * Where the directory read stands, said once for the whole page rather than + * once per name. `refused` is a viewer who may not read the directory (the + * host answered permission denied, the credential names no organization, or + * there is no credential at all) and is kept for that viewer. `failed` is + * anything else; it stays `failed` while a retry is in flight, and when one is + * made follows `Retry` below. + */ +export type PrincipalDirectoryState = + | { status: "loading" } + | { status: "ready" } + | { status: "refused"; reason: string } + | { status: "failed"; reason: string }; + +interface Listed { + display: string; + email: string; +} + +/** + * When a failed read may be tried again: after a backoff (a gateway or network + * failure that can heal by itself), only once the host hands over a new token + * (the credential was not accepted), or not for this viewer at all (the + * request itself is wrong — a malformed organization, a missing route). + */ +type Retry = "backoff" | "token" | "never"; + +/** The directory as the store holds it: the public state plus, when ready, the members. */ +type Held = + | { status: "loading" } + | { status: "ready"; members: ReadonlyMap } + | { status: "refused"; reason: string } + | { status: "failed"; reason: string; retry: Retry }; + +const LOADING: Held = { status: "loading" }; +const READY: PrincipalDirectoryState = { status: "ready" }; +const NO_CREDENTIAL: Held = { status: "refused", reason: "no credential" }; + +/** How long a transient failure is kept before a read is tried again. */ +const BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 60_000; + +/** The state of one viewer's read: what it holds and when it may be read again. */ +interface Entry { + viewer: string; + held: Held; + inFlight: boolean; + failures: number; + retryAt: number; + /** The token a "token" failure was answered for. */ + token: string | null; +} + +/** + * One viewer's directory, read at most once for as long as that viewer is the + * viewer. It holds a single entry: a different viewer replaces it, so no name + * one viewer was shown survives into the next viewer's page, and an answer for + * a viewer who is no longer current is dropped instead of stored. + */ +class DirectoryStore { + private entry: Entry | null = null; + private readonly listeners = new Set<() => void>(); + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + held(viewer: string | null): Held { + if (viewer === null) return NO_CREDENTIAL; + return this.entry?.viewer === viewer ? this.entry.held : LOADING; + } + + /** + * Starts the read for `viewer` unless one is already held or in flight for + * them. Every component on the page asks; the first one's request is the + * only one sent, and the rest share its answer. + * + * A failure is kept, and shown, until its retry rule allows another read: + * a retry never turns the page back to loading, so a page that renders + * names only once the directory settled cannot unmount and remount its way + * into a request loop. `current` re-reads who the viewer is when the answer + * arrives; an answer for a viewer who is no longer current is dropped, even + * before the page has noticed the change. + */ + load( + viewer: string, + token: string, + read: () => Promise, + current: () => string | null, + ): void { + let entry = this.entry; + if (entry?.viewer !== viewer) { + const replaced = entry; + entry = { + viewer, + held: LOADING, + inFlight: false, + failures: 0, + retryAt: 0, + token: null, + }; + this.entry = entry; + if (replaced && replaced.held !== LOADING) this.emit(); + } + if (entry.inFlight || !this.mayRead(entry, token)) return; + const started = entry; + started.inFlight = true; + void read().then((held) => { + if (this.entry !== started) return; + started.inFlight = false; + if (current() !== viewer) return; + if (held.status === "failed") { + started.failures += 1; + started.token = token; + started.retryAt = + held.retry === "backoff" + ? Date.now() + + Math.min(BACKOFF_MS * 2 ** (started.failures - 1), MAX_BACKOFF_MS) + : Number.POSITIVE_INFINITY; + } else started.failures = 0; + started.held = held; + this.emit(); + }); + } + + private mayRead(entry: Entry, token: string): boolean { + const { held } = entry; + if (held.status === "loading") return true; + if (held.status !== "failed") return false; + if (held.retry === "token") return token !== entry.token; + return Date.now() >= entry.retryAt; + } + + private emit(): void { + for (const listener of this.listeners) listener(); + } +} + +/** + * How a failed read may be retried, by its code. A refused authority + * (permission denied) is not a failure at all; it is returned as `refused`. + */ +function retryOf(code: Code): Retry { + switch (code) { + case Code.Unauthenticated: + return "token"; + case Code.InvalidArgument: + case Code.NotFound: + case Code.Unimplemented: + case Code.FailedPrecondition: + return "never"; + default: + return "backoff"; + } +} + +/** + * One read of the viewer's organization directory, as the store holds it. A + * member row naming any organization other than the one asked about is + * ignored rather than trusted, and a member the directory gives no label + * (a deleted or unavailable account) is left unknown rather than named "". + */ +async function readDirectory( + binding: () => SolutionRequestBinding, + org: string, +): Promise { + if (!org) + return { + status: "refused", + reason: "the viewer's credential names no organization", + }; + try { + const { members } = await createClient( + DirectoryService, + solutionTransport(binding()), + ).listOrganizationMembers({ orgId: org }); + const listed = new Map(); + for (const member of members) { + if (member.orgId !== org || !member.userId || !member.userEmail) continue; + listed.set(member.userId, { + display: member.userEmail, + email: member.userEmail, + }); + } + return { status: "ready", members: listed }; + } catch (failure) { + const error = ConnectError.from(failure); + return error.code === Code.PermissionDenied + ? { status: "refused", reason: error.rawMessage || error.message } + : { status: "failed", reason: error.message, retry: retryOf(error.code) }; + } +} + +interface DirectoryContext { + store: DirectoryStore; + viewer: string | null; + principal: string; + load: () => void; +} + +const Directory = createContext(null); + +export interface PrincipalNamesProviderProps { + /** + * The host binding. `getAccessToken` is required here even though a request + * binding may go without it: the names are kept per viewer, and a binding + * that cannot say who the viewer is cannot keep them apart. + */ + binding: SolutionRequestBinding & Pick; + children?: ReactNode; +} + +/** + * Holds the viewer's organization directory for every name rendered beneath + * it: one `DirectoryService.ListOrganizationMembers` read per viewer, made the + * first time something beneath asks and kept until the viewer changes (the + * same change `useViewerEpoch` counts). Mount it once, around the page. + * + * The read is the viewer's own: the host answers only for an organization the + * viewer belongs to, so a principal outside it is simply not listed and + * resolves to `undefined` — never to another tenant's member. + */ +export function PrincipalNamesProvider({ + binding, + children, +}: PrincipalNamesProviderProps) { + const { apiBase, getAccessToken, authedFetch, subscribeToken } = binding; + const token = useAccessToken(getAccessToken, subscribeToken); + const viewer = viewerIdentity(token); + const principal = viewerPrincipal(token); + const org = viewerOrganization(token); + const [store] = useState(() => new DirectoryStore()); + const load = useCallback(() => { + if (viewer === null || token === null) return; + store.load( + viewer, + token, + () => + readDirectory( + () => requestBinding(apiBase, getAccessToken, authedFetch), + org, + ), + () => viewerIdentity(getAccessToken()), + ); + }, [store, viewer, token, org, apiBase, getAccessToken, authedFetch]); + const value = useMemo( + () => ({ store, viewer, principal, load }), + [store, viewer, principal, load], + ); + return {children}; +} + +function useDirectory(): { held: Held; principal: string } { + const context = useContext(Directory); + if (!context) + throw new Error( + "principal names need a above them", + ); + const { store, viewer, principal, load } = context; + const held = useSyncExternalStore( + store.subscribe, + () => store.held(viewer), + () => LOADING, + ); + useEffect(() => { + load(); + }, [load]); + return { held, principal }; +} + +function nameIn( + held: Held, + principal: string, + viewer: string, +): PrincipalName | undefined { + if (held.status !== "ready") return undefined; + const listed = held.members.get(principal); + if (!listed) return undefined; + return { + display: listed.display, + email: listed.email, + you: viewer !== "" && principal === viewer, + }; +} + +/** + * The names of `principals`, from the viewer's directory: each one's + * `PrincipalName`, or `undefined` while the directory loads, when it was + * refused, or when it does not list that principal. Whether it is loading or + * refused is `usePrincipalDirectory`'s to say, once for the page. + * + * `you` is set only on a listed principal. The viewer is `undefined` here + * whenever the directory does not list them — while it loads, when it was + * refused, or when an administrator views as someone outside it — so a caller + * deciding "You" compares against `viewerPrincipal` itself, as + * `` does. + * + * Any number of callers on a page share one directory read; asking again + * after a re-render, or from another component, asks nobody. + */ +export function usePrincipalNames( + principals: readonly string[], +): Record { + const { held, principal: viewer } = useDirectory(); + const key = JSON.stringify(principals); + return useMemo(() => { + // No prototype, so a principal spelled like an Object method is not + // answered by one. + const names: Record = + Object.create(null); + for (const principal of JSON.parse(key) as string[]) + names[principal] = nameIn(held, principal, viewer); + return names; + }, [held, viewer, key]); +} + +/** `NameOf` over the viewer's directory, for a kit that takes one as a prop. */ +export function useNameOf(): NameOf { + const { held } = useDirectory(); + return useCallback( + (principal: string) => + held.status === "ready" + ? held.members.get(principal)?.display + : undefined, + [held], + ); +} + +/** Where the page's one directory read stands. */ +export function usePrincipalDirectory(): PrincipalDirectoryState { + const { held } = useDirectory(); + return useMemo(() => { + if (held.status === "ready") return READY; + if (held.status === "failed") + return { status: "failed", reason: held.reason }; + return held; + }, [held]); +} + +/** The id, shortened to its ends, for a principal no name is known for. */ +function shortId(principal: string): string { + return principal.length <= 12 + ? principal + : `${principal.slice(0, 4)}…${principal.slice(-4)}`; +} + +export interface PrincipalNameProps { + principal: string; + className?: string; +} + +/** + * One principal on screen. "You" on an exact match with the principal + * reading; the directory's label once it is known; otherwise — loading, + * refused, or not listed — the id shortened to its ends. The full id is always + * the `title`, and `data-state` says which of those it is (`none` when no + * principal was recorded at all), so a reader can + * always get from the words back to the id and never sees only the raw id + * once a name is known. + */ +export function PrincipalName({ principal, className }: PrincipalNameProps) { + const { held, principal: viewer } = useDirectory(); + if (!principal) + return ( + + someone + + ); + if (viewer !== "" && principal === viewer) + return ( + + You + + ); + const name = nameIn(held, principal, viewer); + const state = name + ? "named" + : held.status === "ready" + ? "unknown" + : held.status; + return ( + + {name ? name.display : shortId(principal)} + + ); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/viewer.ts b/module/services/frontend/code/packages/saas-ui/src/solution/viewer.ts index e0c5897c0..466fe9976 100644 --- a/module/services/frontend/code/packages/saas-ui/src/solution/viewer.ts +++ b/module/services/frontend/code/packages/saas-ui/src/solution/viewer.ts @@ -69,6 +69,18 @@ export function viewerOrganization(token: string | null | undefined): string { return typeof org === "string" ? org : ""; } +/** + * The principal reading, as the access token names it (its `sub` claim), or "" + * when it names none. On an impersonation token this is still the real actor — + * the person at the keyboard — and never the user being viewed as, so a + * record that user wrote is never read back to the administrator as theirs. + * It decides whether a principal on screen reads as "You", and nothing else. + */ +export function viewerPrincipal(token: string | null | undefined): string { + const sub = unverifiedClaims(token)?.sub; + return typeof sub === "string" ? sub : ""; +} + /** * Whether the access token names the viewer an administrator of its active * organization — its owner or an admin (`or` claim), or a platform super diff --git a/module/services/frontend/code/packages/saas-ui/stories/principal-names.stories.tsx b/module/services/frontend/code/packages/saas-ui/stories/principal-names.stories.tsx new file mode 100644 index 000000000..b1035ecd6 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/stories/principal-names.stories.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { + PrincipalName, + PrincipalNamesProvider, + type PrincipalNamesProviderProps, + usePrincipalDirectory, +} from "../src/index.js"; + +export default { title: "SaaS UI/Principal names" }; + +const VIEWER = "00000000-0000-7000-8000-0000000000a1"; +const MEMBER = "00000000-0000-7000-8000-0000000000c3"; +const STRANGER = "00000000-0000-7000-8000-0000000000ff"; +const ORG = "00000000-0000-7000-8000-00000000000a"; + +const viewerToken = `header.${btoa(JSON.stringify({ iss: "example", sub: VIEWER, org: ORG }))}.signature`; + +type DirectoryAnswer = "members" | "loading" | "refused"; + +function Preview({ answer }: { answer: DirectoryAnswer }) { + const [binding] = useState(() => ({ + apiBase: "/example", + getAccessToken: () => viewerToken, + authedFetch: async () => { + if (answer === "loading") return new Promise(() => {}); + if (answer === "refused") + return Response.json( + { code: "permission_denied", message: "not a member" }, + { status: 403 }, + ); + return Response.json({ + members: [ + { orgId: ORG, userId: VIEWER, userEmail: "jane.doe@example.com" }, + { orgId: ORG, userId: MEMBER, userEmail: "john.roe@example.com" }, + ], + }); + }, + })); + return ( + +
    +
  • + Viewer: +
  • +
  • + Member: +
  • +
  • + Outside the organization: +
  • +
+ +
+ ); +} + +function DirectoryStatus() { + const directory = usePrincipalDirectory(); + return ( +

+ Directory:{" "} + {directory.status === "refused" || directory.status === "failed" + ? `${directory.status} (${directory.reason})` + : directory.status} +

+ ); +} + +/** The directory answered: "You", a member's label, and an unknown id shortened. */ +export const Named = { render: () => }; +/** The read is in flight: every id but the viewer's is shortened, with the full id in its title. */ +export const Loading = { render: () => }; +/** The viewer may not read the directory: said once, every other id shortened. */ +export const Refused = { render: () => }; diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 157a60831..47b33693f 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -1488,6 +1488,36 @@ "coverage": "owner stories; browser/skin verification pending", "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." }, + { + "source": "packages/saas-ui/src/solution/principal-names.tsx", + "imports": [ + "@codefly-dev/saas-sdk", + "@connectrpc/connect", + "react", + "./binding.js", + "./transport.js", + "./viewer.js" + ], + "owner": "@codefly-dev/saas-ui", + "disposition": "already canonical", + "reason": "Who did something, named from the viewer's own organization directory: one read per page, kept per viewer, so a solution or module kit showing a principal does not list the directory itself or show a bare id.", + "stories": [ + { + "file": "packages/saas-ui/stories/principal-names.stories.tsx", + "export": "Named" + }, + { + "file": "packages/saas-ui/stories/principal-names.stories.tsx", + "export": "Loading" + }, + { + "file": "packages/saas-ui/stories/principal-names.stories.tsx", + "export": "Refused" + } + ], + "coverage": "owner stories; browser/skin verification pending", + "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." + }, { "source": "src/app/(auth)/auth/callback/page.tsx", "imports": [