mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
components/consent/Consent.tsx and Choices.tsx are gone. app/providers.tsx mounts Consent from @hanzo/ui/consent 8.29.14: a bar with Manage preferences, Accept all and a × that stores essential cookies only, and a Cookie Preferences sheet that opens from any link to #choices, the footer's Privacy choices included. Its switches start off until chosen. @hanzo/event 0.3.61 makes the stream first party: every page view and event goes for every visitor, stating the grants it went under, so the provider no longer takes `enabled` from consent. Consent gates Google and Meta alone, through startTags. The conversion and forward gates now hold that no platform hears of a visit before Accept or after a refusal, while our stream counts it under no grant. Help's corner words gain the Font and Arabic font rows @hanzo/ui 8.29.12 added to Words.
614 lines
28 KiB
TypeScript
614 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import "@/lib/app";
|
|
|
|
import { claimsOf, IamProvider, useIam } from "@hanzo/iam/react";
|
|
import { asked, assumed, bearer, carry, follow, live, ORG, own, subject, unasked } from "@hanzo/app/lib/session";
|
|
import { bind, keep } from "@/lib/hanzo/affiliate";
|
|
import { claim, keep as hold } from "@/lib/hanzo/referrals";
|
|
import {
|
|
AnalyticsProvider,
|
|
ErrorBoundary,
|
|
useAnalytics,
|
|
usePageview,
|
|
} from "@hanzo/event/react";
|
|
import { ObserveProvider } from "@hanzo/observe/react";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
import NextLink from "next/link";
|
|
import { Box, Link } from "@hanzo/ui";
|
|
|
|
import { api, CLIENT } from "@hanzo/app/lib/api";
|
|
import { door, LOGIN, recognize, rememberDestination, signUp } from "@hanzo/app/lib/destination";
|
|
import { markMethod } from "@hanzo/app/lib/intent";
|
|
import { here, useClient } from "@hanzo/app/lib/signout";
|
|
import { foreign } from "@/lib/auth/signin";
|
|
import { attach, authorize, DOMAINS, link, startTags, track } from "@hanzo/app/lib/tags";
|
|
import { noise } from "@/lib/analytics/noise";
|
|
import { Analytics } from "@hanzo/event";
|
|
import { Consent } from "@hanzo/ui/consent";
|
|
import { Help } from "@/components/shell/help";
|
|
import { SupportBanner } from "@hanzo/app/Support";
|
|
import { Impersonation, Personal, useImpersonation } from "@/components/auth/Impersonation";
|
|
import { impersonated } from "@/lib/auth/impersonation";
|
|
import { arrive } from "@/lib/campaign";
|
|
import { Look } from "@hanzo/app";
|
|
import { I18nProvider } from "@hanzo/i18n/react";
|
|
import { Action, Line } from '@hanzo/ui/marketing'
|
|
|
|
/** Cloud endpoint: POST /v1/event, body { batch: [Event…] }. */
|
|
const getEventHost = () => api();
|
|
|
|
/**
|
|
* THERE IS ONE ANALYTICS CLIENT, and this is what keeps it to one.
|
|
*
|
|
* `@hanzo/ui` depends on `@hanzogui/telemetry`, whose `track` builds an AMBIENT
|
|
* client the first time any shared component reports an interaction — zero-config
|
|
* by design, `pageviews` on by default. So a page carrying both it and the
|
|
* `AnalyticsProvider` below sends `$pageview` TWICE. Measured on one load of
|
|
* /pricing: two POSTs to /v1/event, the second carrying its own `$pageview`.
|
|
*
|
|
* The second one is also UNKEYED, and that is the half that actually breaks in
|
|
* production: anonymous traffic without a `pk-` is answered
|
|
* `401 ingest_key_required`, which is a console error on every visit.
|
|
*
|
|
* Turning it off costs nothing that works today. Its interaction events
|
|
* (`FEATURE_USED`) ride that same keyless send, and `<ObserveProvider>` already
|
|
* captures clicks, inputs, changes and submits through the ONE keyed client.
|
|
*
|
|
* Module scope, not an effect: the ambient client is built on FIRST USE during
|
|
* render, and this file's chunk is evaluated before its component runs.
|
|
*
|
|
* (The duplicate is NOT a mislabelling. Read locally it reports `product: "dev"`,
|
|
* which is `productFromHost` mapping 127.0.0.1 — on hanzo.ai that same table maps
|
|
* the host to `site`, so in production it was silently doubling one number rather
|
|
* than splitting it across two.)
|
|
*/
|
|
if (typeof globalThis !== "undefined") {
|
|
const g = globalThis as { __HANZO_TELEMETRY__?: { enabled?: boolean } };
|
|
g.__HANZO_TELEMETRY__ = { ...g.__HANZO_TELEMETRY__, enabled: false };
|
|
}
|
|
|
|
/**
|
|
* The client `track` is handed, for the page's life: it sends on whichever
|
|
* client the provider holds when a moment goes out.
|
|
*
|
|
* Our stream is first-party analytics and runs for every visitor, whatever they
|
|
* chose; consent gates the third parties alone (`startTags`). @hanzo/event holds
|
|
* a moment fired before the tag set has answered and sends it later on the
|
|
* client it was fired with, so `track` follows the provider's client rather than
|
|
* keeping the one it was first handed.
|
|
*/
|
|
class Stream extends Analytics {
|
|
client?: Analytics;
|
|
capture(...args: Parameters<Analytics["capture"]>): void {
|
|
this.client?.capture(...args);
|
|
}
|
|
link(url: string): string {
|
|
return this.client ? this.client.link(url) : url;
|
|
}
|
|
authorize(url: string): string {
|
|
return this.client ? this.client.authorize(url) : url;
|
|
}
|
|
}
|
|
const stream = new Stream({ product: "site", enabled: false });
|
|
attach(stream);
|
|
|
|
/** The funnel moment a click to one of these places is: a host, or a host and
|
|
* the path it is mounted at. A link marked `data-ga` sends its own, with the
|
|
* item and value it knows. */
|
|
const INTENT: Record<string, string> = {
|
|
"hanzo.ai/pay": "checkout_started",
|
|
"billing.hanzo.ai": "checkout_started",
|
|
"cal.com/hanzo": "sales_contacted",
|
|
};
|
|
|
|
/** The INTENT a link reaches: its host alone, or its host and a mounted path. */
|
|
function intent(href: string): string | undefined {
|
|
const u = new URL(href, window.location.href);
|
|
const at = u.host + u.pathname;
|
|
const key = Object.keys(INTENT).find((k) => at === k || at.startsWith(k + "/"));
|
|
return key && INTENT[key];
|
|
}
|
|
|
|
/**
|
|
* The funnel's wiring (lib/analytics/tags.ts): points `track`'s stream at the
|
|
* provider's client, starts the tag manager, and sends the
|
|
* moment a click to the checkout or a booking host is.
|
|
*/
|
|
function Tags() {
|
|
const client = useAnalytics();
|
|
useEffect(() => {
|
|
stream.client = client;
|
|
}, [client]);
|
|
// The campaign this visit landed with, kept for the tab: a sign-in takes it
|
|
// out of the address, and pay is handed it all the same (lib/campaign).
|
|
useEffect(() => arrive(window.location.search), []);
|
|
// A link to another Hanzo host carries the visitor, however it is opened:
|
|
// the address is decorated before a click, a middle click or a copy takes it.
|
|
useEffect(() => {
|
|
const carry = (e: Event) => {
|
|
const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
|
|
if (a) a.href = link(a.href);
|
|
};
|
|
const on = ["pointerdown", "click", "contextmenu", "keydown"] as const;
|
|
on.forEach((t) => document.addEventListener(t, carry, { capture: true }));
|
|
return () => on.forEach((t) => document.removeEventListener(t, carry, { capture: true }));
|
|
}, []);
|
|
// The tag manager: the host's own tag set from cloud, loaded as consent
|
|
// allows, and this page's ad click kept first-party. One export serves
|
|
// several hosts (hanzo.app, hanzo.chat), so the set is asked for with the
|
|
// host's own project key (@hanzo/event `siteKey`). The signed-in person's
|
|
// bearer tells cloud a member of our own org is visiting: their browser loads
|
|
// Google Analytics alone, tagged internal, and no ad pixel. A support
|
|
// session's token is the person's, not the visitor's, so it asks as nobody.
|
|
useEffect(
|
|
() =>
|
|
startTags({
|
|
domains: [window.location.hostname, ...DOMAINS],
|
|
token: () => {
|
|
const token = live() ? bearer() : null;
|
|
return token && !impersonated(claimsOf(token)) ? token : undefined;
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
useEffect(() => {
|
|
const click = (e: MouseEvent) => {
|
|
const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
|
|
if (!a || a.hasAttribute("data-ga")) return;
|
|
const name = intent(a.href);
|
|
if (!name) return;
|
|
track(name, { link_url: a.href, page_path: window.location.pathname });
|
|
};
|
|
document.addEventListener("click", click, { capture: true });
|
|
return () => document.removeEventListener("click", click, { capture: true });
|
|
}, []);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `<html data-hydrated>` once React has committed the page: rendered last, so its
|
|
* effect runs after every other on the first commit. A press before it follows the
|
|
* bare link; anything that presses (a gate, a script) waits for it.
|
|
*/
|
|
function Hydrated() {
|
|
useEffect(() => document.documentElement.setAttribute("data-hydrated", ""), []);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Unhandled errors and rejections, reported as the SDK would (`captureErrors`,
|
|
* off on the provider) less what is not this site's to fix
|
|
* (lib/analytics/noise.ts): a scanner's, an extension's, another origin's.
|
|
*/
|
|
function Errors() {
|
|
const client = useAnalytics();
|
|
useEffect(() => {
|
|
const report = (err: unknown) => {
|
|
if (!noise(err)) client.captureError(err, { handled: false });
|
|
};
|
|
const error = (e: ErrorEvent) => report(e.error ?? e.message);
|
|
const rejection = (e: PromiseRejectionEvent) => report(e.reason);
|
|
window.addEventListener("error", error);
|
|
window.addEventListener("unhandledrejection", rejection);
|
|
return () => {
|
|
window.removeEventListener("error", error);
|
|
window.removeEventListener("unhandledrejection", rejection);
|
|
};
|
|
}, [client]);
|
|
return null;
|
|
}
|
|
|
|
/** Route-change pageviews. Browser-only; safe under `output: export`. */
|
|
function Pageview() {
|
|
usePageview(usePathname());
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The ONE place this site binds telemetry identity — mounted inside IamProvider
|
|
* so it sees the resolved session on every route, not just /auth/callback.
|
|
*
|
|
* identify(sub, traits) — the stable IAM subject, which is what joins a person's
|
|
* events across hanzo.ai and Hanzo Cloud (which stamps the same subject
|
|
* server-side), plus the attributes that make that subject legible.
|
|
*
|
|
* THE TRAITS ARE NEW, AND THEY REVERSE A STATED RULE. This said "NEVER
|
|
* email/name: the client is PII-free by construction". PII-free was the wrong
|
|
* goal for first-party data about our own users: it produced a warehouse of
|
|
* opaque subjects where every funnel could count people and none could say which
|
|
* person, so no user attribute was visible on any Hanzo property. Email and name
|
|
* arrive in the same hanzo.id userinfo response this file already reads to get
|
|
* `sub`, and were discarded on the way past. They are exactly as sensitive as
|
|
* they were in that response, and only ever describe the signed-in user to
|
|
* ourselves: identify runs only once a session has resolved a user.
|
|
*
|
|
* The traits go to our own stream, which is first party and runs whatever the
|
|
* visitor chose; anything cloud forwards on to an ad platform goes under the
|
|
* grants each event states.
|
|
*
|
|
* THE CLIENT DOES NOT SEND THE ORG. There is deliberately no group() call here.
|
|
* The tenant is stamped SERVER-SIDE from the project key, so the hanzo org's
|
|
* funnels and cohorts are queryable without the browser naming a tenant — and
|
|
* a tenant a client can name is a tenant a client can get wrong. An
|
|
* earlier revision called group(user.owner) to "make ORG-level funnels
|
|
* queryable"; it bought nothing (the server had already stamped that very org
|
|
* from the same session) and it put an org name in a caller-controlled field.
|
|
* Cloud agrees on both counts: it strips groupId from every reduced principal
|
|
* and drops `group` outright on the anonymous lane, so the call was discarded
|
|
* in exactly the cases where trusting it would have mattered.
|
|
*/
|
|
function Identity() {
|
|
const { user, isAuthenticated, isLoading, accessToken } = useIam();
|
|
const analytics = useAnalytics();
|
|
// A support session is the operator's visit, not the person's: it is never
|
|
// joined to them, and nothing is bound or claimed in their name.
|
|
const support = useImpersonation() !== null;
|
|
const who = isAuthenticated ? iamSubject(user) : undefined;
|
|
const traits = isAuthenticated ? iamTraits(user) : undefined;
|
|
useEffect(() => {
|
|
if (!who || support) return;
|
|
// This browser has a person now; signed out later, it is sent to log in, not to sign up.
|
|
recognize();
|
|
analytics.identify(who, traits);
|
|
// `traits` is read off the same userinfo response as `who`, so the
|
|
// subject changing is what marks a new person. Adding the freshly-built
|
|
// object to the deps would re-identify on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [analytics, who, support]);
|
|
// The browser's local state follows the session: once the SDK has settled
|
|
// (a sign-out clears its tokens; a refused refresh leaves them), whoever the
|
|
// stored token names — or nobody — is who the `hanzo*` keys belong to.
|
|
useEffect(() => {
|
|
if (!isLoading) own(subject());
|
|
}, [isLoading, isAuthenticated]);
|
|
// Another tab's sign-out or sign-in is this tab's too (lib/auth/session.ts
|
|
// `follow`). A reload settles the page on what the store now holds: a reader
|
|
// signed out at a place in the app goes to sign in and back to it, and another
|
|
// person is drawn as themselves with the last one's keys emptied. Followed
|
|
// from the first render, while the SDK is still settling on the token it read
|
|
// then, and again from whatever this tab's own sign-in or sign-out leaves.
|
|
useEffect(() => follow(subject(), () => window.location.reload()), [isAuthenticated]);
|
|
// A SESSION IAM HOLDS SIGNS THIS SITE IN ON ANY PAGE. hanzo.ai and
|
|
// platform.hanzo.ai sign in on hanzo.ai's one IAM session, but this site's own
|
|
// tokens are kept apart: signed in for platform, or with this site's tokens
|
|
// expired, a reader on /pricing or /chat was drawn as a stranger while the
|
|
// session that would sign them in sat unused until they opened /login. So the
|
|
// same silent ask the sign-in page makes (components/auth/panel.tsx `Gate`) is
|
|
// made here, wherever the page is: once per tab, only by a browser that has
|
|
// signed in here (`door`), never on the page a sign-out lands on, and not on
|
|
// the sign-in pages and callback, which make or finish their own. A code comes
|
|
// back to this page (`rememberDestination`); a refusal changes nothing.
|
|
const iam = useClient();
|
|
useEffect(() => {
|
|
if (isLoading || isAuthenticated || !here()) return;
|
|
const { pathname, search } = window.location;
|
|
if (pathname === LOGIN || pathname === signUp() || pathname.startsWith("/auth/") || foreign()) return;
|
|
if (new URLSearchParams(search).get("from") === "logout" || door() !== LOGIN || !unasked()) return;
|
|
asked();
|
|
void iam.resume().then((next) => {
|
|
if (!next) return;
|
|
markMethod("session");
|
|
rememberDestination();
|
|
window.location.replace(authorize(next));
|
|
});
|
|
}, [isLoading, isAuthenticated, iam]);
|
|
// The organization a SuperAdmin stepped into is this page's scope. When this
|
|
// tab's own refresh ends it — the SDK mints from the person's own grant, which
|
|
// assumes nothing — the page reloads, so no room keeps reading under a scope
|
|
// the token no longer carries.
|
|
const scoped = useRef<string | null | undefined>(undefined);
|
|
useEffect(() => {
|
|
const now = assumed();
|
|
if (scoped.current === undefined) scoped.current = now;
|
|
else if (now !== scoped.current) window.location.reload();
|
|
}, [accessToken]);
|
|
// An affiliate link lands here (lib/hanzo/affiliate.ts): keep its code, and
|
|
// bind it once the session works in an organization.
|
|
useEffect(keep, []);
|
|
// A referral link (`?ref=`, lib/hanzo/referrals.ts) is the same two halves:
|
|
// keep the code on landing, claim it once the session works in an org.
|
|
useEffect(hold, []);
|
|
useEffect(() => {
|
|
if (isLoading || !isAuthenticated || support) return;
|
|
const on = () => {
|
|
void bind();
|
|
void claim();
|
|
};
|
|
on();
|
|
window.addEventListener(ORG, on);
|
|
return () => window.removeEventListener(ORG, on);
|
|
}, [isLoading, isAuthenticated, support]);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The language the site's own words are drawn in (@hanzo/i18n), and with it the
|
|
* document's `lang` and `dir`: Arabic sets `dir="rtl"` on <html>, and the chrome
|
|
* follows on logical properties.
|
|
*
|
|
* The export is English; a saved choice applies after hydration. It is kept per
|
|
* person on this browser and, signed in, with their IAM account
|
|
* (`/v1/iam/preferences`, the `locale` key alone), so it follows them to every
|
|
* Hanzo surface. The person is the stored token's subject, the same key `own()`
|
|
* empties a browser by, so a signed-in reader's choice applies on the first
|
|
* client render rather than after userinfo. A support session writes nothing as
|
|
* the person: the operator's choice stays on the operator's browser.
|
|
*/
|
|
function Language({ children }: { children: ReactNode }) {
|
|
const { isAuthenticated, accessToken } = useIam();
|
|
const support = useImpersonation() !== null;
|
|
const who = subject() ?? null;
|
|
const account = who && isAuthenticated && accessToken && !support ? { base: api(), token: accessToken } : undefined;
|
|
return (
|
|
<I18nProvider userId={who} account={account}>
|
|
{children}
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The IAM subject (`sub`) off the SDK's user object.
|
|
*
|
|
* This reads `sub` and not `id` because `id` DOES NOT EXIST at runtime, which made
|
|
* the identify above a silent no-op for as long as it has been here — hanzo.ai
|
|
* recorded thousands of pageviews and not one identified person. `IamProvider` sets
|
|
* `user` to the raw OIDC userinfo response (`sdk.getUserInfo()` returns
|
|
* `res.json()` verbatim), and hanzo.id's userinfo returns
|
|
* `{owner, organization, email, iss, aud, preferred_username, name, displayName,
|
|
* picture, sub}` — no `id` anywhere. The SDK's `User` type merely declares `id?`
|
|
* as optional, so reading it type-checks and yields undefined forever; nothing
|
|
* fails, the call just never happens. (`IAM#getUser()` — a DIFFERENT method this
|
|
* provider does not use — is the one that maps userinfo onto a `sub`-bearing
|
|
* shape.)
|
|
*
|
|
* `sub` is the stable IAM user UUID, the same value hanzo.app, hanzo.chat and
|
|
* cloud.hanzo.ai identify this human by. Read defensively: the provider's declared
|
|
* type does not include `sub`, and the value is a network response.
|
|
*/
|
|
function iamSubject(user: unknown): string | undefined {
|
|
if (!user || typeof user !== "object") return undefined;
|
|
const sub = (user as { sub?: unknown }).sub;
|
|
return typeof sub === "string" && sub ? sub : undefined;
|
|
}
|
|
|
|
/**
|
|
* The user attributes that ride the subject, off the same userinfo response.
|
|
*
|
|
* hanzo.id returns `{owner, organization, email, iss, aud, preferred_username,
|
|
* name, displayName, picture, sub}`, so the name is read in descending order of
|
|
* how human it is: `displayName`, then `name`, then `preferred_username`. Read
|
|
* as defensively as `iamSubject` — the provider's declared type does not include
|
|
* these and the value is a network response.
|
|
*
|
|
* A key is OMITTED rather than sent undefined: a trait sent as undefined is a
|
|
* trait written, and it would blank a value an earlier identify established.
|
|
* Returns undefined when nothing resolved, so identify falls back to sending the
|
|
* subject alone rather than an empty object.
|
|
*/
|
|
export function iamTraits(user: unknown): Record<string, unknown> | undefined {
|
|
if (!user || typeof user !== "object") return undefined;
|
|
const claim = (k: string): string | undefined => {
|
|
const v = (user as Record<string, unknown>)[k];
|
|
return typeof v === "string" && v ? v : undefined;
|
|
};
|
|
const traits: Record<string, unknown> = {};
|
|
const email = claim("email");
|
|
if (email) traits.email = email;
|
|
const name =
|
|
claim("displayName") ?? claim("name") ?? claim("preferred_username");
|
|
if (name) traits.name = name;
|
|
return Object.keys(traits).length ? traits : undefined;
|
|
}
|
|
|
|
/** Minimal on-brand fallback when a render error is caught. The boundary already
|
|
* posted it as a type:'error' event, which is what sentry.hanzo.ai reads, so
|
|
* this just keeps the page usable. */
|
|
function Crashed(_error: Error, reset: () => void) {
|
|
return (
|
|
<Box
|
|
display="flex"
|
|
flexDirection="column"
|
|
minH="100vh"
|
|
items="center"
|
|
justify="center"
|
|
gap="$4"
|
|
px="$5"
|
|
bg="$background"
|
|
$platform-web={{ textAlign: "center", color: "var(--foreground)" }}
|
|
>
|
|
<Line render="p" display="block" fontSize="$4" lineHeight="calc(1.75 / 1.125)" fontWeight="500" color="inherit">
|
|
Something went wrong.
|
|
</Line>
|
|
{/* `Action fill` is the site's loud control: white ground, black
|
|
ink, the pill radius, and the only hover it has. */}
|
|
<Action render="button" onClick={reset} fill>
|
|
Try again
|
|
</Action>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The OAuth client a visitor authenticates as is decided by the HOST they landed
|
|
* on — this ONE static export serves several (hanzo.ai and cloud.hanzo.ai from the
|
|
* same `out/`, split by the CloudLanding `cp` in the Dockerfile). It mirrors
|
|
* provision.yaml's app->host graph: cloud.hanzo.ai is the Cloud surface and
|
|
* authenticates as `hanzo-cloud` — the ONLY client whose IAM
|
|
* allowlist carries its `/auth/callback`; every other host is `hanzo-app`.
|
|
*
|
|
* This is why cloud.hanzo.ai login 400'd: the shared build sent `hanzo-app`, whose
|
|
* allowlist has `cloud.hanzo.ai/callback` but not `/auth/callback` (the path the
|
|
* SDK actually sends, since redirectUri is origin + /auth/callback), so authorize
|
|
* was rejected and login was impossible. Deriving the client from the SAME host
|
|
* redirectUri already uses is what keeps the two from drifting.
|
|
* NEXT_PUBLIC_HANZO_CLIENT_ID still overrides for a single-host build.
|
|
*/
|
|
const cloudHosts = new Set(["cloud.hanzo.ai"]);
|
|
// The hanzo.ai domain authenticates as `hanzo-ai`, whose IAM allowlist is exactly
|
|
// these three hosts plus localhost:3000 — the surface's own addresses and nothing
|
|
// else. It was `hanzo-app`, a client this export shares with hanzo.app and its
|
|
// siblings, and that only held while nothing else authorized against this origin.
|
|
// The chat mounted at hanzo.ai/chat now asks for `hanzo-ai` and is returned HERE,
|
|
// and a code is bound to the client that asked for it: exchanging one under
|
|
// another name is refused `invalid_grant: client mismatch`. Signed-out visitors
|
|
// never reach it, because the silent probe answers login_required before a code
|
|
// exists, so the two names disagreeing is invisible until someone arrives already
|
|
// signed in.
|
|
function sanitizeStaleAudience() {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
const token = localStorage.getItem("hanzo_iam_access_token");
|
|
if (!token) return;
|
|
const part = token.split(".")[1];
|
|
if (!part) return;
|
|
const payload = JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/")));
|
|
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
if (auds.includes("hanzo-ai") && !auds.includes("hanzo-app")) {
|
|
// Disallowed by production gateway: purge stale token so next login mints hanzo-app
|
|
localStorage.removeItem("hanzo_iam_access_token");
|
|
localStorage.removeItem("hanzo_iam_refresh_token");
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
|
|
// A brand's own chat host signs in through its own IAM application at its own
|
|
// issuer: the export is one tree served under several hosts, so the host is
|
|
// the fact that picks them. Absent here means Hanzo's.
|
|
const brandHosts: Record<string, { client: string; issuer: string }> = {
|
|
"lux.chat": { client: "lux-chat", issuer: "https://lux.id" },
|
|
};
|
|
function brandForHost() {
|
|
return typeof window !== "undefined" ? brandHosts[window.location.hostname] : undefined;
|
|
}
|
|
|
|
/**
|
|
* WHERE THIS BROWSER SIGNS IN: the issuer, the client and the callback
|
|
* `IamProvider` below is configured with. A code is bound to the client that
|
|
* asked for it, so there is one derivation and every sign-in goes through it.
|
|
*/
|
|
function origin(): {
|
|
serverUrl: string;
|
|
proxyBaseUrl: string;
|
|
clientId: string;
|
|
redirectUri: string;
|
|
postLogoutRedirectUri: string;
|
|
organization: string;
|
|
} {
|
|
const here = typeof window !== "undefined" ? window.location.origin : "https://hanzo.ai";
|
|
const clientId = clientForHost();
|
|
const isLocal =
|
|
typeof window !== "undefined" &&
|
|
(window.location.hostname === "localhost" ||
|
|
window.location.hostname === "127.0.0.1");
|
|
const serverUrl =
|
|
brandForHost()?.issuer ?? (process.env.NEXT_PUBLIC_HANZO_IAM_URL || "https://api.hanzo.ai");
|
|
const proxyBaseUrl = isLocal ? here : serverUrl;
|
|
return {
|
|
serverUrl,
|
|
// The token, refresh and userinfo calls go to serverUrl too. Discovery names
|
|
// the issuer's own host for them (api.hanzo.ai serves hanzo.id's document), and
|
|
// the SDK follows discovery unless told otherwise; the authorize page stays
|
|
// where discovery puts it.
|
|
proxyBaseUrl,
|
|
clientId,
|
|
redirectUri: `${here}/auth/callback`,
|
|
// Signing out comes back to the sign-in page on this site, not to a page on hanzo.id.
|
|
postLogoutRedirectUri: `${here}/login?from=logout`,
|
|
// The org is the client's own first word. Applications are named
|
|
// `<org>-<app>` estate-wide — hanzo-app, hanzo-cloud, lux-chat — so the
|
|
// name already carries it and a second table mapping one to the other
|
|
// would be a table to forget to update. IAM's credential flow needs it.
|
|
organization: clientId.split("-")[0],
|
|
};
|
|
}
|
|
|
|
function clientForHost(): string {
|
|
if (process.env.NEXT_PUBLIC_HANZO_CLIENT_ID)
|
|
return process.env.NEXT_PUBLIC_HANZO_CLIENT_ID;
|
|
const brand = brandForHost();
|
|
if (brand) return brand.client;
|
|
if (typeof window !== "undefined" && cloudHosts.has(window.location.hostname))
|
|
return "hanzo-cloud";
|
|
// hanzo-app is the universal client accepted by api.hanzo.ai gateway
|
|
// and configured with hanzo.ai callback URLs in IAM.
|
|
return CLIENT;
|
|
}
|
|
|
|
/**
|
|
* Client providers. All telemetry is `@hanzo/event` posting to /v1/event; the
|
|
* analytics, insights and error dashboards are lenses cloud resolves over that
|
|
* one stream. `AnalyticsProvider` fires the first pageview, `<Errors/>` reports
|
|
* the unhandled errors that are this site's, `<Pageview/>` counts route changes,
|
|
* `ErrorBoundary` catches React render errors, which never reach window.onerror.
|
|
*
|
|
* `<ObserveProvider>` rides the same client through context and adds interaction
|
|
* capture ($click/$input/$change/$submit) with input values redacted.
|
|
* `nav={false}` leaves history alone, since `<Pageview/>` already counts routes.
|
|
*/
|
|
export function Providers({ children }: { children: ReactNode }) {
|
|
sanitizeStaleAudience();
|
|
carry();
|
|
// Before any room reads storage: a browser another person left behind is
|
|
// emptied of their conversations, contacts and settings (lib/auth/session.ts).
|
|
own(subject());
|
|
return (
|
|
<AnalyticsProvider
|
|
config={{
|
|
product: "site",
|
|
host: getEventHost(),
|
|
captureErrors: false,
|
|
}}
|
|
>
|
|
<ObserveProvider nav={false}>
|
|
<Errors />
|
|
<Pageview />
|
|
<Tags />
|
|
<ErrorBoundary fallback={Crashed}>
|
|
<IamProvider
|
|
config={{
|
|
...origin(),
|
|
// WHERE THE SESSION LIVES is the SDK's decision, and this used to
|
|
// override it with `sessionStorage` — which is per-TAB. Signing in
|
|
// was thrown away when the tab closed and was invisible to every
|
|
// other tab, so a link out, a middle-click or a restored window
|
|
// arrived signed out and asked again. The SDK resolves
|
|
// localStorage (shared by every tab, outlives it), probes that the
|
|
// browser will actually take a write, and falls back to memory for
|
|
// the prerender and for a profile that refuses storage. All three
|
|
// cases, already handled, once.
|
|
}}
|
|
>
|
|
<Identity />
|
|
{/* A support session's mark and its end (components/auth/Impersonation.tsx). */}
|
|
<Impersonation />
|
|
{/* The reader's look — theme, type, corners, accent — applied on
|
|
every page and kept in step with their IAM account; not in a
|
|
support session, where each sync is a write as the person. */}
|
|
<Personal>
|
|
<Look />
|
|
</Personal>
|
|
<Language>
|
|
<Consent />
|
|
<SupportBanner />
|
|
{/* Every @hanzo/ui control that takes an internal `href` routes
|
|
through Next's link, so it navigates client-side. */}
|
|
<Link value={NextLink}>
|
|
{children}
|
|
{/* Help and preferences, at the start corner of every site page
|
|
(components/shell/help.tsx). After the page, so it paints over
|
|
a band the page fixes on the same dock rung (/start's). */}
|
|
<Help />
|
|
</Link>
|
|
</Language>
|
|
<Hydrated />
|
|
</IamProvider>
|
|
</ErrorBoundary>
|
|
</ObserveProvider>
|
|
</AnalyticsProvider>
|
|
);
|
|
}
|