@poly-x/react 0.1.0-alpha.16 → 0.1.0-alpha.18

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/dist/index.cjs CHANGED
@@ -515,43 +515,85 @@ function Protected({ children, fallback = null, loading = null }) {
515
515
  }
516
516
  //#endregion
517
517
  //#region src/geolocation.ts
518
- /**
519
- * Capture the browser's precise location IF the user consented. Resolves to a consent-labelled
520
- * result and never throws/rejects.
521
- */
522
- async function capturePreciseLocation(consented, opts = {}) {
523
- if (!consented) return { consent: "denied" };
524
- const geo = opts.geolocation ?? (typeof navigator !== "undefined" ? navigator.geolocation : void 0);
525
- if (!geo) return { consent: "denied" };
526
- const timeoutMs = opts.timeoutMs ?? 8e3;
518
+ /** W3C Geolocation `PositionError.PERMISSION_DENIED`. The only code that means a real "no". */
519
+ const PERMISSION_DENIED = 1;
520
+ /** One capture attempt at a given accuracy. Never rejects. */
521
+ function attemptCapture(geo, highAccuracy, timeout) {
527
522
  return new Promise((resolve) => {
528
523
  let settled = false;
529
- const done = (result) => {
524
+ const done = (outcome) => {
530
525
  if (settled) return;
531
526
  settled = true;
532
- resolve(result);
527
+ resolve(outcome);
533
528
  };
534
- const timer = setTimeout(() => done({ consent: "denied" }), timeoutMs + 500);
535
- const finish = (result) => {
529
+ const timer = setTimeout(() => done({
530
+ result: { consent: "granted" },
531
+ retriable: true
532
+ }), timeout + 1500);
533
+ const finish = (outcome) => {
536
534
  clearTimeout(timer);
537
- done(result);
535
+ done(outcome);
538
536
  };
539
537
  try {
540
538
  geo.getCurrentPosition((position) => finish({
541
- latitude: String(position.coords.latitude),
542
- longitude: String(position.coords.longitude),
543
- accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : void 0,
544
- consent: "granted"
545
- }), () => finish({ consent: "denied" }), {
546
- enableHighAccuracy: true,
547
- timeout: timeoutMs,
539
+ result: {
540
+ latitude: String(position.coords.latitude),
541
+ longitude: String(position.coords.longitude),
542
+ accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : void 0,
543
+ consent: "granted"
544
+ },
545
+ retriable: false
546
+ }), (err) => {
547
+ const code = err?.code;
548
+ const denied = typeof code === "number" && code === PERMISSION_DENIED;
549
+ finish({
550
+ result: { consent: denied ? "denied" : "granted" },
551
+ retriable: !denied
552
+ });
553
+ }, {
554
+ enableHighAccuracy: highAccuracy,
555
+ timeout,
548
556
  maximumAge: 0
549
557
  });
550
558
  } catch {
551
- finish({ consent: "denied" });
559
+ finish({
560
+ result: { consent: "granted" },
561
+ retriable: true
562
+ });
552
563
  }
553
564
  });
554
565
  }
566
+ /**
567
+ * Capture the browser's precise location. Resolves to a consent-labelled result and never
568
+ * throws/rejects. High-accuracy first; on a no-fix (not a denial) retries once at low accuracy.
569
+ */
570
+ async function capturePreciseLocation(consented, opts = {}) {
571
+ if (!consented) return { consent: "denied" };
572
+ const geo = opts.geolocation ?? (typeof navigator !== "undefined" ? navigator.geolocation : void 0);
573
+ if (!geo) return { consent: "denied" };
574
+ const highAccuracyMs = opts.timeoutMs ?? 8e3;
575
+ const lowAccuracyMs = opts.fallbackTimeoutMs ?? 12e3;
576
+ const first = await attemptCapture(geo, true, highAccuracyMs);
577
+ if (first.result.latitude !== void 0 || !first.retriable) return first.result;
578
+ return (await attemptCapture(geo, false, lowAccuracyMs)).result;
579
+ }
580
+ //#endregion
581
+ //#region src/device-context.ts
582
+ /** Capture the browser's timezone + screen resolution. Never throws; omits anything unavailable. */
583
+ function captureDeviceContext() {
584
+ const context = {};
585
+ try {
586
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
587
+ if (typeof timezone === "string" && timezone) context.timezone = timezone;
588
+ } catch {}
589
+ try {
590
+ if (typeof window !== "undefined" && window.screen) {
591
+ const { width, height } = window.screen;
592
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) context.screenResolution = `${Math.round(width)}x${Math.round(height)}`;
593
+ }
594
+ } catch {}
595
+ return context;
596
+ }
555
597
  //#endregion
556
598
  //#region src/components/sign-in.tsx
557
599
  const DEFAULT_LABELS$3 = {
@@ -713,6 +755,7 @@ function SignIn(props) {
713
755
  setError(null);
714
756
  try {
715
757
  const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
758
+ const deviceContext = captureDeviceContext();
716
759
  const body = await (await fetch(action, {
717
760
  method: "POST",
718
761
  headers: { "content-type": "application/json" },
@@ -722,6 +765,7 @@ function SignIn(props) {
722
765
  organizationCode: props.organizationCode,
723
766
  tenantId: options.tenantId,
724
767
  ...geo ? { geo } : {},
768
+ ...Object.keys(deviceContext).length ? { deviceContext } : {},
725
769
  ...options.passwords
726
770
  })
727
771
  })).json().catch(() => ({}));
package/dist/index.d.cts CHANGED
@@ -410,11 +410,17 @@ declare function AuthCallback({
410
410
  /**
411
411
  * @poly-x/react/geolocation — browser precise-location capture for sign-in (v6, FR-CNST/FR-LOC).
412
412
  *
413
- * The consent-gated bridge to `navigator.geolocation`. It NEVER rejects: a decline, a browser
414
- * permission denial, a missing API, or a timeout all resolve to `{ consent: "denied" }` with no
415
- * coordinates, so the caller can always proceed — sign-in must never block on location (D52).
416
- * The captured value is posted to the BFF, which forwards it to the platform on the token
417
- * exchange; the platform stores precise only when consent is `granted`, else coarse (IP).
413
+ * The consent-gated bridge to `navigator.geolocation`. It NEVER rejects: a missing API resolves
414
+ * to `{ consent: "denied" }` with no coordinates so the caller can always proceed — sign-in must
415
+ * never block on location (D52).
416
+ *
417
+ * Consent vs. fix are kept SEPARATE. `consent` reflects the browser PERMISSION decision only:
418
+ * `denied` means the user actually said no (`PERMISSION_DENIED`). A position that is merely
419
+ * unavailable or times out (common on desktops with no GPS) is NOT a denial — the user consented,
420
+ * we just couldn't get coordinates, so consent stays `granted` with no coords and the platform
421
+ * uses the coarse (IP) fallback. Conflating the two is why an allowed login could be stored as
422
+ * `denied` + coarse. On a no-fix (not a denial) we retry once at low accuracy (network-based),
423
+ * which is far more reliable than high-accuracy GPS on desktop browsers.
418
424
  */
419
425
  /** Minimal shape of the browser Geolocation API we depend on (injectable for testing). */
420
426
  interface GeolocationLike {
@@ -424,28 +430,33 @@ interface GeolocationLike {
424
430
  longitude: number;
425
431
  accuracy?: number;
426
432
  };
427
- }) => void, error?: (err: unknown) => void, options?: {
433
+ }) => void, error?: (err: {
434
+ code?: number;
435
+ } | unknown) => void, options?: {
428
436
  enableHighAccuracy?: boolean;
429
437
  timeout?: number;
430
438
  maximumAge?: number;
431
439
  }): void;
432
440
  }
433
- /** The consent-labelled result of a capture attempt. Coordinates present only when granted. */
441
+ /** The consent-labelled result of a capture attempt. Coordinates present only on a successful fix. */
434
442
  interface CapturedGeo {
435
443
  latitude?: string;
436
444
  longitude?: string;
437
445
  accuracyMeters?: number;
446
+ /** The browser PERMISSION decision only — never downgraded by a failed/timed-out fix. */
438
447
  consent: "granted" | "denied";
439
448
  }
440
449
  interface CaptureOptions {
441
- /** Max time to wait for a fix before giving up (coarse fallback). Default 8000ms. */
450
+ /** High-accuracy (GPS) attempt budget before the network-based retry. Default 8000ms. */
442
451
  timeoutMs?: number;
452
+ /** Low-accuracy (network) retry budget when the first attempt gets no fix. Default 12000ms. */
453
+ fallbackTimeoutMs?: number;
443
454
  /** Override the geolocation source (tests). Defaults to `navigator.geolocation`. */
444
455
  geolocation?: GeolocationLike;
445
456
  }
446
457
  /**
447
- * Capture the browser's precise location IF the user consented. Resolves to a consent-labelled
448
- * result and never throws/rejects.
458
+ * Capture the browser's precise location. Resolves to a consent-labelled result and never
459
+ * throws/rejects. High-accuracy first; on a no-fix (not a denial) retries once at low accuracy.
449
460
  */
450
461
  declare function capturePreciseLocation(consented: boolean, opts?: CaptureOptions): Promise<CapturedGeo>;
451
462
  //#endregion
package/dist/index.d.mts CHANGED
@@ -410,11 +410,17 @@ declare function AuthCallback({
410
410
  /**
411
411
  * @poly-x/react/geolocation — browser precise-location capture for sign-in (v6, FR-CNST/FR-LOC).
412
412
  *
413
- * The consent-gated bridge to `navigator.geolocation`. It NEVER rejects: a decline, a browser
414
- * permission denial, a missing API, or a timeout all resolve to `{ consent: "denied" }` with no
415
- * coordinates, so the caller can always proceed — sign-in must never block on location (D52).
416
- * The captured value is posted to the BFF, which forwards it to the platform on the token
417
- * exchange; the platform stores precise only when consent is `granted`, else coarse (IP).
413
+ * The consent-gated bridge to `navigator.geolocation`. It NEVER rejects: a missing API resolves
414
+ * to `{ consent: "denied" }` with no coordinates so the caller can always proceed — sign-in must
415
+ * never block on location (D52).
416
+ *
417
+ * Consent vs. fix are kept SEPARATE. `consent` reflects the browser PERMISSION decision only:
418
+ * `denied` means the user actually said no (`PERMISSION_DENIED`). A position that is merely
419
+ * unavailable or times out (common on desktops with no GPS) is NOT a denial — the user consented,
420
+ * we just couldn't get coordinates, so consent stays `granted` with no coords and the platform
421
+ * uses the coarse (IP) fallback. Conflating the two is why an allowed login could be stored as
422
+ * `denied` + coarse. On a no-fix (not a denial) we retry once at low accuracy (network-based),
423
+ * which is far more reliable than high-accuracy GPS on desktop browsers.
418
424
  */
419
425
  /** Minimal shape of the browser Geolocation API we depend on (injectable for testing). */
420
426
  interface GeolocationLike {
@@ -424,28 +430,33 @@ interface GeolocationLike {
424
430
  longitude: number;
425
431
  accuracy?: number;
426
432
  };
427
- }) => void, error?: (err: unknown) => void, options?: {
433
+ }) => void, error?: (err: {
434
+ code?: number;
435
+ } | unknown) => void, options?: {
428
436
  enableHighAccuracy?: boolean;
429
437
  timeout?: number;
430
438
  maximumAge?: number;
431
439
  }): void;
432
440
  }
433
- /** The consent-labelled result of a capture attempt. Coordinates present only when granted. */
441
+ /** The consent-labelled result of a capture attempt. Coordinates present only on a successful fix. */
434
442
  interface CapturedGeo {
435
443
  latitude?: string;
436
444
  longitude?: string;
437
445
  accuracyMeters?: number;
446
+ /** The browser PERMISSION decision only — never downgraded by a failed/timed-out fix. */
438
447
  consent: "granted" | "denied";
439
448
  }
440
449
  interface CaptureOptions {
441
- /** Max time to wait for a fix before giving up (coarse fallback). Default 8000ms. */
450
+ /** High-accuracy (GPS) attempt budget before the network-based retry. Default 8000ms. */
442
451
  timeoutMs?: number;
452
+ /** Low-accuracy (network) retry budget when the first attempt gets no fix. Default 12000ms. */
453
+ fallbackTimeoutMs?: number;
443
454
  /** Override the geolocation source (tests). Defaults to `navigator.geolocation`. */
444
455
  geolocation?: GeolocationLike;
445
456
  }
446
457
  /**
447
- * Capture the browser's precise location IF the user consented. Resolves to a consent-labelled
448
- * result and never throws/rejects.
458
+ * Capture the browser's precise location. Resolves to a consent-labelled result and never
459
+ * throws/rejects. High-accuracy first; on a no-fix (not a denial) retries once at low accuracy.
449
460
  */
450
461
  declare function capturePreciseLocation(consented: boolean, opts?: CaptureOptions): Promise<CapturedGeo>;
451
462
  //#endregion
package/dist/index.mjs CHANGED
@@ -514,43 +514,85 @@ function Protected({ children, fallback = null, loading = null }) {
514
514
  }
515
515
  //#endregion
516
516
  //#region src/geolocation.ts
517
- /**
518
- * Capture the browser's precise location IF the user consented. Resolves to a consent-labelled
519
- * result and never throws/rejects.
520
- */
521
- async function capturePreciseLocation(consented, opts = {}) {
522
- if (!consented) return { consent: "denied" };
523
- const geo = opts.geolocation ?? (typeof navigator !== "undefined" ? navigator.geolocation : void 0);
524
- if (!geo) return { consent: "denied" };
525
- const timeoutMs = opts.timeoutMs ?? 8e3;
517
+ /** W3C Geolocation `PositionError.PERMISSION_DENIED`. The only code that means a real "no". */
518
+ const PERMISSION_DENIED = 1;
519
+ /** One capture attempt at a given accuracy. Never rejects. */
520
+ function attemptCapture(geo, highAccuracy, timeout) {
526
521
  return new Promise((resolve) => {
527
522
  let settled = false;
528
- const done = (result) => {
523
+ const done = (outcome) => {
529
524
  if (settled) return;
530
525
  settled = true;
531
- resolve(result);
526
+ resolve(outcome);
532
527
  };
533
- const timer = setTimeout(() => done({ consent: "denied" }), timeoutMs + 500);
534
- const finish = (result) => {
528
+ const timer = setTimeout(() => done({
529
+ result: { consent: "granted" },
530
+ retriable: true
531
+ }), timeout + 1500);
532
+ const finish = (outcome) => {
535
533
  clearTimeout(timer);
536
- done(result);
534
+ done(outcome);
537
535
  };
538
536
  try {
539
537
  geo.getCurrentPosition((position) => finish({
540
- latitude: String(position.coords.latitude),
541
- longitude: String(position.coords.longitude),
542
- accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : void 0,
543
- consent: "granted"
544
- }), () => finish({ consent: "denied" }), {
545
- enableHighAccuracy: true,
546
- timeout: timeoutMs,
538
+ result: {
539
+ latitude: String(position.coords.latitude),
540
+ longitude: String(position.coords.longitude),
541
+ accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : void 0,
542
+ consent: "granted"
543
+ },
544
+ retriable: false
545
+ }), (err) => {
546
+ const code = err?.code;
547
+ const denied = typeof code === "number" && code === PERMISSION_DENIED;
548
+ finish({
549
+ result: { consent: denied ? "denied" : "granted" },
550
+ retriable: !denied
551
+ });
552
+ }, {
553
+ enableHighAccuracy: highAccuracy,
554
+ timeout,
547
555
  maximumAge: 0
548
556
  });
549
557
  } catch {
550
- finish({ consent: "denied" });
558
+ finish({
559
+ result: { consent: "granted" },
560
+ retriable: true
561
+ });
551
562
  }
552
563
  });
553
564
  }
565
+ /**
566
+ * Capture the browser's precise location. Resolves to a consent-labelled result and never
567
+ * throws/rejects. High-accuracy first; on a no-fix (not a denial) retries once at low accuracy.
568
+ */
569
+ async function capturePreciseLocation(consented, opts = {}) {
570
+ if (!consented) return { consent: "denied" };
571
+ const geo = opts.geolocation ?? (typeof navigator !== "undefined" ? navigator.geolocation : void 0);
572
+ if (!geo) return { consent: "denied" };
573
+ const highAccuracyMs = opts.timeoutMs ?? 8e3;
574
+ const lowAccuracyMs = opts.fallbackTimeoutMs ?? 12e3;
575
+ const first = await attemptCapture(geo, true, highAccuracyMs);
576
+ if (first.result.latitude !== void 0 || !first.retriable) return first.result;
577
+ return (await attemptCapture(geo, false, lowAccuracyMs)).result;
578
+ }
579
+ //#endregion
580
+ //#region src/device-context.ts
581
+ /** Capture the browser's timezone + screen resolution. Never throws; omits anything unavailable. */
582
+ function captureDeviceContext() {
583
+ const context = {};
584
+ try {
585
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
586
+ if (typeof timezone === "string" && timezone) context.timezone = timezone;
587
+ } catch {}
588
+ try {
589
+ if (typeof window !== "undefined" && window.screen) {
590
+ const { width, height } = window.screen;
591
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) context.screenResolution = `${Math.round(width)}x${Math.round(height)}`;
592
+ }
593
+ } catch {}
594
+ return context;
595
+ }
554
596
  //#endregion
555
597
  //#region src/components/sign-in.tsx
556
598
  const DEFAULT_LABELS$3 = {
@@ -712,6 +754,7 @@ function SignIn(props) {
712
754
  setError(null);
713
755
  try {
714
756
  const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
757
+ const deviceContext = captureDeviceContext();
715
758
  const body = await (await fetch(action, {
716
759
  method: "POST",
717
760
  headers: { "content-type": "application/json" },
@@ -721,6 +764,7 @@ function SignIn(props) {
721
764
  organizationCode: props.organizationCode,
722
765
  tenantId: options.tenantId,
723
766
  ...geo ? { geo } : {},
767
+ ...Object.keys(deviceContext).length ? { deviceContext } : {},
724
768
  ...options.passwords
725
769
  })
726
770
  })).json().catch(() => ({}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/react",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.18",
4
4
  "description": "PolyX SDK for React SPAs - provider, hooks, drop-in auth UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "module": "./dist/index.mjs",
28
28
  "types": "./dist/index.d.cts",
29
29
  "dependencies": {
30
- "@poly-x/core": "0.1.0-alpha.16"
30
+ "@poly-x/core": "0.1.0-alpha.18"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18",