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