@poly-x/react 0.2.0 → 0.3.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.
- package/README.md +98 -2
- package/dist/index.cjs +248 -3
- package/dist/index.d.cts +122 -2
- package/dist/index.d.mts +122 -2
- package/dist/index.mjs +243 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -72,11 +72,107 @@ and it lives in the PolyX console with the permission model and audit trail.
|
|
|
72
72
|
Email, username and password are likewise excluded: each has its own verification
|
|
73
73
|
path that a profile form must not route around.
|
|
74
74
|
|
|
75
|
+
## Signing in silently, and switching workspace
|
|
76
|
+
|
|
77
|
+
Someone already signed into another application on the same domain should not be asked for a
|
|
78
|
+
password again. `useWarmHop()` tries that once when your sign-in screen mounts:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { useWarmHop, SignIn } from "@poly-x/react";
|
|
82
|
+
|
|
83
|
+
export default function SignInPage() {
|
|
84
|
+
const hop = useWarmHop();
|
|
85
|
+
if (hop.status !== "settled") return null; // never a half-drawn form
|
|
86
|
+
if (hop.result.status === "signed_in") router.replace("/");
|
|
87
|
+
return <SignIn />; // the ordinary path
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A failed attempt is invisible on purpose. Someone who is genuinely signed out pays the whole
|
|
92
|
+
cost of this and gains nothing from it, so there is a hard timeout, nothing in the flow
|
|
93
|
+
navigates, and every failure — offline, slow platform, unexpected response — ends at "show the
|
|
94
|
+
form" rather than at an exception or a spinner.
|
|
95
|
+
|
|
96
|
+
The attempt runs **once per tab**. The marker lives in `sessionStorage` rather than component
|
|
97
|
+
state because the fall-back renders the sign-in page, which is a route change in most apps — a
|
|
98
|
+
guard in state would reset at exactly the moment it needs to hold. Call `resetWarmHop()` after
|
|
99
|
+
signing out so the next sign-in may try again.
|
|
100
|
+
|
|
101
|
+
### Switching workspace
|
|
102
|
+
|
|
103
|
+
Someone with access to several workspaces can move between them without a password:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { switchWorkspace } from "@poly-x/react";
|
|
107
|
+
|
|
108
|
+
const result = await switchWorkspace(tenantId);
|
|
109
|
+
if (result.status === "signed_in") router.refresh(); // now scoped to that workspace
|
|
110
|
+
if (result.status === "no_access") showNoAccess();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When someone has more than one workspace and none was named, the settled result is
|
|
114
|
+
`{ status: "select_workspace", workspaces }` — the SDK never picks for you, because putting
|
|
115
|
+
someone to work in the wrong tenant without their noticing is worse than asking:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
const hop = useWarmHop();
|
|
119
|
+
if (hop.status === "settled" && hop.result.status === "select_workspace") {
|
|
120
|
+
return <WorkspacePicker options={hop.result.workspaces} onPick={switchWorkspace} />;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Entitlement is decided **by the platform at the moment of the switch**, not from the list you are
|
|
125
|
+
holding, so a workspace revoked since you drew the picker never lets them in. What you get back
|
|
126
|
+
depends on what is left: still hold other workspaces and you get `select_workspace` listing them;
|
|
127
|
+
lost them all and you get `no_access`. Either way, not entry — so do not cache the decision in
|
|
128
|
+
front of it.
|
|
129
|
+
|
|
130
|
+
### When the workspace you asked for is quietly ignored
|
|
131
|
+
|
|
132
|
+
Worth knowing, because success is not always what it looks like. PolyX only honours a workspace
|
|
133
|
+
hint when the person has **more than one** workspace for that application. With exactly one, it
|
|
134
|
+
ignores the hint and seats them there anyway — answering an ordinary success. Nobody gains access
|
|
135
|
+
they are not entitled to, but they can end up **working in the wrong tenant without noticing**,
|
|
136
|
+
which is the failure the picker exists to prevent.
|
|
137
|
+
|
|
138
|
+
The SDK will not relay that as plain success. Pass a **tenant id** and land somewhere else, and you
|
|
139
|
+
get `workspace_mismatch` carrying both:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
const result = await switchWorkspace(tenantId);
|
|
143
|
+
if (result.status === "workspace_mismatch") {
|
|
144
|
+
// result.requested — what you asked for
|
|
145
|
+
// result.workspace — where they actually are now
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The session is left in place: it is valid, and you may be perfectly happy with it. What to do about
|
|
150
|
+
it is your call, not the SDK's.
|
|
151
|
+
|
|
152
|
+
An **organization code or slug** cannot be compared against what a session carries, so no mismatch
|
|
153
|
+
is claimed for those — guessing would invent failures. Instead **every** successful result names
|
|
154
|
+
the workspace actually entered, so you can check for yourself:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
if (result.status === "signed_in" && result.workspace !== expected) { /* … */ }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Switching does not navigate or reload — the SDK cannot know what is unsaved on your screen, so
|
|
161
|
+
what happens next is yours to decide.
|
|
162
|
+
|
|
163
|
+
### The boundary
|
|
164
|
+
|
|
165
|
+
Silent sign-on works between applications that **share the ecosystem cookie's domain**.
|
|
166
|
+
Browsers block third-party cookies by default, so an application on an unrelated domain falls
|
|
167
|
+
back to ordinary sign-in. That is browser policy, not something to configure around.
|
|
168
|
+
*Switching workspace is unaffected* — it happens inside one application.
|
|
169
|
+
|
|
75
170
|
## API
|
|
76
171
|
|
|
77
172
|
`PolyXProvider` · `useAuth` (`isLoaded`, `isSignedIn`, `signIn`, `signOut`,
|
|
78
173
|
`getToken`) · `useUser` · `useSession` · `<SignIn>` · `<Protected>` ·
|
|
79
|
-
`<UserButton>` · `<UserProfile>` · `<AuthCallback
|
|
80
|
-
|
|
174
|
+
`<UserButton>` · `<UserProfile>` · `<AuthCallback>` · `useWarmHop` ·
|
|
175
|
+
`attemptWarmHop` · `switchWorkspace` · `resetWarmHop`. Sign-in defaults to a
|
|
176
|
+
popup; pass `mode="redirect"` for a full-page flow.
|
|
81
177
|
|
|
82
178
|
MIT licensed.
|
package/dist/index.cjs
CHANGED
|
@@ -200,7 +200,7 @@ var BroadcastChannelSessionChannel = class {
|
|
|
200
200
|
//#endregion
|
|
201
201
|
//#region src/seams/session-storage-pkce.ts
|
|
202
202
|
const KEY_PREFIX = "poly-x.pkce.";
|
|
203
|
-
function hasSessionStorage() {
|
|
203
|
+
function hasSessionStorage$1() {
|
|
204
204
|
try {
|
|
205
205
|
return typeof sessionStorage !== "undefined";
|
|
206
206
|
} catch {
|
|
@@ -216,7 +216,7 @@ function hasSessionStorage() {
|
|
|
216
216
|
var SessionStoragePkceStore = class {
|
|
217
217
|
fallback;
|
|
218
218
|
constructor() {
|
|
219
|
-
if (!hasSessionStorage()) this.fallback = new _poly_x_core.InMemoryPkceStore();
|
|
219
|
+
if (!hasSessionStorage$1()) this.fallback = new _poly_x_core.InMemoryPkceStore();
|
|
220
220
|
}
|
|
221
221
|
save(entry) {
|
|
222
222
|
if (this.fallback) return this.fallback.save(entry);
|
|
@@ -481,6 +481,198 @@ function getBffSessionStore() {
|
|
|
481
481
|
return store;
|
|
482
482
|
}
|
|
483
483
|
//#endregion
|
|
484
|
+
//#region src/warm-hop.ts
|
|
485
|
+
/**
|
|
486
|
+
* Warm hop — the browser half (F027/F028 · FR-HOP-1…6, FR-FALL-1…3, FR-SWITCH-1…7).
|
|
487
|
+
*
|
|
488
|
+
* The probe is made **from here, by the browser**, and that is not a style choice. The
|
|
489
|
+
* platform resolves the ecosystem session from a cookie on its own domain; a server-side
|
|
490
|
+
* request carries no browser cookie, so it would be told "sign-in required" every time and
|
|
491
|
+
* the whole feature would look built while never working. `@poly-x/core` refuses the
|
|
492
|
+
* server-side call outright for that reason.
|
|
493
|
+
*
|
|
494
|
+
* The three steps, and why the credential stays safe throughout:
|
|
495
|
+
*
|
|
496
|
+
* 1. Ask our own BFF to prepare (`/api/polyx/hop`). It mints PKCE and keeps the verifier
|
|
497
|
+
* in an httpOnly cookie; we receive only a URL.
|
|
498
|
+
* 2. Call that URL with `credentials:"include"` so the browser attaches the ecosystem
|
|
499
|
+
* cookie. This is the one request that must not come from a server.
|
|
500
|
+
* 3. Hand the resulting `code` back to our BFF (`/api/polyx/hop-complete`), which does the
|
|
501
|
+
* exchange. The code is worthless to anyone who intercepts it — the PKCE verifier that
|
|
502
|
+
* redeems it never left the server — so a page holding it briefly is not a custody leak.
|
|
503
|
+
*
|
|
504
|
+
* Nothing here navigates. That matters beyond tidiness: this exact flow produced a redirect
|
|
505
|
+
* loop in a hand-rolled implementation once already, and a fetch cannot loop.
|
|
506
|
+
*/
|
|
507
|
+
const HOP_ENDPOINT = "/api/polyx/hop";
|
|
508
|
+
const HOP_COMPLETE_ENDPOINT = "/api/polyx/hop-complete";
|
|
509
|
+
/**
|
|
510
|
+
* How long to wait for the silent attempt before giving up and showing the form.
|
|
511
|
+
*
|
|
512
|
+
* A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
|
|
513
|
+
* dead platform must never leave someone watching a spinner — and for that, *any* bound
|
|
514
|
+
* beats none. The person this protects is already signed out and gains nothing from
|
|
515
|
+
* waiting, so the bound is deliberately short. Override it if you have measured yours.
|
|
516
|
+
*/
|
|
517
|
+
const WARM_HOP_TIMEOUT_MS = 5e3;
|
|
518
|
+
/**
|
|
519
|
+
* Survives navigation on purpose (FR-FALL-3).
|
|
520
|
+
*
|
|
521
|
+
* The fall-back from a failed attempt renders the sign-in page, which in most apps is a
|
|
522
|
+
* route change — so a guard held in React state or a module variable is reset exactly when
|
|
523
|
+
* it is needed. `sessionStorage` is scoped to the tab and cleared when it closes, which is
|
|
524
|
+
* the same lifetime as "this entry".
|
|
525
|
+
*/
|
|
526
|
+
const ATTEMPTED_KEY = "polyx.warmHop.attempted";
|
|
527
|
+
function hasSessionStorage() {
|
|
528
|
+
try {
|
|
529
|
+
return typeof sessionStorage !== "undefined";
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Has an automatic attempt already run in this tab? */
|
|
535
|
+
function warmHopAttempted() {
|
|
536
|
+
if (!hasSessionStorage()) return false;
|
|
537
|
+
try {
|
|
538
|
+
return sessionStorage.getItem(ATTEMPTED_KEY) === "1";
|
|
539
|
+
} catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function markWarmHopAttempted() {
|
|
544
|
+
if (!hasSessionStorage()) return;
|
|
545
|
+
try {
|
|
546
|
+
sessionStorage.setItem(ATTEMPTED_KEY, "1");
|
|
547
|
+
} catch {}
|
|
548
|
+
}
|
|
549
|
+
/** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
|
|
550
|
+
function resetWarmHop() {
|
|
551
|
+
if (!hasSessionStorage()) return;
|
|
552
|
+
try {
|
|
553
|
+
sessionStorage.removeItem(ATTEMPTED_KEY);
|
|
554
|
+
} catch {}
|
|
555
|
+
}
|
|
556
|
+
async function postJson(url, body, signal) {
|
|
557
|
+
return fetch(url, {
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "content-type": "application/json" },
|
|
560
|
+
body: JSON.stringify(body ?? {}),
|
|
561
|
+
signal,
|
|
562
|
+
credentials: "same-origin"
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Run one silent attempt and report what the platform said.
|
|
567
|
+
*
|
|
568
|
+
* Never throws: every failure resolves to `failed`, because a caller in the middle of
|
|
569
|
+
* rendering a sign-in page has no better answer than "show the form" and an exception
|
|
570
|
+
* there is how a blank screen happens.
|
|
571
|
+
*/
|
|
572
|
+
async function attemptWarmHop(options = {}) {
|
|
573
|
+
if (typeof window === "undefined") return {
|
|
574
|
+
status: "failed",
|
|
575
|
+
reason: "warm hop attempted outside a browser"
|
|
576
|
+
};
|
|
577
|
+
const controller = new AbortController();
|
|
578
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5e3);
|
|
579
|
+
try {
|
|
580
|
+
const prepared = await postJson(HOP_ENDPOINT, options.workspace ? { tenantId: options.workspace } : {}, controller.signal);
|
|
581
|
+
if (!prepared.ok) return {
|
|
582
|
+
status: "failed",
|
|
583
|
+
reason: `hop preparation failed (${prepared.status})`
|
|
584
|
+
};
|
|
585
|
+
const { authorizeUrl } = await prepared.json();
|
|
586
|
+
if (!authorizeUrl) return {
|
|
587
|
+
status: "failed",
|
|
588
|
+
reason: "hop preparation returned no authorize URL"
|
|
589
|
+
};
|
|
590
|
+
const probe = await fetch(authorizeUrl, {
|
|
591
|
+
method: "GET",
|
|
592
|
+
credentials: "include",
|
|
593
|
+
headers: { accept: "application/json" },
|
|
594
|
+
signal: controller.signal
|
|
595
|
+
});
|
|
596
|
+
const body = await probe.json().catch(() => ({}));
|
|
597
|
+
const outcome = (0, _poly_x_core.parseAuthorizeOutcome)(probe.status, body);
|
|
598
|
+
switch (outcome.type) {
|
|
599
|
+
case "login_required": return { status: "sign_in_required" };
|
|
600
|
+
case "no_access": return { status: "no_access" };
|
|
601
|
+
case "select_tenant": return {
|
|
602
|
+
status: "select_workspace",
|
|
603
|
+
workspaces: outcome.tenants
|
|
604
|
+
};
|
|
605
|
+
case "authorized": {
|
|
606
|
+
const completed = await postJson(HOP_COMPLETE_ENDPOINT, {
|
|
607
|
+
code: outcome.code,
|
|
608
|
+
state: outcome.state
|
|
609
|
+
}, controller.signal);
|
|
610
|
+
const settled = await completed.json().catch(() => ({}));
|
|
611
|
+
if (completed.ok) {
|
|
612
|
+
if (settled.status === "workspace_mismatch" && settled.requested && settled.workspace) return {
|
|
613
|
+
status: "workspace_mismatch",
|
|
614
|
+
requested: settled.requested,
|
|
615
|
+
workspace: settled.workspace
|
|
616
|
+
};
|
|
617
|
+
return {
|
|
618
|
+
status: "signed_in",
|
|
619
|
+
...settled.workspace ? { workspace: settled.workspace } : {}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
status: "failed",
|
|
624
|
+
reason: settled.status ?? `exchange failed (${completed.status})`
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return {
|
|
630
|
+
status: "failed",
|
|
631
|
+
reason: error instanceof Error ? error.message : "warm hop failed"
|
|
632
|
+
};
|
|
633
|
+
} finally {
|
|
634
|
+
clearTimeout(timeout);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Move to another workspace (F028 / FR-SWITCH-1…6).
|
|
639
|
+
*
|
|
640
|
+
* The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
|
|
641
|
+
* deliberately **not** subject to the once-per-entry guard, because this one is a thing the
|
|
642
|
+
* person asked for.
|
|
643
|
+
*
|
|
644
|
+
* Eligibility is decided by the platform at this moment, not from any list held here, so a
|
|
645
|
+
* workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
|
|
646
|
+
* refusal you get depends on what is left, verified against a live platform: still holding
|
|
647
|
+
* other workspaces gives `select_workspace` listing them; having lost them all gives
|
|
648
|
+
* `no_access`. Do not treat `no_access` as the only refusal.
|
|
649
|
+
*
|
|
650
|
+
* On `signed_in` the session cookie now names the new workspace. The caller decides what to
|
|
651
|
+
* do next — the SDK does not reload the page or discard anything on their behalf, because it
|
|
652
|
+
* cannot know what is unsaved on the screen (FR-SWITCH-7).
|
|
653
|
+
*/
|
|
654
|
+
async function switchWorkspace(workspace, options = {}) {
|
|
655
|
+
if (!workspace) return {
|
|
656
|
+
status: "failed",
|
|
657
|
+
reason: "no workspace given"
|
|
658
|
+
};
|
|
659
|
+
return attemptWarmHop({
|
|
660
|
+
...options,
|
|
661
|
+
workspace
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
|
|
666
|
+
*
|
|
667
|
+
* Returns `null` when it declines to attempt — already tried this entry — so a caller can
|
|
668
|
+
* tell "we tried and they must sign in" from "we did not try".
|
|
669
|
+
*/
|
|
670
|
+
async function attemptWarmHopOnce(options = {}) {
|
|
671
|
+
if (warmHopAttempted()) return null;
|
|
672
|
+
markWarmHopAttempted();
|
|
673
|
+
return attemptWarmHop(options);
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
484
676
|
//#region src/hooks.ts
|
|
485
677
|
function useSessionSource() {
|
|
486
678
|
const ctx = (0, react.useContext)(PolyXContext);
|
|
@@ -550,6 +742,52 @@ function useUser() {
|
|
|
550
742
|
const state = useSession();
|
|
551
743
|
return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
|
|
552
744
|
}
|
|
745
|
+
/**
|
|
746
|
+
* Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
|
|
747
|
+
*
|
|
748
|
+
* The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
|
|
749
|
+
* is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
|
|
750
|
+
* must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
|
|
751
|
+
* Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
|
|
752
|
+
* change to the sign-in page (a guard in React state would reset exactly when it is needed).
|
|
753
|
+
*
|
|
754
|
+
* ```tsx
|
|
755
|
+
* const hop = useWarmHop();
|
|
756
|
+
* if (hop.status !== "settled" ) return null; // no half-drawn form
|
|
757
|
+
* if (hop.result.status === "signed_in") return <Redirect/>;
|
|
758
|
+
* return <SignIn />; // the ordinary path
|
|
759
|
+
* ```
|
|
760
|
+
*
|
|
761
|
+
* Strict Mode's double-mount does not produce two attempts: the marker is written before the
|
|
762
|
+
* first request goes out.
|
|
763
|
+
*/
|
|
764
|
+
function useWarmHop(options = {}) {
|
|
765
|
+
const { enabled = true, workspace, timeoutMs } = options;
|
|
766
|
+
const [state, setState] = (0, react.useState)({ status: "idle" });
|
|
767
|
+
(0, react.useEffect)(() => {
|
|
768
|
+
if (!enabled) return;
|
|
769
|
+
let live = true;
|
|
770
|
+
setState({ status: "attempting" });
|
|
771
|
+
attemptWarmHopOnce({
|
|
772
|
+
workspace,
|
|
773
|
+
timeoutMs
|
|
774
|
+
}).then((result) => {
|
|
775
|
+
if (!live) return;
|
|
776
|
+
setState({
|
|
777
|
+
status: "settled",
|
|
778
|
+
result: result ?? { status: "sign_in_required" }
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
return () => {
|
|
782
|
+
live = false;
|
|
783
|
+
};
|
|
784
|
+
}, [
|
|
785
|
+
enabled,
|
|
786
|
+
workspace,
|
|
787
|
+
timeoutMs
|
|
788
|
+
]);
|
|
789
|
+
return state;
|
|
790
|
+
}
|
|
553
791
|
//#endregion
|
|
554
792
|
//#region src/components/protected.tsx
|
|
555
793
|
/** Gate content on authentication state without flashing the wrong branch. */
|
|
@@ -2681,7 +2919,7 @@ function useAuthzChanged(callback) {
|
|
|
2681
2919
|
*/
|
|
2682
2920
|
const PACKAGE_NAME = "@poly-x/react";
|
|
2683
2921
|
/** The published version of this package, injected at build time. */
|
|
2684
|
-
const version = "0.
|
|
2922
|
+
const version = "0.3.0";
|
|
2685
2923
|
//#endregion
|
|
2686
2924
|
exports.AuthCallback = AuthCallback;
|
|
2687
2925
|
exports.BroadcastAuthChannel = BroadcastAuthChannel;
|
|
@@ -2698,18 +2936,25 @@ exports.SignIn = SignIn;
|
|
|
2698
2936
|
exports.UserAvatar = UserAvatar;
|
|
2699
2937
|
exports.UserButton = UserButton;
|
|
2700
2938
|
exports.UserProfile = UserProfile;
|
|
2939
|
+
exports.WARM_HOP_TIMEOUT_MS = WARM_HOP_TIMEOUT_MS;
|
|
2701
2940
|
exports.WebLocksLock = WebLocksLock;
|
|
2702
2941
|
exports.WindowBrowserBridge = WindowBrowserBridge;
|
|
2942
|
+
exports.attemptWarmHop = attemptWarmHop;
|
|
2943
|
+
exports.attemptWarmHopOnce = attemptWarmHopOnce;
|
|
2703
2944
|
exports.capturePreciseLocation = capturePreciseLocation;
|
|
2704
2945
|
exports.consumeAuthEvent = consumeAuthEvent;
|
|
2705
2946
|
exports.createLoginController = createLoginController;
|
|
2706
2947
|
exports.initialsFrom = initialsFrom;
|
|
2948
|
+
exports.resetWarmHop = resetWarmHop;
|
|
2707
2949
|
exports.resolveSide = resolveSide;
|
|
2708
2950
|
exports.startLiveAuthz = startLiveAuthz;
|
|
2951
|
+
exports.switchWorkspace = switchWorkspace;
|
|
2709
2952
|
exports.useAuth = useAuth;
|
|
2710
2953
|
exports.useAuthCallback = useAuthCallback;
|
|
2711
2954
|
exports.useAuthzChanged = useAuthzChanged;
|
|
2712
2955
|
exports.useLiveAuthz = useLiveAuthz;
|
|
2713
2956
|
exports.useSession = useSession;
|
|
2714
2957
|
exports.useUser = useUser;
|
|
2958
|
+
exports.useWarmHop = useWarmHop;
|
|
2715
2959
|
exports.version = version;
|
|
2960
|
+
exports.warmHopAttempted = warmHopAttempted;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
|
-
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
|
|
2
|
+
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState, TenantOption } from "@poly-x/core";
|
|
3
3
|
|
|
4
4
|
//#region src/provider.d.ts
|
|
5
5
|
interface PolyXProviderProps {
|
|
@@ -122,6 +122,95 @@ declare class PopupCancelledError extends Error {
|
|
|
122
122
|
}
|
|
123
123
|
declare function createLoginController(deps: LoginControllerDeps): LoginController;
|
|
124
124
|
//#endregion
|
|
125
|
+
//#region src/warm-hop.d.ts
|
|
126
|
+
/**
|
|
127
|
+
* How long to wait for the silent attempt before giving up and showing the form.
|
|
128
|
+
*
|
|
129
|
+
* A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
|
|
130
|
+
* dead platform must never leave someone watching a spinner — and for that, *any* bound
|
|
131
|
+
* beats none. The person this protects is already signed out and gains nothing from
|
|
132
|
+
* waiting, so the bound is deliberately short. Override it if you have measured yours.
|
|
133
|
+
*/
|
|
134
|
+
declare const WARM_HOP_TIMEOUT_MS = 5000;
|
|
135
|
+
type WarmHopResult = /** Signed in. The session cookie is set; `workspace` names the tenant actually entered. */{
|
|
136
|
+
status: "signed_in";
|
|
137
|
+
workspace?: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Signed in — but **not** where you asked.
|
|
141
|
+
*
|
|
142
|
+
* The platform only honours a workspace hint when the person has more than one candidate for
|
|
143
|
+
* that application; with exactly one it seats them there regardless and still answers
|
|
144
|
+
* "authorized". The session is valid and the person is somewhere they are entitled to be, so
|
|
145
|
+
* this is not a failure — but it is not what was requested, and being silently put to work in
|
|
146
|
+
* the wrong tenant is worse than being asked. Decide deliberately: accept it, or send them to
|
|
147
|
+
* a picker.
|
|
148
|
+
*/
|
|
149
|
+
| {
|
|
150
|
+
status: "workspace_mismatch";
|
|
151
|
+
requested: string;
|
|
152
|
+
workspace: string;
|
|
153
|
+
} /** Several workspaces are available and none was requested — ask, never guess (D14). */ | {
|
|
154
|
+
status: "select_workspace";
|
|
155
|
+
workspaces: readonly TenantOption[];
|
|
156
|
+
} /** Recognised, but not entitled to this application or workspace (D15). */ | {
|
|
157
|
+
status: "no_access";
|
|
158
|
+
} /** Not recognised. The ordinary signed-out path — not an error. */ | {
|
|
159
|
+
status: "sign_in_required";
|
|
160
|
+
} /** The attempt could not be completed. Treated as signed-out by callers; surfaced for logs. */ | {
|
|
161
|
+
status: "failed";
|
|
162
|
+
reason: string;
|
|
163
|
+
};
|
|
164
|
+
/** Has an automatic attempt already run in this tab? */
|
|
165
|
+
declare function warmHopAttempted(): boolean;
|
|
166
|
+
/** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
|
|
167
|
+
declare function resetWarmHop(): void;
|
|
168
|
+
interface WarmHopOptions {
|
|
169
|
+
/**
|
|
170
|
+
* Pin a workspace by tenant id, organization code or slug.
|
|
171
|
+
*
|
|
172
|
+
* The platform matches this **only within the person's own memberships**, so it can
|
|
173
|
+
* narrow a choice but never widen access — which is why passing it is safe even when the
|
|
174
|
+
* value came from something the page was holding.
|
|
175
|
+
*/
|
|
176
|
+
workspace?: string;
|
|
177
|
+
/** Override the fall-back bound. See `WARM_HOP_TIMEOUT_MS`. */
|
|
178
|
+
timeoutMs?: number;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Run one silent attempt and report what the platform said.
|
|
182
|
+
*
|
|
183
|
+
* Never throws: every failure resolves to `failed`, because a caller in the middle of
|
|
184
|
+
* rendering a sign-in page has no better answer than "show the form" and an exception
|
|
185
|
+
* there is how a blank screen happens.
|
|
186
|
+
*/
|
|
187
|
+
declare function attemptWarmHop(options?: WarmHopOptions): Promise<WarmHopResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Move to another workspace (F028 / FR-SWITCH-1…6).
|
|
190
|
+
*
|
|
191
|
+
* The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
|
|
192
|
+
* deliberately **not** subject to the once-per-entry guard, because this one is a thing the
|
|
193
|
+
* person asked for.
|
|
194
|
+
*
|
|
195
|
+
* Eligibility is decided by the platform at this moment, not from any list held here, so a
|
|
196
|
+
* workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
|
|
197
|
+
* refusal you get depends on what is left, verified against a live platform: still holding
|
|
198
|
+
* other workspaces gives `select_workspace` listing them; having lost them all gives
|
|
199
|
+
* `no_access`. Do not treat `no_access` as the only refusal.
|
|
200
|
+
*
|
|
201
|
+
* On `signed_in` the session cookie now names the new workspace. The caller decides what to
|
|
202
|
+
* do next — the SDK does not reload the page or discard anything on their behalf, because it
|
|
203
|
+
* cannot know what is unsaved on the screen (FR-SWITCH-7).
|
|
204
|
+
*/
|
|
205
|
+
declare function switchWorkspace(workspace: string, options?: WarmHopOptions): Promise<WarmHopResult>;
|
|
206
|
+
/**
|
|
207
|
+
* The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
|
|
208
|
+
*
|
|
209
|
+
* Returns `null` when it declines to attempt — already tried this entry — so a caller can
|
|
210
|
+
* tell "we tried and they must sign in" from "we did not try".
|
|
211
|
+
*/
|
|
212
|
+
declare function attemptWarmHopOnce(options?: WarmHopOptions): Promise<WarmHopResult | null>;
|
|
213
|
+
//#endregion
|
|
125
214
|
//#region src/hooks.d.ts
|
|
126
215
|
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
127
216
|
declare function useSession(): SessionState;
|
|
@@ -151,6 +240,37 @@ declare function useAuth(): UseAuth;
|
|
|
151
240
|
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
152
241
|
declare function useAuthCallback(): () => Promise<void>;
|
|
153
242
|
declare function useUser(): SessionClaims | null;
|
|
243
|
+
/** What `useWarmHop` reports while and after it tries. */
|
|
244
|
+
type WarmHopState = {
|
|
245
|
+
status: "idle";
|
|
246
|
+
} | {
|
|
247
|
+
status: "attempting";
|
|
248
|
+
} | {
|
|
249
|
+
status: "settled";
|
|
250
|
+
result: WarmHopResult;
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
|
|
254
|
+
*
|
|
255
|
+
* The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
|
|
256
|
+
* is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
|
|
257
|
+
* must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
|
|
258
|
+
* Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
|
|
259
|
+
* change to the sign-in page (a guard in React state would reset exactly when it is needed).
|
|
260
|
+
*
|
|
261
|
+
* ```tsx
|
|
262
|
+
* const hop = useWarmHop();
|
|
263
|
+
* if (hop.status !== "settled" ) return null; // no half-drawn form
|
|
264
|
+
* if (hop.result.status === "signed_in") return <Redirect/>;
|
|
265
|
+
* return <SignIn />; // the ordinary path
|
|
266
|
+
* ```
|
|
267
|
+
*
|
|
268
|
+
* Strict Mode's double-mount does not produce two attempts: the marker is written before the
|
|
269
|
+
* first request goes out.
|
|
270
|
+
*/
|
|
271
|
+
declare function useWarmHop(options?: WarmHopOptions & {
|
|
272
|
+
enabled?: boolean;
|
|
273
|
+
}): WarmHopState;
|
|
154
274
|
//#endregion
|
|
155
275
|
//#region src/context.d.ts
|
|
156
276
|
interface PolyXContextValue {
|
|
@@ -806,4 +926,4 @@ declare const PACKAGE_NAME = "@poly-x/react";
|
|
|
806
926
|
/** The published version of this package, injected at build time. */
|
|
807
927
|
declare const version: string;
|
|
808
928
|
//#endregion
|
|
809
|
-
export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, UserProfile, type UserProfileLabels, type UserProfileProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
|
|
929
|
+
export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, UserProfile, type UserProfileLabels, type UserProfileProps, WARM_HOP_TIMEOUT_MS, type WarmHopOptions, type WarmHopResult, type WarmHopState, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState } from "@poly-x/core";
|
|
1
|
+
import { AuthClient, Lock, PkceEntry, PkceStore, SessionChannel, SessionChannelEvent, SessionClaims, SessionEngine, SessionState, TenantOption } from "@poly-x/core";
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
//#region src/provider.d.ts
|
|
@@ -122,6 +122,95 @@ declare class PopupCancelledError extends Error {
|
|
|
122
122
|
}
|
|
123
123
|
declare function createLoginController(deps: LoginControllerDeps): LoginController;
|
|
124
124
|
//#endregion
|
|
125
|
+
//#region src/warm-hop.d.ts
|
|
126
|
+
/**
|
|
127
|
+
* How long to wait for the silent attempt before giving up and showing the form.
|
|
128
|
+
*
|
|
129
|
+
* A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
|
|
130
|
+
* dead platform must never leave someone watching a spinner — and for that, *any* bound
|
|
131
|
+
* beats none. The person this protects is already signed out and gains nothing from
|
|
132
|
+
* waiting, so the bound is deliberately short. Override it if you have measured yours.
|
|
133
|
+
*/
|
|
134
|
+
declare const WARM_HOP_TIMEOUT_MS = 5000;
|
|
135
|
+
type WarmHopResult = /** Signed in. The session cookie is set; `workspace` names the tenant actually entered. */{
|
|
136
|
+
status: "signed_in";
|
|
137
|
+
workspace?: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Signed in — but **not** where you asked.
|
|
141
|
+
*
|
|
142
|
+
* The platform only honours a workspace hint when the person has more than one candidate for
|
|
143
|
+
* that application; with exactly one it seats them there regardless and still answers
|
|
144
|
+
* "authorized". The session is valid and the person is somewhere they are entitled to be, so
|
|
145
|
+
* this is not a failure — but it is not what was requested, and being silently put to work in
|
|
146
|
+
* the wrong tenant is worse than being asked. Decide deliberately: accept it, or send them to
|
|
147
|
+
* a picker.
|
|
148
|
+
*/
|
|
149
|
+
| {
|
|
150
|
+
status: "workspace_mismatch";
|
|
151
|
+
requested: string;
|
|
152
|
+
workspace: string;
|
|
153
|
+
} /** Several workspaces are available and none was requested — ask, never guess (D14). */ | {
|
|
154
|
+
status: "select_workspace";
|
|
155
|
+
workspaces: readonly TenantOption[];
|
|
156
|
+
} /** Recognised, but not entitled to this application or workspace (D15). */ | {
|
|
157
|
+
status: "no_access";
|
|
158
|
+
} /** Not recognised. The ordinary signed-out path — not an error. */ | {
|
|
159
|
+
status: "sign_in_required";
|
|
160
|
+
} /** The attempt could not be completed. Treated as signed-out by callers; surfaced for logs. */ | {
|
|
161
|
+
status: "failed";
|
|
162
|
+
reason: string;
|
|
163
|
+
};
|
|
164
|
+
/** Has an automatic attempt already run in this tab? */
|
|
165
|
+
declare function warmHopAttempted(): boolean;
|
|
166
|
+
/** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
|
|
167
|
+
declare function resetWarmHop(): void;
|
|
168
|
+
interface WarmHopOptions {
|
|
169
|
+
/**
|
|
170
|
+
* Pin a workspace by tenant id, organization code or slug.
|
|
171
|
+
*
|
|
172
|
+
* The platform matches this **only within the person's own memberships**, so it can
|
|
173
|
+
* narrow a choice but never widen access — which is why passing it is safe even when the
|
|
174
|
+
* value came from something the page was holding.
|
|
175
|
+
*/
|
|
176
|
+
workspace?: string;
|
|
177
|
+
/** Override the fall-back bound. See `WARM_HOP_TIMEOUT_MS`. */
|
|
178
|
+
timeoutMs?: number;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Run one silent attempt and report what the platform said.
|
|
182
|
+
*
|
|
183
|
+
* Never throws: every failure resolves to `failed`, because a caller in the middle of
|
|
184
|
+
* rendering a sign-in page has no better answer than "show the form" and an exception
|
|
185
|
+
* there is how a blank screen happens.
|
|
186
|
+
*/
|
|
187
|
+
declare function attemptWarmHop(options?: WarmHopOptions): Promise<WarmHopResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Move to another workspace (F028 / FR-SWITCH-1…6).
|
|
190
|
+
*
|
|
191
|
+
* The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
|
|
192
|
+
* deliberately **not** subject to the once-per-entry guard, because this one is a thing the
|
|
193
|
+
* person asked for.
|
|
194
|
+
*
|
|
195
|
+
* Eligibility is decided by the platform at this moment, not from any list held here, so a
|
|
196
|
+
* workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
|
|
197
|
+
* refusal you get depends on what is left, verified against a live platform: still holding
|
|
198
|
+
* other workspaces gives `select_workspace` listing them; having lost them all gives
|
|
199
|
+
* `no_access`. Do not treat `no_access` as the only refusal.
|
|
200
|
+
*
|
|
201
|
+
* On `signed_in` the session cookie now names the new workspace. The caller decides what to
|
|
202
|
+
* do next — the SDK does not reload the page or discard anything on their behalf, because it
|
|
203
|
+
* cannot know what is unsaved on the screen (FR-SWITCH-7).
|
|
204
|
+
*/
|
|
205
|
+
declare function switchWorkspace(workspace: string, options?: WarmHopOptions): Promise<WarmHopResult>;
|
|
206
|
+
/**
|
|
207
|
+
* The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
|
|
208
|
+
*
|
|
209
|
+
* Returns `null` when it declines to attempt — already tried this entry — so a caller can
|
|
210
|
+
* tell "we tried and they must sign in" from "we did not try".
|
|
211
|
+
*/
|
|
212
|
+
declare function attemptWarmHopOnce(options?: WarmHopOptions): Promise<WarmHopResult | null>;
|
|
213
|
+
//#endregion
|
|
125
214
|
//#region src/hooks.d.ts
|
|
126
215
|
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
127
216
|
declare function useSession(): SessionState;
|
|
@@ -151,6 +240,37 @@ declare function useAuth(): UseAuth;
|
|
|
151
240
|
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
152
241
|
declare function useAuthCallback(): () => Promise<void>;
|
|
153
242
|
declare function useUser(): SessionClaims | null;
|
|
243
|
+
/** What `useWarmHop` reports while and after it tries. */
|
|
244
|
+
type WarmHopState = {
|
|
245
|
+
status: "idle";
|
|
246
|
+
} | {
|
|
247
|
+
status: "attempting";
|
|
248
|
+
} | {
|
|
249
|
+
status: "settled";
|
|
250
|
+
result: WarmHopResult;
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
|
|
254
|
+
*
|
|
255
|
+
* The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
|
|
256
|
+
* is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
|
|
257
|
+
* must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
|
|
258
|
+
* Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
|
|
259
|
+
* change to the sign-in page (a guard in React state would reset exactly when it is needed).
|
|
260
|
+
*
|
|
261
|
+
* ```tsx
|
|
262
|
+
* const hop = useWarmHop();
|
|
263
|
+
* if (hop.status !== "settled" ) return null; // no half-drawn form
|
|
264
|
+
* if (hop.result.status === "signed_in") return <Redirect/>;
|
|
265
|
+
* return <SignIn />; // the ordinary path
|
|
266
|
+
* ```
|
|
267
|
+
*
|
|
268
|
+
* Strict Mode's double-mount does not produce two attempts: the marker is written before the
|
|
269
|
+
* first request goes out.
|
|
270
|
+
*/
|
|
271
|
+
declare function useWarmHop(options?: WarmHopOptions & {
|
|
272
|
+
enabled?: boolean;
|
|
273
|
+
}): WarmHopState;
|
|
154
274
|
//#endregion
|
|
155
275
|
//#region src/context.d.ts
|
|
156
276
|
interface PolyXContextValue {
|
|
@@ -806,4 +926,4 @@ declare const PACKAGE_NAME = "@poly-x/react";
|
|
|
806
926
|
/** The published version of this package, injected at build time. */
|
|
807
927
|
declare const version: string;
|
|
808
928
|
//#endregion
|
|
809
|
-
export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, UserProfile, type UserProfileLabels, type UserProfileProps, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
|
|
929
|
+
export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, type AuthEventKind, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, type CaptureOptions, type CapturedGeo, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type GeolocationLike, type LiveAuthzHandle, type LiveAuthzOptions, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, type UseLiveAuthzOptions, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, UserProfile, type UserProfileLabels, type UserProfileProps, WARM_HOP_TIMEOUT_MS, type WarmHopOptions, type WarmHopResult, type WarmHopState, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, PROFILE_FIELDS, buildProfileUpdate, conflictField, createSessionEngine, generatePkce, randomState, resolveConfig, validateProfileDraft, validateProfileImage } from "@poly-x/core";
|
|
2
|
+
import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, PROFILE_FIELDS, buildProfileUpdate, conflictField, createSessionEngine, generatePkce, parseAuthorizeOutcome, randomState, resolveConfig, validateProfileDraft, validateProfileImage } from "@poly-x/core";
|
|
3
3
|
import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
@@ -199,7 +199,7 @@ var BroadcastChannelSessionChannel = class {
|
|
|
199
199
|
//#endregion
|
|
200
200
|
//#region src/seams/session-storage-pkce.ts
|
|
201
201
|
const KEY_PREFIX = "poly-x.pkce.";
|
|
202
|
-
function hasSessionStorage() {
|
|
202
|
+
function hasSessionStorage$1() {
|
|
203
203
|
try {
|
|
204
204
|
return typeof sessionStorage !== "undefined";
|
|
205
205
|
} catch {
|
|
@@ -215,7 +215,7 @@ function hasSessionStorage() {
|
|
|
215
215
|
var SessionStoragePkceStore = class {
|
|
216
216
|
fallback;
|
|
217
217
|
constructor() {
|
|
218
|
-
if (!hasSessionStorage()) this.fallback = new InMemoryPkceStore();
|
|
218
|
+
if (!hasSessionStorage$1()) this.fallback = new InMemoryPkceStore();
|
|
219
219
|
}
|
|
220
220
|
save(entry) {
|
|
221
221
|
if (this.fallback) return this.fallback.save(entry);
|
|
@@ -480,6 +480,198 @@ function getBffSessionStore() {
|
|
|
480
480
|
return store;
|
|
481
481
|
}
|
|
482
482
|
//#endregion
|
|
483
|
+
//#region src/warm-hop.ts
|
|
484
|
+
/**
|
|
485
|
+
* Warm hop — the browser half (F027/F028 · FR-HOP-1…6, FR-FALL-1…3, FR-SWITCH-1…7).
|
|
486
|
+
*
|
|
487
|
+
* The probe is made **from here, by the browser**, and that is not a style choice. The
|
|
488
|
+
* platform resolves the ecosystem session from a cookie on its own domain; a server-side
|
|
489
|
+
* request carries no browser cookie, so it would be told "sign-in required" every time and
|
|
490
|
+
* the whole feature would look built while never working. `@poly-x/core` refuses the
|
|
491
|
+
* server-side call outright for that reason.
|
|
492
|
+
*
|
|
493
|
+
* The three steps, and why the credential stays safe throughout:
|
|
494
|
+
*
|
|
495
|
+
* 1. Ask our own BFF to prepare (`/api/polyx/hop`). It mints PKCE and keeps the verifier
|
|
496
|
+
* in an httpOnly cookie; we receive only a URL.
|
|
497
|
+
* 2. Call that URL with `credentials:"include"` so the browser attaches the ecosystem
|
|
498
|
+
* cookie. This is the one request that must not come from a server.
|
|
499
|
+
* 3. Hand the resulting `code` back to our BFF (`/api/polyx/hop-complete`), which does the
|
|
500
|
+
* exchange. The code is worthless to anyone who intercepts it — the PKCE verifier that
|
|
501
|
+
* redeems it never left the server — so a page holding it briefly is not a custody leak.
|
|
502
|
+
*
|
|
503
|
+
* Nothing here navigates. That matters beyond tidiness: this exact flow produced a redirect
|
|
504
|
+
* loop in a hand-rolled implementation once already, and a fetch cannot loop.
|
|
505
|
+
*/
|
|
506
|
+
const HOP_ENDPOINT = "/api/polyx/hop";
|
|
507
|
+
const HOP_COMPLETE_ENDPOINT = "/api/polyx/hop-complete";
|
|
508
|
+
/**
|
|
509
|
+
* How long to wait for the silent attempt before giving up and showing the form.
|
|
510
|
+
*
|
|
511
|
+
* A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
|
|
512
|
+
* dead platform must never leave someone watching a spinner — and for that, *any* bound
|
|
513
|
+
* beats none. The person this protects is already signed out and gains nothing from
|
|
514
|
+
* waiting, so the bound is deliberately short. Override it if you have measured yours.
|
|
515
|
+
*/
|
|
516
|
+
const WARM_HOP_TIMEOUT_MS = 5e3;
|
|
517
|
+
/**
|
|
518
|
+
* Survives navigation on purpose (FR-FALL-3).
|
|
519
|
+
*
|
|
520
|
+
* The fall-back from a failed attempt renders the sign-in page, which in most apps is a
|
|
521
|
+
* route change — so a guard held in React state or a module variable is reset exactly when
|
|
522
|
+
* it is needed. `sessionStorage` is scoped to the tab and cleared when it closes, which is
|
|
523
|
+
* the same lifetime as "this entry".
|
|
524
|
+
*/
|
|
525
|
+
const ATTEMPTED_KEY = "polyx.warmHop.attempted";
|
|
526
|
+
function hasSessionStorage() {
|
|
527
|
+
try {
|
|
528
|
+
return typeof sessionStorage !== "undefined";
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Has an automatic attempt already run in this tab? */
|
|
534
|
+
function warmHopAttempted() {
|
|
535
|
+
if (!hasSessionStorage()) return false;
|
|
536
|
+
try {
|
|
537
|
+
return sessionStorage.getItem(ATTEMPTED_KEY) === "1";
|
|
538
|
+
} catch {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function markWarmHopAttempted() {
|
|
543
|
+
if (!hasSessionStorage()) return;
|
|
544
|
+
try {
|
|
545
|
+
sessionStorage.setItem(ATTEMPTED_KEY, "1");
|
|
546
|
+
} catch {}
|
|
547
|
+
}
|
|
548
|
+
/** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
|
|
549
|
+
function resetWarmHop() {
|
|
550
|
+
if (!hasSessionStorage()) return;
|
|
551
|
+
try {
|
|
552
|
+
sessionStorage.removeItem(ATTEMPTED_KEY);
|
|
553
|
+
} catch {}
|
|
554
|
+
}
|
|
555
|
+
async function postJson(url, body, signal) {
|
|
556
|
+
return fetch(url, {
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: { "content-type": "application/json" },
|
|
559
|
+
body: JSON.stringify(body ?? {}),
|
|
560
|
+
signal,
|
|
561
|
+
credentials: "same-origin"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Run one silent attempt and report what the platform said.
|
|
566
|
+
*
|
|
567
|
+
* Never throws: every failure resolves to `failed`, because a caller in the middle of
|
|
568
|
+
* rendering a sign-in page has no better answer than "show the form" and an exception
|
|
569
|
+
* there is how a blank screen happens.
|
|
570
|
+
*/
|
|
571
|
+
async function attemptWarmHop(options = {}) {
|
|
572
|
+
if (typeof window === "undefined") return {
|
|
573
|
+
status: "failed",
|
|
574
|
+
reason: "warm hop attempted outside a browser"
|
|
575
|
+
};
|
|
576
|
+
const controller = new AbortController();
|
|
577
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5e3);
|
|
578
|
+
try {
|
|
579
|
+
const prepared = await postJson(HOP_ENDPOINT, options.workspace ? { tenantId: options.workspace } : {}, controller.signal);
|
|
580
|
+
if (!prepared.ok) return {
|
|
581
|
+
status: "failed",
|
|
582
|
+
reason: `hop preparation failed (${prepared.status})`
|
|
583
|
+
};
|
|
584
|
+
const { authorizeUrl } = await prepared.json();
|
|
585
|
+
if (!authorizeUrl) return {
|
|
586
|
+
status: "failed",
|
|
587
|
+
reason: "hop preparation returned no authorize URL"
|
|
588
|
+
};
|
|
589
|
+
const probe = await fetch(authorizeUrl, {
|
|
590
|
+
method: "GET",
|
|
591
|
+
credentials: "include",
|
|
592
|
+
headers: { accept: "application/json" },
|
|
593
|
+
signal: controller.signal
|
|
594
|
+
});
|
|
595
|
+
const body = await probe.json().catch(() => ({}));
|
|
596
|
+
const outcome = parseAuthorizeOutcome(probe.status, body);
|
|
597
|
+
switch (outcome.type) {
|
|
598
|
+
case "login_required": return { status: "sign_in_required" };
|
|
599
|
+
case "no_access": return { status: "no_access" };
|
|
600
|
+
case "select_tenant": return {
|
|
601
|
+
status: "select_workspace",
|
|
602
|
+
workspaces: outcome.tenants
|
|
603
|
+
};
|
|
604
|
+
case "authorized": {
|
|
605
|
+
const completed = await postJson(HOP_COMPLETE_ENDPOINT, {
|
|
606
|
+
code: outcome.code,
|
|
607
|
+
state: outcome.state
|
|
608
|
+
}, controller.signal);
|
|
609
|
+
const settled = await completed.json().catch(() => ({}));
|
|
610
|
+
if (completed.ok) {
|
|
611
|
+
if (settled.status === "workspace_mismatch" && settled.requested && settled.workspace) return {
|
|
612
|
+
status: "workspace_mismatch",
|
|
613
|
+
requested: settled.requested,
|
|
614
|
+
workspace: settled.workspace
|
|
615
|
+
};
|
|
616
|
+
return {
|
|
617
|
+
status: "signed_in",
|
|
618
|
+
...settled.workspace ? { workspace: settled.workspace } : {}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
status: "failed",
|
|
623
|
+
reason: settled.status ?? `exchange failed (${completed.status})`
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return {
|
|
629
|
+
status: "failed",
|
|
630
|
+
reason: error instanceof Error ? error.message : "warm hop failed"
|
|
631
|
+
};
|
|
632
|
+
} finally {
|
|
633
|
+
clearTimeout(timeout);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Move to another workspace (F028 / FR-SWITCH-1…6).
|
|
638
|
+
*
|
|
639
|
+
* The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
|
|
640
|
+
* deliberately **not** subject to the once-per-entry guard, because this one is a thing the
|
|
641
|
+
* person asked for.
|
|
642
|
+
*
|
|
643
|
+
* Eligibility is decided by the platform at this moment, not from any list held here, so a
|
|
644
|
+
* workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
|
|
645
|
+
* refusal you get depends on what is left, verified against a live platform: still holding
|
|
646
|
+
* other workspaces gives `select_workspace` listing them; having lost them all gives
|
|
647
|
+
* `no_access`. Do not treat `no_access` as the only refusal.
|
|
648
|
+
*
|
|
649
|
+
* On `signed_in` the session cookie now names the new workspace. The caller decides what to
|
|
650
|
+
* do next — the SDK does not reload the page or discard anything on their behalf, because it
|
|
651
|
+
* cannot know what is unsaved on the screen (FR-SWITCH-7).
|
|
652
|
+
*/
|
|
653
|
+
async function switchWorkspace(workspace, options = {}) {
|
|
654
|
+
if (!workspace) return {
|
|
655
|
+
status: "failed",
|
|
656
|
+
reason: "no workspace given"
|
|
657
|
+
};
|
|
658
|
+
return attemptWarmHop({
|
|
659
|
+
...options,
|
|
660
|
+
workspace
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
|
|
665
|
+
*
|
|
666
|
+
* Returns `null` when it declines to attempt — already tried this entry — so a caller can
|
|
667
|
+
* tell "we tried and they must sign in" from "we did not try".
|
|
668
|
+
*/
|
|
669
|
+
async function attemptWarmHopOnce(options = {}) {
|
|
670
|
+
if (warmHopAttempted()) return null;
|
|
671
|
+
markWarmHopAttempted();
|
|
672
|
+
return attemptWarmHop(options);
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
483
675
|
//#region src/hooks.ts
|
|
484
676
|
function useSessionSource() {
|
|
485
677
|
const ctx = useContext(PolyXContext);
|
|
@@ -549,6 +741,52 @@ function useUser() {
|
|
|
549
741
|
const state = useSession();
|
|
550
742
|
return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
|
|
551
743
|
}
|
|
744
|
+
/**
|
|
745
|
+
* Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
|
|
746
|
+
*
|
|
747
|
+
* The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
|
|
748
|
+
* is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
|
|
749
|
+
* must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
|
|
750
|
+
* Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
|
|
751
|
+
* change to the sign-in page (a guard in React state would reset exactly when it is needed).
|
|
752
|
+
*
|
|
753
|
+
* ```tsx
|
|
754
|
+
* const hop = useWarmHop();
|
|
755
|
+
* if (hop.status !== "settled" ) return null; // no half-drawn form
|
|
756
|
+
* if (hop.result.status === "signed_in") return <Redirect/>;
|
|
757
|
+
* return <SignIn />; // the ordinary path
|
|
758
|
+
* ```
|
|
759
|
+
*
|
|
760
|
+
* Strict Mode's double-mount does not produce two attempts: the marker is written before the
|
|
761
|
+
* first request goes out.
|
|
762
|
+
*/
|
|
763
|
+
function useWarmHop(options = {}) {
|
|
764
|
+
const { enabled = true, workspace, timeoutMs } = options;
|
|
765
|
+
const [state, setState] = useState({ status: "idle" });
|
|
766
|
+
useEffect(() => {
|
|
767
|
+
if (!enabled) return;
|
|
768
|
+
let live = true;
|
|
769
|
+
setState({ status: "attempting" });
|
|
770
|
+
attemptWarmHopOnce({
|
|
771
|
+
workspace,
|
|
772
|
+
timeoutMs
|
|
773
|
+
}).then((result) => {
|
|
774
|
+
if (!live) return;
|
|
775
|
+
setState({
|
|
776
|
+
status: "settled",
|
|
777
|
+
result: result ?? { status: "sign_in_required" }
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
return () => {
|
|
781
|
+
live = false;
|
|
782
|
+
};
|
|
783
|
+
}, [
|
|
784
|
+
enabled,
|
|
785
|
+
workspace,
|
|
786
|
+
timeoutMs
|
|
787
|
+
]);
|
|
788
|
+
return state;
|
|
789
|
+
}
|
|
552
790
|
//#endregion
|
|
553
791
|
//#region src/components/protected.tsx
|
|
554
792
|
/** Gate content on authentication state without flashing the wrong branch. */
|
|
@@ -2680,6 +2918,6 @@ function useAuthzChanged(callback) {
|
|
|
2680
2918
|
*/
|
|
2681
2919
|
const PACKAGE_NAME = "@poly-x/react";
|
|
2682
2920
|
/** The published version of this package, injected at build time. */
|
|
2683
|
-
const version = "0.
|
|
2921
|
+
const version = "0.3.0";
|
|
2684
2922
|
//#endregion
|
|
2685
|
-
export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, UserProfile, WebLocksLock, WindowBrowserBridge, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resolveSide, startLiveAuthz, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, version };
|
|
2923
|
+
export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, UserProfile, WARM_HOP_TIMEOUT_MS, WebLocksLock, WindowBrowserBridge, attemptWarmHop, attemptWarmHopOnce, capturePreciseLocation, consumeAuthEvent, createLoginController, initialsFrom, resetWarmHop, resolveSide, startLiveAuthz, switchWorkspace, useAuth, useAuthCallback, useAuthzChanged, useLiveAuthz, useSession, useUser, useWarmHop, version, warmHopAttempted };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poly-x/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "PolyX SDK for React SPAs - provider, hooks, drop-in auth UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"module": "./dist/index.mjs",
|
|
28
28
|
"types": "./dist/index.d.cts",
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@poly-x/core": "0.
|
|
30
|
+
"@poly-x/core": "0.3.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"react": ">=18",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsdown && node scripts/copy-styles.mjs",
|
|
58
58
|
"test": "vitest run",
|
|
59
|
-
"lint": "eslint src"
|
|
59
|
+
"lint": "eslint src && tsc --noEmit -p tsconfig.docs.json"
|
|
60
60
|
}
|
|
61
61
|
}
|