import { useEffect, useRef, useState } from "react"; import type { ExternalIdentityLink, ExternalIdentityLinkPage } from "@opengeni/sdk"; import { useOpenGeniText } from "./messages/runtime"; export type IdentityLinkAccountsClient = { listIdentityLinks(workspaceId: string, cursor?: string): Promise; revokeIdentityLink( workspaceId: string, linkId: string, expectedRevision: number, ): Promise; }; /** Participant-scoped inventory. Revocation never deletes native work or merges * identities. The caller remounts this component when the signed-in user changes. */ export function IdentityLinkAccounts({ client, workspaceId, }: { client: IdentityLinkAccountsClient; workspaceId: string; }) { const t = useOpenGeniText(); const [links, setLinks] = useState([]); const [cursor, setCursor] = useState(null); const [busy, setBusy] = useState(true); const [error, setError] = useState(false); const [revision, setRevision] = useState(0); const generation = useRef(0); const inFlight = useRef(false); useEffect(() => { const current = ++generation.current; inFlight.current = true; setLinks([]); setCursor(null); setBusy(true); setError(false); void client .listIdentityLinks(workspaceId) .then( (page) => { if (current !== generation.current) return; setLinks(page.links); setCursor(page.nextCursor); }, () => { if (current === generation.current) setError(true); }, ) .finally(() => { if (current === generation.current) { inFlight.current = false; setBusy(false); } }); return () => { // Invalidate live async work, rather than cleaning up a captured DOM node. // eslint-disable-next-line react-hooks/exhaustive-deps generation.current++; }; }, [client, workspaceId, revision]); async function act(link?: ExternalIdentityLink) { if (inFlight.current || (!link && !cursor)) return; const current = generation.current; inFlight.current = true; setBusy(true); setError(false); try { if (link) { const updated = await client.revokeIdentityLink(workspaceId, link.id, link.revision); if (current === generation.current) setLinks((values) => values.map((value) => (value.id === updated.id ? { ...value, ...updated } : value)), ); } else { const page = await client.listIdentityLinks(workspaceId, cursor!); if (current === generation.current) { setLinks((values) => [ ...new Map([...values, ...page.links].map((value) => [value.id, value])).values(), ]); setCursor(page.nextCursor); } } } catch { if (current === generation.current) setError(true); } finally { if (current === generation.current) { inFlight.current = false; setBusy(false); } } } return (

{t("identityLinkAccounts.linkedProductAccess")}

{t("identityLinkAccounts.manageProductsAllowedToAct")}

{/* The links sit in one card under the heading, like a settings section; the empty and loading lines are rows of that card. */}
{error && (
{t("identityLinkAccounts.couldNotUpdateAccountLinks")}
)} {busy &&

{t("identityLinkAccounts.loadingAccountLinks")}

} {!busy && !error && links.length === 0 && (

{t("identityLinkAccounts.noProductsHaveLinkedAccess")}

)}
    {links.map((link) => (
  • {link.externalIdentity?.source || t("identityLinkAccounts.productIdentity")}{" "} {link.externalIdentity?.externalId ?? link.externalIdentityId}

    {t("identityLinkAccounts.statusStatus", { status: link.status })}{" "} {link.expiresAt ? t("identityLinkAccounts.expiresLocalestring", { localeString: new Date(link.expiresAt).toLocaleString(t.locale), }) : t("identityLinkAccounts.noAutomaticExpiry")}

    {link.permissions.join(", ")}

    {(link.status === "active" || link.status === "pending") && ( )}
  • ))}
{cursor && ( )}
); }