@poly-x/react 0.1.1 → 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 +129 -2
- package/dist/index.cjs +893 -83
- package/dist/index.d.cts +228 -3
- package/dist/index.d.mts +228 -3
- package/dist/index.mjs +886 -85
- package/dist/styles.css +151 -0
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
3
3
|
let _poly_x_core = require("@poly-x/core");
|
|
4
4
|
let react = require("react");
|
|
5
5
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
6
|
+
let react_dom = require("react-dom");
|
|
6
7
|
//#region src/auth-channel.ts
|
|
7
8
|
var BroadcastAuthChannel = class {
|
|
8
9
|
supported;
|
|
@@ -199,7 +200,7 @@ var BroadcastChannelSessionChannel = class {
|
|
|
199
200
|
//#endregion
|
|
200
201
|
//#region src/seams/session-storage-pkce.ts
|
|
201
202
|
const KEY_PREFIX = "poly-x.pkce.";
|
|
202
|
-
function hasSessionStorage() {
|
|
203
|
+
function hasSessionStorage$1() {
|
|
203
204
|
try {
|
|
204
205
|
return typeof sessionStorage !== "undefined";
|
|
205
206
|
} catch {
|
|
@@ -215,7 +216,7 @@ function hasSessionStorage() {
|
|
|
215
216
|
var SessionStoragePkceStore = class {
|
|
216
217
|
fallback;
|
|
217
218
|
constructor() {
|
|
218
|
-
if (!hasSessionStorage()) this.fallback = new _poly_x_core.InMemoryPkceStore();
|
|
219
|
+
if (!hasSessionStorage$1()) this.fallback = new _poly_x_core.InMemoryPkceStore();
|
|
219
220
|
}
|
|
220
221
|
save(entry) {
|
|
221
222
|
if (this.fallback) return this.fallback.save(entry);
|
|
@@ -480,6 +481,198 @@ function getBffSessionStore() {
|
|
|
480
481
|
return store;
|
|
481
482
|
}
|
|
482
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
|
|
483
676
|
//#region src/hooks.ts
|
|
484
677
|
function useSessionSource() {
|
|
485
678
|
const ctx = (0, react.useContext)(PolyXContext);
|
|
@@ -499,6 +692,14 @@ function useSessionSource() {
|
|
|
499
692
|
} catch {
|
|
500
693
|
return null;
|
|
501
694
|
}
|
|
695
|
+
},
|
|
696
|
+
refresh: async () => {
|
|
697
|
+
try {
|
|
698
|
+
await engine.getAccessToken();
|
|
699
|
+
return true;
|
|
700
|
+
} catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
502
703
|
}
|
|
503
704
|
},
|
|
504
705
|
canSignIn: Boolean(controller)
|
|
@@ -519,12 +720,14 @@ function useAuth() {
|
|
|
519
720
|
}, [ctx]);
|
|
520
721
|
const signOut = (0, react.useCallback)((options) => source.signOut(options), [source]);
|
|
521
722
|
const getToken = (0, react.useCallback)(() => source.getToken(), [source]);
|
|
723
|
+
const refresh = (0, react.useCallback)(() => source.refresh(), [source]);
|
|
522
724
|
return {
|
|
523
725
|
isLoaded: state.status !== "resolving",
|
|
524
726
|
isSignedIn: state.status === "authenticated" || state.status === "refreshing",
|
|
525
727
|
signIn,
|
|
526
728
|
signOut,
|
|
527
|
-
getToken
|
|
729
|
+
getToken,
|
|
730
|
+
refresh
|
|
528
731
|
};
|
|
529
732
|
}
|
|
530
733
|
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
@@ -539,6 +742,52 @@ function useUser() {
|
|
|
539
742
|
const state = useSession();
|
|
540
743
|
return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
|
|
541
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
|
+
}
|
|
542
791
|
//#endregion
|
|
543
792
|
//#region src/components/protected.tsx
|
|
544
793
|
/** Gate content on authentication state without flashing the wrong branch. */
|
|
@@ -630,7 +879,7 @@ function captureDeviceContext() {
|
|
|
630
879
|
}
|
|
631
880
|
//#endregion
|
|
632
881
|
//#region src/components/sign-in.tsx
|
|
633
|
-
const DEFAULT_LABELS$
|
|
882
|
+
const DEFAULT_LABELS$4 = {
|
|
634
883
|
heading: "Sign in",
|
|
635
884
|
subheading: "Enter your credentials to continue.",
|
|
636
885
|
tagline: "",
|
|
@@ -656,7 +905,8 @@ const DEFAULT_LABELS$3 = {
|
|
|
656
905
|
invalidCredentials: "Invalid email or password.",
|
|
657
906
|
noAccess: "You don't have access to this application.",
|
|
658
907
|
passwordChangeRequired: "You must reset your password before you can sign in.",
|
|
659
|
-
genericError: "Something went wrong. Please try again."
|
|
908
|
+
genericError: "Something went wrong. Please try again.",
|
|
909
|
+
verificationRequired: "Additional verification is required to sign in."
|
|
660
910
|
};
|
|
661
911
|
/** Mirrors poly-auth's own floor (Joi: min 6). */
|
|
662
912
|
const MIN_PASSWORD_LENGTH = 6;
|
|
@@ -745,7 +995,7 @@ function SignIn(props) {
|
|
|
745
995
|
const action = props.action ?? "/api/polyx/authenticate";
|
|
746
996
|
const variant = props.variant ?? "card";
|
|
747
997
|
const labels = {
|
|
748
|
-
...DEFAULT_LABELS$
|
|
998
|
+
...DEFAULT_LABELS$4,
|
|
749
999
|
...props.labels
|
|
750
1000
|
};
|
|
751
1001
|
const [email, setEmail] = (0, react.useState)("");
|
|
@@ -826,6 +1076,9 @@ function SignIn(props) {
|
|
|
826
1076
|
case "no_access":
|
|
827
1077
|
setError(labels.noAccess);
|
|
828
1078
|
return;
|
|
1079
|
+
case "verification_required":
|
|
1080
|
+
setError(labels.verificationRequired ?? labels.genericError);
|
|
1081
|
+
return;
|
|
829
1082
|
default:
|
|
830
1083
|
setError(labels.genericError);
|
|
831
1084
|
return;
|
|
@@ -1138,7 +1391,7 @@ function RecoveryStatus(props) {
|
|
|
1138
1391
|
}
|
|
1139
1392
|
//#endregion
|
|
1140
1393
|
//#region src/components/forgot-password.tsx
|
|
1141
|
-
const DEFAULT_LABELS$
|
|
1394
|
+
const DEFAULT_LABELS$3 = {
|
|
1142
1395
|
heading: "Reset your password",
|
|
1143
1396
|
subheading: "Enter your email and we'll send you a reset link.",
|
|
1144
1397
|
emailLabel: "Email",
|
|
@@ -1173,7 +1426,7 @@ function ForgotPassword(props) {
|
|
|
1173
1426
|
const action = props.action ?? "/api/polyx/forgot-password";
|
|
1174
1427
|
const signInPath = props.signInPath ?? "/sign-in";
|
|
1175
1428
|
const labels = {
|
|
1176
|
-
...DEFAULT_LABELS$
|
|
1429
|
+
...DEFAULT_LABELS$3,
|
|
1177
1430
|
...props.labels
|
|
1178
1431
|
};
|
|
1179
1432
|
const [email, setEmail] = (0, react.useState)("");
|
|
@@ -1301,7 +1554,7 @@ function ForgotPassword(props) {
|
|
|
1301
1554
|
}
|
|
1302
1555
|
//#endregion
|
|
1303
1556
|
//#region src/components/reset-password.tsx
|
|
1304
|
-
const DEFAULT_LABELS$
|
|
1557
|
+
const DEFAULT_LABELS$2 = {
|
|
1305
1558
|
heading: "Choose a new password",
|
|
1306
1559
|
subheading: "Enter and confirm your new password.",
|
|
1307
1560
|
newPasswordLabel: "New password",
|
|
@@ -1337,7 +1590,7 @@ function ResetPassword(props) {
|
|
|
1337
1590
|
const signInPath = props.signInPath ?? "/sign-in";
|
|
1338
1591
|
const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
|
|
1339
1592
|
const labels = {
|
|
1340
|
-
...DEFAULT_LABELS$
|
|
1593
|
+
...DEFAULT_LABELS$2,
|
|
1341
1594
|
...props.labels
|
|
1342
1595
|
};
|
|
1343
1596
|
const [token] = (0, react.useState)(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
|
|
@@ -1620,6 +1873,511 @@ function UserAvatar({ src, name, size = 32, className }) {
|
|
|
1620
1873
|
});
|
|
1621
1874
|
}
|
|
1622
1875
|
//#endregion
|
|
1876
|
+
//#region src/components/user-profile.tsx
|
|
1877
|
+
const PROFILE_ENDPOINT = "/api/polyx/profile";
|
|
1878
|
+
const DEFAULT_LABELS$1 = {
|
|
1879
|
+
trigger: "Manage profile",
|
|
1880
|
+
title: "Your profile",
|
|
1881
|
+
description: "Update your personal details.",
|
|
1882
|
+
firstName: "First name",
|
|
1883
|
+
lastName: "Last name",
|
|
1884
|
+
phoneNumber: "Phone number",
|
|
1885
|
+
cnic: "National ID",
|
|
1886
|
+
gender: "Gender",
|
|
1887
|
+
photo: "Photo",
|
|
1888
|
+
changePhoto: "Change photo",
|
|
1889
|
+
optional: "optional",
|
|
1890
|
+
save: "Save changes",
|
|
1891
|
+
saving: "Saving…",
|
|
1892
|
+
cancel: "Cancel",
|
|
1893
|
+
close: "Close",
|
|
1894
|
+
loading: "Loading your profile…",
|
|
1895
|
+
loadFailed: "We couldn't load your profile. Close this and try again.",
|
|
1896
|
+
saveFailed: "We couldn't save your changes. Your edits are still here — try again.",
|
|
1897
|
+
saved: "Profile updated.",
|
|
1898
|
+
signedOut: "Your session ended. Sign in again to make changes.",
|
|
1899
|
+
genderUnset: "Prefer not to say",
|
|
1900
|
+
genderMale: "Male",
|
|
1901
|
+
genderFemale: "Female",
|
|
1902
|
+
genderOther: "Other"
|
|
1903
|
+
};
|
|
1904
|
+
/** Matches the console's options, so the same stored values round-trip. */
|
|
1905
|
+
const GENDERS = [
|
|
1906
|
+
"male",
|
|
1907
|
+
"female",
|
|
1908
|
+
"other"
|
|
1909
|
+
];
|
|
1910
|
+
const EMPTY = {
|
|
1911
|
+
firstName: "",
|
|
1912
|
+
lastName: "",
|
|
1913
|
+
phoneNumber: "",
|
|
1914
|
+
cnic: "",
|
|
1915
|
+
gender: ""
|
|
1916
|
+
};
|
|
1917
|
+
function readProfile(user) {
|
|
1918
|
+
const draft = { ...EMPTY };
|
|
1919
|
+
for (const field of _poly_x_core.PROFILE_FIELDS) {
|
|
1920
|
+
const value = user[field];
|
|
1921
|
+
if (typeof value === "string") draft[field] = value;
|
|
1922
|
+
}
|
|
1923
|
+
return draft;
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* The signed-in person's own profile, as a dialog (v0.4 / FR-PROF).
|
|
1927
|
+
*
|
|
1928
|
+
* Every application that wanted this had to build the same four things: a form, a validation
|
|
1929
|
+
* ruleset guessed at from error messages, a file upload, and — because the browser holds no
|
|
1930
|
+
* credential under BFF custody — a server route to carry the call. The last of those is
|
|
1931
|
+
* security-adjacent work that had no business being duplicated per app.
|
|
1932
|
+
*
|
|
1933
|
+
* The credential never comes near this component: it posts to the SDK's own route, which
|
|
1934
|
+
* attaches the session server-side and takes the identity from the sealed session rather than
|
|
1935
|
+
* from anything sent here. This surface cannot be aimed at another person's profile even if a
|
|
1936
|
+
* caller tries (FR-CUSTODY-4 / D24).
|
|
1937
|
+
*
|
|
1938
|
+
* ```tsx
|
|
1939
|
+
* <UserProfile onSaved={() => router.refresh()} />
|
|
1940
|
+
* ```
|
|
1941
|
+
*/
|
|
1942
|
+
function UserProfile(props) {
|
|
1943
|
+
const labels = {
|
|
1944
|
+
...DEFAULT_LABELS$1,
|
|
1945
|
+
...props.labels
|
|
1946
|
+
};
|
|
1947
|
+
const { isLoaded, isSignedIn, refresh } = useAuth();
|
|
1948
|
+
const controlled = props.open !== void 0;
|
|
1949
|
+
const [internalOpen, setInternalOpen] = (0, react.useState)(props.defaultOpen ?? false);
|
|
1950
|
+
const open = controlled ? props.open === true : internalOpen;
|
|
1951
|
+
const [status, setStatus] = (0, react.useState)("loading");
|
|
1952
|
+
const [stored, setStored] = (0, react.useState)(EMPTY);
|
|
1953
|
+
const [draft, setDraft] = (0, react.useState)(EMPTY);
|
|
1954
|
+
const [errors, setErrors] = (0, react.useState)({});
|
|
1955
|
+
const [photo, setPhoto] = (0, react.useState)(null);
|
|
1956
|
+
const [photoError, setPhotoError] = (0, react.useState)(null);
|
|
1957
|
+
const [storedPhoto, setStoredPhoto] = (0, react.useState)(void 0);
|
|
1958
|
+
const [saving, setSaving] = (0, react.useState)(false);
|
|
1959
|
+
const [feedback, setFeedback] = (0, react.useState)(null);
|
|
1960
|
+
const [portalHost, setPortalHost] = (0, react.useState)(null);
|
|
1961
|
+
(0, react.useEffect)(() => {
|
|
1962
|
+
const host = document.createElement("div");
|
|
1963
|
+
host.setAttribute("data-polyx-userprofile-portal", "");
|
|
1964
|
+
document.body.appendChild(host);
|
|
1965
|
+
setPortalHost(host);
|
|
1966
|
+
return () => host.remove();
|
|
1967
|
+
}, []);
|
|
1968
|
+
const triggerRef = (0, react.useRef)(null);
|
|
1969
|
+
const dialogRef = (0, react.useRef)(null);
|
|
1970
|
+
const fileRef = (0, react.useRef)(null);
|
|
1971
|
+
const titleId = (0, react.useId)();
|
|
1972
|
+
const fieldId = (0, react.useId)();
|
|
1973
|
+
const onOpenChange = props.onOpenChange;
|
|
1974
|
+
const setOpen = (0, react.useCallback)((next) => {
|
|
1975
|
+
if (!controlled) setInternalOpen(next);
|
|
1976
|
+
onOpenChange?.(next);
|
|
1977
|
+
}, [controlled, onOpenChange]);
|
|
1978
|
+
const polyx = (0, react.useContext)(PolyXContext);
|
|
1979
|
+
const [branding, setBranding] = (0, react.useState)({});
|
|
1980
|
+
(0, react.useEffect)(() => {
|
|
1981
|
+
if (!polyx || !open) return;
|
|
1982
|
+
let active = true;
|
|
1983
|
+
polyx.authClient.fetchBranding({}).then((tokens) => {
|
|
1984
|
+
if (active) setBranding(tokens);
|
|
1985
|
+
});
|
|
1986
|
+
return () => {
|
|
1987
|
+
active = false;
|
|
1988
|
+
};
|
|
1989
|
+
}, [polyx, open]);
|
|
1990
|
+
const brandingStyle = {};
|
|
1991
|
+
if (branding.primaryColor) brandingStyle["--polyx-brand"] = branding.primaryColor;
|
|
1992
|
+
/** Load on open. Never leaves blank fields looking like a stored (empty) profile — EC-PROF-1. */
|
|
1993
|
+
(0, react.useEffect)(() => {
|
|
1994
|
+
if (!open) return;
|
|
1995
|
+
let active = true;
|
|
1996
|
+
setStatus("loading");
|
|
1997
|
+
setFeedback(null);
|
|
1998
|
+
(async () => {
|
|
1999
|
+
try {
|
|
2000
|
+
const response = await fetch(PROFILE_ENDPOINT, { headers: { Accept: "application/json" } });
|
|
2001
|
+
const body = await response.json().catch(() => ({}));
|
|
2002
|
+
if (!active) return;
|
|
2003
|
+
if (!response.ok || !body.user) {
|
|
2004
|
+
setStatus("load_failed");
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
const loaded = readProfile(body.user);
|
|
2008
|
+
setStored(loaded);
|
|
2009
|
+
setDraft(loaded);
|
|
2010
|
+
setStoredPhoto(typeof body.user.profilePicture === "string" ? body.user.profilePicture : void 0);
|
|
2011
|
+
setErrors({});
|
|
2012
|
+
setStatus("ready");
|
|
2013
|
+
} catch {
|
|
2014
|
+
if (active) setStatus("load_failed");
|
|
2015
|
+
}
|
|
2016
|
+
})();
|
|
2017
|
+
return () => {
|
|
2018
|
+
active = false;
|
|
2019
|
+
};
|
|
2020
|
+
}, [open]);
|
|
2021
|
+
(0, react.useEffect)(() => {
|
|
2022
|
+
const preview = photo?.preview;
|
|
2023
|
+
return () => {
|
|
2024
|
+
if (preview) URL.revokeObjectURL(preview);
|
|
2025
|
+
};
|
|
2026
|
+
}, [photo]);
|
|
2027
|
+
/**
|
|
2028
|
+
* Hide the rest of the page from assistive technology while the dialog is open.
|
|
2029
|
+
*
|
|
2030
|
+
* `aria-modal="true"` alone is not enough — screen-reader support for it is inconsistent, so a
|
|
2031
|
+
* virtual cursor can still read and act on everything behind the dialog. The keyboard focus trap
|
|
2032
|
+
* does not cover this either: it constrains Tab, not the virtual cursor. Without both, the dialog
|
|
2033
|
+
* announces itself as modal and then does not behave modally.
|
|
2034
|
+
*
|
|
2035
|
+
* Every direct child of `<body>` except the dialog's own portal is marked, and only attributes
|
|
2036
|
+
* this effect added are removed again — a host that set its own `aria-hidden` keeps it.
|
|
2037
|
+
*/
|
|
2038
|
+
(0, react.useEffect)(() => {
|
|
2039
|
+
if (!open || !portalHost) return;
|
|
2040
|
+
const marked = [];
|
|
2041
|
+
for (const node of Array.from(document.body.children)) {
|
|
2042
|
+
if (node === portalHost || !(node instanceof HTMLElement)) continue;
|
|
2043
|
+
if (node.hasAttribute("aria-hidden")) continue;
|
|
2044
|
+
node.setAttribute("aria-hidden", "true");
|
|
2045
|
+
node.setAttribute("inert", "");
|
|
2046
|
+
marked.push(node);
|
|
2047
|
+
}
|
|
2048
|
+
return () => {
|
|
2049
|
+
for (const node of marked) {
|
|
2050
|
+
node.removeAttribute("aria-hidden");
|
|
2051
|
+
node.removeAttribute("inert");
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
}, [open, portalHost]);
|
|
2055
|
+
/**
|
|
2056
|
+
* Move focus into the dialog once there is something to focus (FR-A11Y-1).
|
|
2057
|
+
*
|
|
2058
|
+
* Deliberately keyed on `status`, not just `open`: the dialog opens in its loading state with
|
|
2059
|
+
* no focusable content, so placing focus at open time silently leaves it on the page behind —
|
|
2060
|
+
* which is exactly nowhere for anyone navigating by keyboard or screen reader.
|
|
2061
|
+
*/
|
|
2062
|
+
(0, react.useEffect)(() => {
|
|
2063
|
+
if (!open || status === "loading") return;
|
|
2064
|
+
dialogRef.current?.querySelector("input, select, button")?.focus();
|
|
2065
|
+
}, [open, status]);
|
|
2066
|
+
/** Escape closes; focus is trapped while open (FR-A11Y-2/3). */
|
|
2067
|
+
(0, react.useEffect)(() => {
|
|
2068
|
+
if (!open) return;
|
|
2069
|
+
const node = dialogRef.current;
|
|
2070
|
+
const previous = document.activeElement;
|
|
2071
|
+
const onKeyDown = (event) => {
|
|
2072
|
+
if (event.key === "Escape") {
|
|
2073
|
+
setOpen(false);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (event.key !== "Tab" || !node) return;
|
|
2077
|
+
const focusable = [...node.querySelectorAll("input, select, button, a[href]")].filter((element) => !element.hasAttribute("disabled"));
|
|
2078
|
+
if (focusable.length === 0) return;
|
|
2079
|
+
const first = focusable[0];
|
|
2080
|
+
const last = focusable[focusable.length - 1];
|
|
2081
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
2082
|
+
event.preventDefault();
|
|
2083
|
+
last.focus();
|
|
2084
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
2085
|
+
event.preventDefault();
|
|
2086
|
+
first.focus();
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
document.addEventListener("keydown", onKeyDown);
|
|
2090
|
+
return () => {
|
|
2091
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
2092
|
+
(triggerRef.current ?? previous)?.focus();
|
|
2093
|
+
};
|
|
2094
|
+
}, [open, setOpen]);
|
|
2095
|
+
function set(field, value) {
|
|
2096
|
+
setDraft((current) => ({
|
|
2097
|
+
...current,
|
|
2098
|
+
[field]: value
|
|
2099
|
+
}));
|
|
2100
|
+
setErrors((current) => ({
|
|
2101
|
+
...current,
|
|
2102
|
+
[field]: void 0
|
|
2103
|
+
}));
|
|
2104
|
+
setFeedback(null);
|
|
2105
|
+
}
|
|
2106
|
+
/** Validate on leaving a field, so a mistake is caught next to where it was made (FR-VALID-8). */
|
|
2107
|
+
function blur(field) {
|
|
2108
|
+
const found = (0, _poly_x_core.validateProfileDraft)(draft)[field];
|
|
2109
|
+
setErrors((current) => ({
|
|
2110
|
+
...current,
|
|
2111
|
+
[field]: found
|
|
2112
|
+
}));
|
|
2113
|
+
}
|
|
2114
|
+
function choosePhoto(file) {
|
|
2115
|
+
if (!file) return;
|
|
2116
|
+
const invalid = (0, _poly_x_core.validateProfileImage)(file);
|
|
2117
|
+
if (invalid) {
|
|
2118
|
+
setPhotoError(invalid);
|
|
2119
|
+
setPhoto(null);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
setPhotoError(null);
|
|
2123
|
+
setPhoto({
|
|
2124
|
+
file,
|
|
2125
|
+
preview: URL.createObjectURL(file)
|
|
2126
|
+
});
|
|
2127
|
+
setFeedback(null);
|
|
2128
|
+
}
|
|
2129
|
+
async function save() {
|
|
2130
|
+
if (saving) return;
|
|
2131
|
+
const found = (0, _poly_x_core.validateProfileDraft)(draft);
|
|
2132
|
+
if (Object.keys(found).length > 0) {
|
|
2133
|
+
setErrors(found);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const changed = (0, _poly_x_core.buildProfileUpdate)(draft, stored);
|
|
2137
|
+
if (Object.keys(changed).length === 0 && !photo) {
|
|
2138
|
+
setOpen(false);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
const body = new FormData();
|
|
2142
|
+
for (const [field, value] of Object.entries(changed)) body.append(field, value);
|
|
2143
|
+
if (photo) body.append("image", photo.file);
|
|
2144
|
+
setSaving(true);
|
|
2145
|
+
setFeedback(null);
|
|
2146
|
+
try {
|
|
2147
|
+
const response = await fetch(PROFILE_ENDPOINT, {
|
|
2148
|
+
method: "POST",
|
|
2149
|
+
body
|
|
2150
|
+
});
|
|
2151
|
+
const payload = await response.json().catch(() => ({}));
|
|
2152
|
+
if (response.status === 401) {
|
|
2153
|
+
setFeedback({
|
|
2154
|
+
kind: "error",
|
|
2155
|
+
message: labels.signedOut
|
|
2156
|
+
});
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
if (!response.ok) {
|
|
2160
|
+
const field = (0, _poly_x_core.conflictField)(payload.message);
|
|
2161
|
+
if (field) {
|
|
2162
|
+
setErrors((current) => ({
|
|
2163
|
+
...current,
|
|
2164
|
+
[field]: payload.message
|
|
2165
|
+
}));
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
setFeedback({
|
|
2169
|
+
kind: "error",
|
|
2170
|
+
message: payload.message ?? labels.saveFailed
|
|
2171
|
+
});
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
const saved = payload.user ?? {};
|
|
2175
|
+
setStored(readProfile(saved));
|
|
2176
|
+
if (typeof saved.profilePicture === "string") setStoredPhoto(saved.profilePicture);
|
|
2177
|
+
setPhoto(null);
|
|
2178
|
+
setFeedback({
|
|
2179
|
+
kind: "success",
|
|
2180
|
+
message: labels.saved
|
|
2181
|
+
});
|
|
2182
|
+
await refresh().catch(() => false);
|
|
2183
|
+
props.onSaved?.(saved);
|
|
2184
|
+
setOpen(false);
|
|
2185
|
+
} catch {
|
|
2186
|
+
setFeedback({
|
|
2187
|
+
kind: "error",
|
|
2188
|
+
message: labels.saveFailed
|
|
2189
|
+
});
|
|
2190
|
+
} finally {
|
|
2191
|
+
setSaving(false);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (!isLoaded || !isSignedIn) return null;
|
|
2195
|
+
const rootClass = [
|
|
2196
|
+
"polyx-signin",
|
|
2197
|
+
"polyx-userprofile",
|
|
2198
|
+
props.className
|
|
2199
|
+
].filter(Boolean).join(" ");
|
|
2200
|
+
const displayName = [draft.firstName, draft.lastName].filter(Boolean).join(" ");
|
|
2201
|
+
function field(name, label, extra = {}) {
|
|
2202
|
+
const id = `${fieldId}-${name}`;
|
|
2203
|
+
const error = errors[name];
|
|
2204
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2205
|
+
className: "polyx-signin__field",
|
|
2206
|
+
children: [
|
|
2207
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
2208
|
+
className: "polyx-signin__label",
|
|
2209
|
+
htmlFor: id,
|
|
2210
|
+
children: [label, extra.optional ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2211
|
+
className: "polyx-userprofile__optional",
|
|
2212
|
+
children: [
|
|
2213
|
+
" (",
|
|
2214
|
+
labels.optional,
|
|
2215
|
+
")"
|
|
2216
|
+
]
|
|
2217
|
+
}) : null]
|
|
2218
|
+
}),
|
|
2219
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2220
|
+
id,
|
|
2221
|
+
className: "polyx-signin__input",
|
|
2222
|
+
type: extra.type ?? "text",
|
|
2223
|
+
value: draft[name] ?? "",
|
|
2224
|
+
placeholder: extra.placeholder,
|
|
2225
|
+
disabled: saving,
|
|
2226
|
+
"aria-required": extra.optional ? void 0 : true,
|
|
2227
|
+
"aria-invalid": error ? true : void 0,
|
|
2228
|
+
"aria-describedby": error ? `${id}-error` : void 0,
|
|
2229
|
+
onChange: (event) => set(name, event.target.value),
|
|
2230
|
+
onBlur: () => blur(name)
|
|
2231
|
+
}),
|
|
2232
|
+
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2233
|
+
id: `${id}-error`,
|
|
2234
|
+
className: "polyx-userprofile__field-error",
|
|
2235
|
+
children: error
|
|
2236
|
+
}) : null
|
|
2237
|
+
]
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2241
|
+
className: rootClass,
|
|
2242
|
+
style: brandingStyle,
|
|
2243
|
+
children: [props.open === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2244
|
+
ref: triggerRef,
|
|
2245
|
+
type: "button",
|
|
2246
|
+
className: ["polyx-userprofile__trigger", props.triggerClassName].filter(Boolean).join(" "),
|
|
2247
|
+
onClick: () => setOpen(true),
|
|
2248
|
+
children: props.trigger ?? labels.trigger
|
|
2249
|
+
}) : null, open && portalHost ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2250
|
+
className: "polyx-signin polyx-userprofile polyx-userprofile__overlay",
|
|
2251
|
+
style: brandingStyle,
|
|
2252
|
+
onMouseDown: () => setOpen(false),
|
|
2253
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2254
|
+
ref: dialogRef,
|
|
2255
|
+
role: "dialog",
|
|
2256
|
+
"aria-modal": "true",
|
|
2257
|
+
"aria-labelledby": titleId,
|
|
2258
|
+
className: ["polyx-userprofile__dialog", props.dialogClassName].filter(Boolean).join(" "),
|
|
2259
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
2260
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2261
|
+
className: "polyx-userprofile__header",
|
|
2262
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
2263
|
+
id: titleId,
|
|
2264
|
+
className: "polyx-userprofile__title",
|
|
2265
|
+
children: labels.title
|
|
2266
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2267
|
+
className: "polyx-userprofile__description",
|
|
2268
|
+
children: labels.description
|
|
2269
|
+
})]
|
|
2270
|
+
}), status === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2271
|
+
className: "polyx-userprofile__status",
|
|
2272
|
+
role: "status",
|
|
2273
|
+
children: labels.loading
|
|
2274
|
+
}) : status === "load_failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2275
|
+
className: "polyx-signin__error",
|
|
2276
|
+
role: "alert",
|
|
2277
|
+
children: labels.loadFailed
|
|
2278
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
2279
|
+
className: "polyx-signin__form",
|
|
2280
|
+
onSubmit: (event) => {
|
|
2281
|
+
event.preventDefault();
|
|
2282
|
+
save();
|
|
2283
|
+
},
|
|
2284
|
+
children: [
|
|
2285
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2286
|
+
className: "polyx-userprofile__photo",
|
|
2287
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
2288
|
+
src: photo?.preview ?? storedPhoto,
|
|
2289
|
+
name: displayName,
|
|
2290
|
+
size: 56
|
|
2291
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2292
|
+
className: "polyx-userprofile__photo-actions",
|
|
2293
|
+
children: [
|
|
2294
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2295
|
+
type: "button",
|
|
2296
|
+
className: "polyx-userprofile__photo-button",
|
|
2297
|
+
disabled: saving,
|
|
2298
|
+
onClick: () => fileRef.current?.click(),
|
|
2299
|
+
children: labels.changePhoto
|
|
2300
|
+
}),
|
|
2301
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2302
|
+
ref: fileRef,
|
|
2303
|
+
type: "file",
|
|
2304
|
+
accept: "image/*",
|
|
2305
|
+
className: "polyx-userprofile__file",
|
|
2306
|
+
"aria-label": labels.photo,
|
|
2307
|
+
onChange: (event) => choosePhoto(event.target.files?.[0])
|
|
2308
|
+
}),
|
|
2309
|
+
photoError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2310
|
+
className: "polyx-userprofile__field-error",
|
|
2311
|
+
children: photoError
|
|
2312
|
+
}) : null
|
|
2313
|
+
]
|
|
2314
|
+
})]
|
|
2315
|
+
}),
|
|
2316
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2317
|
+
className: "polyx-userprofile__row",
|
|
2318
|
+
children: [field("firstName", labels.firstName), field("lastName", labels.lastName)]
|
|
2319
|
+
}),
|
|
2320
|
+
field("phoneNumber", labels.phoneNumber, {
|
|
2321
|
+
optional: true,
|
|
2322
|
+
type: "tel",
|
|
2323
|
+
placeholder: "+14155552671"
|
|
2324
|
+
}),
|
|
2325
|
+
field("cnic", labels.cnic, { optional: true }),
|
|
2326
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2327
|
+
className: "polyx-signin__field",
|
|
2328
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
2329
|
+
className: "polyx-signin__label",
|
|
2330
|
+
htmlFor: `${fieldId}-gender`,
|
|
2331
|
+
children: [labels.gender, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2332
|
+
className: "polyx-userprofile__optional",
|
|
2333
|
+
children: [
|
|
2334
|
+
" (",
|
|
2335
|
+
labels.optional,
|
|
2336
|
+
")"
|
|
2337
|
+
]
|
|
2338
|
+
})]
|
|
2339
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
2340
|
+
id: `${fieldId}-gender`,
|
|
2341
|
+
className: "polyx-signin__input",
|
|
2342
|
+
value: draft.gender ?? "",
|
|
2343
|
+
disabled: saving,
|
|
2344
|
+
onChange: (event) => set("gender", event.target.value),
|
|
2345
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
2346
|
+
value: "",
|
|
2347
|
+
children: labels.genderUnset
|
|
2348
|
+
}), GENDERS.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
2349
|
+
value,
|
|
2350
|
+
children: value === "male" ? labels.genderMale : value === "female" ? labels.genderFemale : labels.genderOther
|
|
2351
|
+
}, value))]
|
|
2352
|
+
})]
|
|
2353
|
+
}),
|
|
2354
|
+
feedback ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2355
|
+
className: feedback.kind === "error" ? "polyx-signin__error" : "polyx-userprofile__success",
|
|
2356
|
+
role: feedback.kind === "error" ? "alert" : "status",
|
|
2357
|
+
children: feedback.message
|
|
2358
|
+
}) : null,
|
|
2359
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2360
|
+
className: "polyx-userprofile__actions",
|
|
2361
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2362
|
+
type: "button",
|
|
2363
|
+
className: "polyx-userprofile__cancel",
|
|
2364
|
+
disabled: saving,
|
|
2365
|
+
onClick: () => setOpen(false),
|
|
2366
|
+
children: labels.cancel
|
|
2367
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2368
|
+
type: "submit",
|
|
2369
|
+
className: "polyx-signin__submit",
|
|
2370
|
+
disabled: saving,
|
|
2371
|
+
children: saving ? labels.saving : labels.save
|
|
2372
|
+
})]
|
|
2373
|
+
})
|
|
2374
|
+
]
|
|
2375
|
+
})]
|
|
2376
|
+
})
|
|
2377
|
+
}), portalHost) : null]
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
//#endregion
|
|
1623
2381
|
//#region src/components/user-button.tsx
|
|
1624
2382
|
const DEFAULT_LABELS = {
|
|
1625
2383
|
trigger: "Account menu",
|
|
@@ -1726,6 +2484,7 @@ function UserButtonRoot(props) {
|
|
|
1726
2484
|
const { isLoaded, isSignedIn, signOut } = useAuth();
|
|
1727
2485
|
const user = useUser();
|
|
1728
2486
|
const [open, setOpen] = (0, react.useState)(props.defaultOpen ?? false);
|
|
2487
|
+
const [profileOpen, setProfileOpen] = (0, react.useState)(false);
|
|
1729
2488
|
const [focused, setFocused] = (0, react.useState)(0);
|
|
1730
2489
|
const [side, setSide] = (0, react.useState)(props.side && props.side !== "auto" ? props.side : "bottom");
|
|
1731
2490
|
const triggerRef = (0, react.useRef)(null);
|
|
@@ -1802,6 +2561,12 @@ function UserButtonRoot(props) {
|
|
|
1802
2561
|
labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
|
|
1803
2562
|
href: props.manageAccountUrl
|
|
1804
2563
|
});
|
|
2564
|
+
else if (props.showUserProfile) builtIns.set("manageAccount", {
|
|
2565
|
+
key: "manageAccount",
|
|
2566
|
+
label: labels.manageAccount,
|
|
2567
|
+
labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
|
|
2568
|
+
onClick: () => setProfileOpen(true)
|
|
2569
|
+
});
|
|
1805
2570
|
builtIns.set("signOut", {
|
|
1806
2571
|
key: "signOut",
|
|
1807
2572
|
label: labels.signOut,
|
|
@@ -1841,81 +2606,89 @@ function UserButtonRoot(props) {
|
|
|
1841
2606
|
props.className
|
|
1842
2607
|
].filter(Boolean).join(" "),
|
|
1843
2608
|
"data-polyx-userbutton": open ? "open" : "closed",
|
|
1844
|
-
children: [
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
name,
|
|
1859
|
-
size: props.avatarSize
|
|
1860
|
-
}), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1861
|
-
className: "polyx-userbutton__trigger-name",
|
|
1862
|
-
children: name
|
|
1863
|
-
}) : null] })
|
|
1864
|
-
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1865
|
-
ref: menuRef,
|
|
1866
|
-
id: menuId,
|
|
1867
|
-
role: "menu",
|
|
1868
|
-
className: "polyx-userbutton__menu",
|
|
1869
|
-
"data-polyx-side": side,
|
|
1870
|
-
"data-polyx-align": align,
|
|
1871
|
-
onKeyDown: onMenuKeyDown,
|
|
1872
|
-
children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1873
|
-
className: "polyx-userbutton__identity",
|
|
1874
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
2609
|
+
children: [
|
|
2610
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2611
|
+
ref: triggerRef,
|
|
2612
|
+
type: "button",
|
|
2613
|
+
className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
|
|
2614
|
+
"aria-haspopup": "menu",
|
|
2615
|
+
"aria-expanded": open,
|
|
2616
|
+
"aria-controls": open ? menuId : void 0,
|
|
2617
|
+
"aria-label": labels.trigger,
|
|
2618
|
+
onClick: () => {
|
|
2619
|
+
setFocused(0);
|
|
2620
|
+
setOpen((current) => !current);
|
|
2621
|
+
},
|
|
2622
|
+
children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
1875
2623
|
src: user.avatarUrl,
|
|
1876
2624
|
name,
|
|
1877
|
-
size:
|
|
1878
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
1879
|
-
className: "polyx-
|
|
1880
|
-
children:
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2625
|
+
size: props.avatarSize
|
|
2626
|
+
}), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2627
|
+
className: "polyx-userbutton__trigger-name",
|
|
2628
|
+
children: name
|
|
2629
|
+
}) : null] })
|
|
2630
|
+
}),
|
|
2631
|
+
open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2632
|
+
ref: menuRef,
|
|
2633
|
+
id: menuId,
|
|
2634
|
+
role: "menu",
|
|
2635
|
+
className: "polyx-userbutton__menu",
|
|
2636
|
+
"data-polyx-side": side,
|
|
2637
|
+
"data-polyx-align": align,
|
|
2638
|
+
onKeyDown: onMenuKeyDown,
|
|
2639
|
+
children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2640
|
+
className: "polyx-userbutton__identity",
|
|
2641
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
2642
|
+
src: user.avatarUrl,
|
|
2643
|
+
name,
|
|
2644
|
+
size: 36
|
|
2645
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2646
|
+
className: "polyx-userbutton__identity-text",
|
|
2647
|
+
children: [user.displayName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2648
|
+
className: "polyx-userbutton__name",
|
|
2649
|
+
children: user.displayName
|
|
2650
|
+
}) : null, user.email ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2651
|
+
className: "polyx-userbutton__email",
|
|
2652
|
+
children: user.email
|
|
2653
|
+
}) : null]
|
|
2654
|
+
})]
|
|
2655
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
2656
|
+
className: "polyx-userbutton__items",
|
|
2657
|
+
children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2658
|
+
className: "polyx-userbutton__custom",
|
|
2659
|
+
children: item.custom
|
|
2660
|
+
}) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
|
|
2661
|
+
ref: (node) => {
|
|
2662
|
+
itemRefs.current[index] = node;
|
|
2663
|
+
},
|
|
2664
|
+
role: "menuitem",
|
|
2665
|
+
tabIndex: index === focused ? 0 : -1,
|
|
2666
|
+
className: "polyx-userbutton__item",
|
|
2667
|
+
href: item.href,
|
|
2668
|
+
onClick: () => close(false),
|
|
2669
|
+
children: [item.labelIcon, item.label]
|
|
2670
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2671
|
+
ref: (node) => {
|
|
2672
|
+
itemRefs.current[index] = node;
|
|
2673
|
+
},
|
|
2674
|
+
role: "menuitem",
|
|
2675
|
+
tabIndex: index === focused ? 0 : -1,
|
|
2676
|
+
type: "button",
|
|
2677
|
+
className: "polyx-userbutton__item",
|
|
2678
|
+
onClick: () => {
|
|
2679
|
+
close(false);
|
|
2680
|
+
item.onClick?.();
|
|
2681
|
+
},
|
|
2682
|
+
children: [item.labelIcon, item.label]
|
|
2683
|
+
}) }, item.key))
|
|
1887
2684
|
})]
|
|
1888
|
-
})
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
itemRefs.current[index] = node;
|
|
1896
|
-
},
|
|
1897
|
-
role: "menuitem",
|
|
1898
|
-
tabIndex: index === focused ? 0 : -1,
|
|
1899
|
-
className: "polyx-userbutton__item",
|
|
1900
|
-
href: item.href,
|
|
1901
|
-
onClick: () => close(false),
|
|
1902
|
-
children: [item.labelIcon, item.label]
|
|
1903
|
-
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1904
|
-
ref: (node) => {
|
|
1905
|
-
itemRefs.current[index] = node;
|
|
1906
|
-
},
|
|
1907
|
-
role: "menuitem",
|
|
1908
|
-
tabIndex: index === focused ? 0 : -1,
|
|
1909
|
-
type: "button",
|
|
1910
|
-
className: "polyx-userbutton__item",
|
|
1911
|
-
onClick: () => {
|
|
1912
|
-
close(false);
|
|
1913
|
-
item.onClick?.();
|
|
1914
|
-
},
|
|
1915
|
-
children: [item.labelIcon, item.label]
|
|
1916
|
-
}) }, item.key))
|
|
1917
|
-
})]
|
|
1918
|
-
}) : null]
|
|
2685
|
+
}) : null,
|
|
2686
|
+
props.showUserProfile && !props.manageAccountUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserProfile, {
|
|
2687
|
+
...props.userProfileProps,
|
|
2688
|
+
open: profileOpen,
|
|
2689
|
+
onOpenChange: setProfileOpen
|
|
2690
|
+
}) : null
|
|
2691
|
+
]
|
|
1919
2692
|
});
|
|
1920
2693
|
}
|
|
1921
2694
|
function GearIcon() {
|
|
@@ -1980,6 +2753,33 @@ const UserButton = Object.assign(UserButtonRoot, {
|
|
|
1980
2753
|
Header
|
|
1981
2754
|
});
|
|
1982
2755
|
//#endregion
|
|
2756
|
+
//#region src/auth-event.ts
|
|
2757
|
+
/**
|
|
2758
|
+
* Read a one-shot auth event published by the BFF (F017 / FR-CLIENT-5).
|
|
2759
|
+
*
|
|
2760
|
+
* Sign-in and sign-out are full-page navigations, so an app cannot simply react in the
|
|
2761
|
+
* handler that triggered them — the page is gone. The BFF leaves a short-lived, readable
|
|
2762
|
+
* cookie; the destination page consumes it once.
|
|
2763
|
+
*/
|
|
2764
|
+
/** Must match `AUTH_EVENT_COOKIE` in `@poly-x/next`. */
|
|
2765
|
+
const AUTH_EVENT_COOKIE = "polyx_auth_event";
|
|
2766
|
+
function readCookie(name) {
|
|
2767
|
+
if (typeof document === "undefined") return void 0;
|
|
2768
|
+
return document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* Read the pending auth event and clear it, so it fires exactly once.
|
|
2772
|
+
*
|
|
2773
|
+
* Returns `null` when there is nothing pending — which is the normal case on almost every
|
|
2774
|
+
* page load. Safe to call during render or in an effect; it is a no-op on the server.
|
|
2775
|
+
*/
|
|
2776
|
+
function consumeAuthEvent() {
|
|
2777
|
+
const value = readCookie(AUTH_EVENT_COOKIE);
|
|
2778
|
+
if (value !== "signed-in" && value !== "signed-out") return null;
|
|
2779
|
+
document.cookie = `${AUTH_EVENT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
2780
|
+
return value;
|
|
2781
|
+
}
|
|
2782
|
+
//#endregion
|
|
1983
2783
|
//#region src/live-authz.ts
|
|
1984
2784
|
const DEFAULT_POLL_MS = 6e4;
|
|
1985
2785
|
const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
|
|
@@ -2118,7 +2918,8 @@ function useAuthzChanged(callback) {
|
|
|
2118
2918
|
* F006; this entry is the state foundation (provider, hooks, browser seams).
|
|
2119
2919
|
*/
|
|
2120
2920
|
const PACKAGE_NAME = "@poly-x/react";
|
|
2121
|
-
|
|
2921
|
+
/** The published version of this package, injected at build time. */
|
|
2922
|
+
const version = "0.3.0";
|
|
2122
2923
|
//#endregion
|
|
2123
2924
|
exports.AuthCallback = AuthCallback;
|
|
2124
2925
|
exports.BroadcastAuthChannel = BroadcastAuthChannel;
|
|
@@ -2134,17 +2935,26 @@ exports.SessionStoragePkceStore = SessionStoragePkceStore;
|
|
|
2134
2935
|
exports.SignIn = SignIn;
|
|
2135
2936
|
exports.UserAvatar = UserAvatar;
|
|
2136
2937
|
exports.UserButton = UserButton;
|
|
2938
|
+
exports.UserProfile = UserProfile;
|
|
2939
|
+
exports.WARM_HOP_TIMEOUT_MS = WARM_HOP_TIMEOUT_MS;
|
|
2137
2940
|
exports.WebLocksLock = WebLocksLock;
|
|
2138
2941
|
exports.WindowBrowserBridge = WindowBrowserBridge;
|
|
2942
|
+
exports.attemptWarmHop = attemptWarmHop;
|
|
2943
|
+
exports.attemptWarmHopOnce = attemptWarmHopOnce;
|
|
2139
2944
|
exports.capturePreciseLocation = capturePreciseLocation;
|
|
2945
|
+
exports.consumeAuthEvent = consumeAuthEvent;
|
|
2140
2946
|
exports.createLoginController = createLoginController;
|
|
2141
2947
|
exports.initialsFrom = initialsFrom;
|
|
2948
|
+
exports.resetWarmHop = resetWarmHop;
|
|
2142
2949
|
exports.resolveSide = resolveSide;
|
|
2143
2950
|
exports.startLiveAuthz = startLiveAuthz;
|
|
2951
|
+
exports.switchWorkspace = switchWorkspace;
|
|
2144
2952
|
exports.useAuth = useAuth;
|
|
2145
2953
|
exports.useAuthCallback = useAuthCallback;
|
|
2146
2954
|
exports.useAuthzChanged = useAuthzChanged;
|
|
2147
2955
|
exports.useLiveAuthz = useLiveAuthz;
|
|
2148
2956
|
exports.useSession = useSession;
|
|
2149
2957
|
exports.useUser = useUser;
|
|
2958
|
+
exports.useWarmHop = useWarmHop;
|
|
2150
2959
|
exports.version = version;
|
|
2960
|
+
exports.warmHopAttempted = warmHopAttempted;
|