@the-portland-company/shell 0.75.1 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -550,12 +550,16 @@ function useNavChrome(component) {
550
550
  }
551
551
  return ctx;
552
552
  }
553
- function useChromeOrgs() {
553
+ function useChromeOrgs({ enabled = true } = {}) {
554
554
  const { dedupedFetch } = useNavChrome("useChromeOrgs");
555
555
  const [orgs, setOrgs] = react.useState(null);
556
- const [loading, setLoading] = react.useState(true);
556
+ const [loading, setLoading] = react.useState(enabled);
557
557
  const [error, setError] = react.useState(false);
558
558
  react.useEffect(() => {
559
+ if (!enabled) {
560
+ setLoading(false);
561
+ return;
562
+ }
559
563
  let cancelled = false;
560
564
  setLoading(true);
561
565
  setError(false);
@@ -571,7 +575,7 @@ function useChromeOrgs() {
571
575
  return () => {
572
576
  cancelled = true;
573
577
  };
574
- }, [dedupedFetch]);
578
+ }, [dedupedFetch, enabled]);
575
579
  return { orgs, loading, error };
576
580
  }
577
581
  function ValueSkeleton({ className }) {
@@ -1335,10 +1339,14 @@ function composeSupportMessage(input) {
1335
1339
  ---
1336
1340
  ${ctx.join("\n")}`;
1337
1341
  }
1338
- function SupportAction({ orgId } = {}) {
1342
+ function SupportAction({
1343
+ orgId,
1344
+ orgs: knownOrgs
1345
+ } = {}) {
1339
1346
  const { fetchJson } = useNavChrome("SupportAction");
1340
1347
  const { me, loaded } = useChromeMe();
1341
- const { orgs } = useChromeOrgs();
1348
+ const { orgs: fetchedOrgs } = useChromeOrgs({ enabled: !knownOrgs });
1349
+ const orgs = knownOrgs ?? fetchedOrgs;
1342
1350
  const [open, setOpen] = react.useState(false);
1343
1351
  const [message, setMessage] = react.useState("");
1344
1352
  const [email, setEmail] = react.useState("");
@@ -2896,7 +2904,7 @@ function NavSidebar({
2896
2904
  "div",
2897
2905
  {
2898
2906
  "data-shell-meta": true,
2899
- className: `mt-1.5 flex flex-col items-center gap-0.5 font-[family-name:var(--font-mono,ui-monospace,monospace)] text-[11px] text-[color:var(--sidebar-meta-text,rgba(255,255,255,0.4))] ${rc("max-md:hidden")}`,
2907
+ className: `mt-1.5 flex flex-col items-center gap-0.5 font-[family-name:var(--font-mono,ui-monospace,monospace)] text-[11px] text-[color:var(--sidebar-meta-text,rgba(255,255,255,0.72))] ${rc("max-md:hidden")}`,
2900
2908
  children: [
2901
2909
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1.5", children: [
2902
2910
  footer.envLabel ? shortEnvLabel(footer.envLabel) : null,
@@ -3855,6 +3863,85 @@ function resolveActiveOrg(search) {
3855
3863
  if (fromUrl) return fromUrl;
3856
3864
  return readActiveOrgCookie();
3857
3865
  }
3866
+ var ORG_ID_QUERY_PARAM = "org_id";
3867
+ var ORG_ID_TAIL_LENGTH = 7;
3868
+ var LEGACY_ORG_TAIL_LENGTH = 6;
3869
+ var TAIL_RE = /^[0-9a-f]{6,7}$/i;
3870
+ var AUTH_BASE_URL = "https://auth.politogyvrm.com";
3871
+ var AUTH_BASE_URL_STAGING = "https://auth-staging.politogyvrm.com";
3872
+ function orgIdTail(orgId) {
3873
+ return isOrgId(orgId) ? orgId.trim().toLowerCase().slice(-ORG_ID_TAIL_LENGTH) : null;
3874
+ }
3875
+ function isOrgSelector(v) {
3876
+ return typeof v === "string" && (UUID_RE.test(v.trim()) || TAIL_RE.test(v.trim()));
3877
+ }
3878
+ function matchOrgSelector(selector, orgs) {
3879
+ if (!isOrgSelector(selector)) return null;
3880
+ const sel = selector.trim().toLowerCase();
3881
+ if (UUID_RE.test(sel)) return orgs.find((o) => o.id.toLowerCase() === sel) ?? null;
3882
+ if (sel.length !== ORG_ID_TAIL_LENGTH && sel.length !== LEGACY_ORG_TAIL_LENGTH) return null;
3883
+ const hits = orgs.filter((o) => o.id.toLowerCase().endsWith(sel));
3884
+ return hits.length === 1 ? hits[0] : null;
3885
+ }
3886
+ function readOrgSelectorFromUrl(search) {
3887
+ if (!search) return null;
3888
+ try {
3889
+ const qs = search.includes("?") ? search.slice(search.indexOf("?") + 1) : search;
3890
+ const params = new URLSearchParams(qs);
3891
+ for (const key of [ORG_ID_QUERY_PARAM, ACTIVE_ORG_QUERY_PARAM]) {
3892
+ const v = params.get(key);
3893
+ if (isOrgSelector(v)) return v.trim().toLowerCase();
3894
+ }
3895
+ return null;
3896
+ } catch {
3897
+ return null;
3898
+ }
3899
+ }
3900
+ function orgClaimsFrom(claims) {
3901
+ if (!claims || typeof claims !== "object") return null;
3902
+ const c = claims;
3903
+ if (!isOrgId(c.org_id)) return null;
3904
+ return {
3905
+ orgId: c.org_id.trim().toLowerCase(),
3906
+ orgRole: typeof c.org_role === "string" ? c.org_role : null
3907
+ };
3908
+ }
3909
+ function authBaseUrl() {
3910
+ if (typeof window !== "undefined" && window.location.hostname.toLowerCase().includes("staging")) {
3911
+ return AUTH_BASE_URL_STAGING;
3912
+ }
3913
+ return AUTH_BASE_URL;
3914
+ }
3915
+ var ActiveOrgSwitchError = class extends Error {
3916
+ constructor(status, message) {
3917
+ super(message);
3918
+ this.status = status;
3919
+ this.name = "ActiveOrgSwitchError";
3920
+ }
3921
+ };
3922
+ async function switchActiveOrg(orgId, opts = {}) {
3923
+ if (!isOrgId(orgId)) throw new ActiveOrgSwitchError(400, "org_id must be a full uuid");
3924
+ const doFetch = opts.fetch ?? fetch;
3925
+ const res = await doFetch(`${opts.authBaseUrl ?? authBaseUrl()}/api/active-org`, {
3926
+ method: "POST",
3927
+ credentials: "include",
3928
+ headers: { "Content-Type": "application/json" },
3929
+ body: JSON.stringify({ org_id: orgId })
3930
+ });
3931
+ if (!res.ok) {
3932
+ let msg = `active-org switch failed (${res.status})`;
3933
+ try {
3934
+ const body = await res.json();
3935
+ if (body?.error) msg = body.error;
3936
+ } catch {
3937
+ }
3938
+ throw new ActiveOrgSwitchError(res.status, msg);
3939
+ }
3940
+ const claims = orgClaimsFrom(await res.json().catch(() => null));
3941
+ if (!claims) throw new ActiveOrgSwitchError(502, "active-org switch returned no org_id");
3942
+ writeActiveOrgCookie(claims.orgId);
3943
+ return claims;
3944
+ }
3858
3945
 
3859
3946
  // src/lib/breadcrumbs.ts
3860
3947
  function deriveBreadcrumbs(items, pathname, opts) {
@@ -4355,7 +4442,9 @@ function LoadingScreen({
4355
4442
  padding: 0,
4356
4443
  border: "none",
4357
4444
  background: "transparent",
4358
- color: "rgba(255,255,255,0.38)",
4445
+ // 0.38 alpha was 3.51:1 on the ink-900 loading background —
4446
+ // below WCAG 2.2 AA 4.5:1. 0.72 clears it.
4447
+ color: "rgba(255,255,255,0.72)",
4359
4448
  fontFamily: "var(--font-family, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif)",
4360
4449
  fontSize: "12.5px",
4361
4450
  textDecoration: "underline",
@@ -4382,7 +4471,9 @@ function LoadingScreen({
4382
4471
  padding: "0 32px 30px",
4383
4472
  fontFamily: "var(--font-family, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif)",
4384
4473
  fontSize: "12.5px",
4385
- color: "rgba(255,255,255,0.38)"
4474
+ // 0.38 alpha was 3.51:1 on the ink-900 loading background — below
4475
+ // WCAG 2.2 AA 4.5:1. 0.72 clears it.
4476
+ color: "rgba(255,255,255,0.72)"
4386
4477
  },
4387
4478
  children: [
4388
4479
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Politogy VRM. Built for the long game." }),
@@ -4450,11 +4541,17 @@ var POLITOGY_APPS = {
4450
4541
  storagePrefix: "contacts",
4451
4542
  pageTitle: false
4452
4543
  },
4453
- forms: { title: "Forms", origin: "https://forms.politogyvrm.com", storagePrefix: "forms" },
4454
- surveys: { title: "Surveys", origin: "https://surveys.politogyvrm.com", storagePrefix: "surveys" },
4544
+ // 'title' for forms/surveys/polls/ctas for the same reason as petitions
4545
+ // below: their pages have real names ("Volunteer Signup", "Q3 Satisfaction"),
4546
+ // where the registry can only say the app's own name. Without the grant the
4547
+ // page header falls back to the static registry title forever, so the
4548
+ // designed "<mode> mode" eyebrow + H2 + subtitle block never gets the real
4549
+ // per-page title an app passes it (UI-Platform → Page header).
4550
+ forms: { title: "Forms", origin: "https://forms.politogyvrm.com", storagePrefix: "forms", grants: ["title"] },
4551
+ surveys: { title: "Surveys", origin: "https://surveys.politogyvrm.com", storagePrefix: "surveys", grants: ["title"] },
4455
4552
  // Specs/Left-Navigation §9.2: Polling is a Campaign row (Research).
4456
- polls: { title: "Polls", origin: "https://polls.politogyvrm.com", storagePrefix: "polls", mode: "campaign" },
4457
- ctas: { title: "CTAs", origin: "https://ctas.politogyvrm.com", storagePrefix: "ctas" },
4553
+ polls: { title: "Polls", origin: "https://polls.politogyvrm.com", storagePrefix: "polls", mode: "campaign", grants: ["title"] },
4554
+ ctas: { title: "CTAs", origin: "https://ctas.politogyvrm.com", storagePrefix: "ctas", grants: ["title"] },
4458
4555
  // V2 apps that replace retired app.politogyvrm.com pages (Reference/
4459
4556
  // UI-Platform v42 "Retirement of app.politogyvrm.com"). Dashboard hosts the
4460
4557
  // Relationship Dashboard + Campaign Battleground/District data/Campaigns
@@ -4478,7 +4575,7 @@ var POLITOGY_APPS = {
4478
4575
  },
4479
4576
  // Relationship Mode Email Engine (Specs/Email-Engine) — the V2 standalone
4480
4577
  // app, running in parallel to VRM core's V1 Email Broadcast.
4481
- email: { title: "Email", origin: "https://email.politogyvrm.com", storagePrefix: "email", mode: "relationship" },
4578
+ email: { title: "Email", origin: "https://email.politogyvrm.com", storagePrefix: "email", mode: "relationship", grants: ["title"] },
4482
4579
  // Campaign Mode apps — each scoped to Campaign, so they render the Campaign
4483
4580
  // tree and a static Campaign pill. Not yet deployed; hidden from the Campaign
4484
4581
  // nav by CAMPAIGN_FEATURE_AVAILABILITY until their launch flips the flag.
@@ -4491,11 +4588,12 @@ var POLITOGY_APPS = {
4491
4588
  origin: "https://a2p.politogyvrm.com",
4492
4589
  storagePrefix: "a2p",
4493
4590
  mode: "campaign",
4494
- dual: { modes: ["relationship", "campaign"], defaultMode: "campaign" }
4591
+ dual: { modes: ["relationship", "campaign"], defaultMode: "campaign" },
4592
+ grants: ["title"]
4495
4593
  },
4496
- phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign" },
4497
- canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign" },
4498
- signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign" }
4594
+ phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign", grants: ["title"] },
4595
+ canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign", grants: ["title"] },
4596
+ signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign", grants: ["title"] }
4499
4597
  };
4500
4598
  var DEFAULT_SEARCH_PLACEHOLDER = {
4501
4599
  contacts: "Search contacts, segments, forms"
@@ -4522,6 +4620,7 @@ function PolitogyAppFrame({
4522
4620
  fetchJson: data?.fetchJson,
4523
4621
  devNotes: data?.devNotes,
4524
4622
  org: data?.org,
4623
+ tokenOrg: data?.tokenOrg,
4525
4624
  identity: {
4526
4625
  userId: session.userId,
4527
4626
  userName: session.userName ?? null,
@@ -4802,6 +4901,107 @@ var ChromeStateContext = react.createContext(null);
4802
4901
  function useChromeState() {
4803
4902
  return react.useContext(ChromeStateContext);
4804
4903
  }
4904
+ function TokenChromeStateProvider({
4905
+ tokenOrg,
4906
+ children
4907
+ }) {
4908
+ const { orgs: fetchedOrgs, loading: orgsFetching, error: orgsError } = useChromeOrgs({
4909
+ enabled: !tokenOrg.orgs
4910
+ });
4911
+ const [navDisplay, setNavDisplayState] = react.useState(NAV_DISPLAY_DEFAULT);
4912
+ const [pendingOrg, setPendingOrg] = react.useState(null);
4913
+ const tokenOrgRef = react.useRef(tokenOrg);
4914
+ tokenOrgRef.current = tokenOrg;
4915
+ react.useEffect(() => {
4916
+ setNavDisplayState(resolveNavDisplay());
4917
+ }, []);
4918
+ const orgs = react.useMemo(
4919
+ () => tokenOrg.orgs ?? (fetchedOrgs ?? []).map((o) => ({
4920
+ id: o.id,
4921
+ name: o.name,
4922
+ logoUrl: o.logoUrl,
4923
+ brandColor: o.brandColor,
4924
+ role: o.role,
4925
+ rawRole: o.rawRole ?? null
4926
+ })),
4927
+ [tokenOrg.orgs, fetchedOrgs]
4928
+ );
4929
+ const [urlSelector] = react.useState(
4930
+ () => typeof window === "undefined" ? null : readOrgSelectorFromUrl(window.location.search)
4931
+ );
4932
+ const overrideOrg = react.useMemo(
4933
+ () => urlSelector ? matchOrgSelector(urlSelector, orgs)?.id ?? null : null,
4934
+ [urlSelector, orgs]
4935
+ );
4936
+ const orgId = pendingOrg ?? (tokenOrg.orgId && overrideOrg ? overrideOrg : tokenOrg.orgId);
4937
+ const reexchangedFor = react.useRef(null);
4938
+ react.useEffect(() => {
4939
+ if (!overrideOrg || !tokenOrg.orgId || overrideOrg === tokenOrg.orgId) return;
4940
+ if (reexchangedFor.current === overrideOrg) return;
4941
+ reexchangedFor.current = overrideOrg;
4942
+ void Promise.resolve(tokenOrgRef.current.onReexchange(overrideOrg, { perTab: true })).catch(
4943
+ () => {
4944
+ }
4945
+ );
4946
+ }, [overrideOrg, tokenOrg.orgId]);
4947
+ react.useEffect(() => {
4948
+ if (pendingOrg && tokenOrg.orgId === pendingOrg) setPendingOrg(null);
4949
+ }, [pendingOrg, tokenOrg.orgId]);
4950
+ const setOrgId = react.useCallback((id) => {
4951
+ const current = tokenOrgRef.current;
4952
+ if (!isOrgId(id) || id === (current.orgId ?? null)) return;
4953
+ setPendingOrg(id);
4954
+ void (async () => {
4955
+ try {
4956
+ const claims = await switchActiveOrg(id, { authBaseUrl: current.authBaseUrl });
4957
+ try {
4958
+ const url = new URL(window.location.href);
4959
+ url.searchParams.delete(ACTIVE_ORG_QUERY_PARAM);
4960
+ url.searchParams.set(ORG_ID_QUERY_PARAM, orgIdTail(claims.orgId));
4961
+ window.history.replaceState(window.history.state, "", url.toString());
4962
+ } catch {
4963
+ }
4964
+ await tokenOrgRef.current.onReexchange(claims.orgId, { perTab: false });
4965
+ } catch (err) {
4966
+ setPendingOrg(null);
4967
+ tokenOrgRef.current.onSwitchError?.(err);
4968
+ }
4969
+ })();
4970
+ }, []);
4971
+ const setNavDisplay = react.useCallback((m) => {
4972
+ setNavDisplayState(m);
4973
+ writeNavDisplayLocal(m);
4974
+ writeNavDisplayCookie(m);
4975
+ }, []);
4976
+ react.useEffect(() => {
4977
+ if (!isOrgId(orgId)) return;
4978
+ try {
4979
+ const url = new URL(window.location.href);
4980
+ const tail = orgIdTail(orgId);
4981
+ if (url.searchParams.has(ACTIVE_ORG_QUERY_PARAM) || url.searchParams.get(ORG_ID_QUERY_PARAM) !== tail) {
4982
+ url.searchParams.delete(ACTIVE_ORG_QUERY_PARAM);
4983
+ url.searchParams.set(ORG_ID_QUERY_PARAM, tail);
4984
+ window.history.replaceState(window.history.state, "", url.toString());
4985
+ }
4986
+ } catch {
4987
+ }
4988
+ }, [orgId]);
4989
+ react.useEffect(() => {
4990
+ if (isOrgId(tokenOrg.orgId) && !pendingOrg) writeActiveOrgCookie(tokenOrg.orgId);
4991
+ }, [tokenOrg.orgId, pendingOrg]);
4992
+ const value = react.useMemo(
4993
+ () => ({
4994
+ orgId,
4995
+ setOrgId,
4996
+ orgs,
4997
+ orgsLoading: !tokenOrg.orgs && orgsFetching && !orgsError && !fetchedOrgs,
4998
+ navDisplay,
4999
+ setNavDisplay
5000
+ }),
5001
+ [orgId, setOrgId, orgs, tokenOrg.orgs, orgsFetching, orgsError, fetchedOrgs, navDisplay, setNavDisplay]
5002
+ );
5003
+ return /* @__PURE__ */ jsxRuntime.jsx(ChromeStateContext.Provider, { value, children });
5004
+ }
4805
5005
  function ChromeStateProvider({
4806
5006
  controlledOrg,
4807
5007
  children
@@ -5105,6 +5305,7 @@ function PolitogyAppChrome({
5105
5305
  fetchJson: fetchJsonOverride,
5106
5306
  devNotes,
5107
5307
  org,
5308
+ tokenOrg,
5108
5309
  identity,
5109
5310
  children
5110
5311
  }) {
@@ -5123,7 +5324,7 @@ function PolitogyAppChrome({
5123
5324
  [proxyBasePath]
5124
5325
  );
5125
5326
  const fetchJson = fetchJsonOverride ?? proxyFetchJson;
5126
- return /* @__PURE__ */ jsxRuntime.jsx(NavChromeProvider, { fetchJson, children: /* @__PURE__ */ jsxRuntime.jsx(ChromeStateProvider, { controlledOrg: org, children: /* @__PURE__ */ jsxRuntime.jsx(
5327
+ const devNotesTree = /* @__PURE__ */ jsxRuntime.jsx(
5127
5328
  AppDevNotes,
5128
5329
  {
5129
5330
  storagePrefix,
@@ -5132,7 +5333,8 @@ function PolitogyAppChrome({
5132
5333
  identity,
5133
5334
  children
5134
5335
  }
5135
- ) }) });
5336
+ );
5337
+ return /* @__PURE__ */ jsxRuntime.jsx(NavChromeProvider, { fetchJson, children: tokenOrg ? /* @__PURE__ */ jsxRuntime.jsx(TokenChromeStateProvider, { tokenOrg, children: devNotesTree }) : /* @__PURE__ */ jsxRuntime.jsx(ChromeStateProvider, { controlledOrg: org, children: devNotesTree }) });
5136
5338
  }
5137
5339
  function ChromeTopBarActions() {
5138
5340
  const state = useChromeState();
@@ -5149,7 +5351,7 @@ function ChromeTopBarActions() {
5149
5351
  /* @__PURE__ */ jsxRuntime.jsx(FuelAction, { orgId: state.orgId }),
5150
5352
  /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId, orgName: state.orgs.find((o) => o.id === state.orgId)?.name ?? null }),
5151
5353
  /* @__PURE__ */ jsxRuntime.jsx(NotificationsAction, { orgId: state.orgId }),
5152
- /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId })
5354
+ /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId, orgs: state.orgs.length ? state.orgs : void 0 })
5153
5355
  ] });
5154
5356
  }
5155
5357
  function ChromeDevNotesMenu() {