@unhingged/vizu-core 0.1.4 → 0.1.6

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
@@ -372,15 +372,76 @@ var CloudStorageAdapter = class {
372
372
  this.pendingAuth = null;
373
373
  /** Whether we've already fired onAuthChanged for the current token. */
374
374
  this.lastNotifiedTokenId = null;
375
+ /**
376
+ * Sticky flag set when /connect tells us this workspace is inaccessible
377
+ * to the signed-in user (no role, origin not allowed, workspace gone).
378
+ * Future resolveToken calls reject immediately instead of reopening the
379
+ * popup — the answer won't change without a Clerk account switch, which
380
+ * a page reload covers. Cleared only by reload (or future explicit API).
381
+ */
382
+ this.accessDenied = false;
383
+ /**
384
+ * Last denial reason from /connect's postMessage — useful for hosts that
385
+ * want to render a workspace-level "no access" banner. Null until denied.
386
+ */
387
+ this.accessDeniedReason = null;
375
388
  this.workspace = opts.workspace;
376
389
  this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
377
390
  this.autoSignIn = opts.autoSignIn !== false;
378
391
  this.onAuthChanged = opts.onAuthChanged;
379
- this.cachedToken = this.readStoredToken();
392
+ const fromHash = this.readHashToken();
393
+ if (fromHash) {
394
+ this.cachedToken = fromHash;
395
+ this.writeStoredToken(fromHash);
396
+ } else {
397
+ this.cachedToken = this.readStoredToken();
398
+ }
380
399
  if (this.cachedToken && !this.isExpired(this.cachedToken)) {
381
400
  this.fireAuthChanged(this.cachedToken);
382
401
  }
383
402
  }
403
+ /**
404
+ * Pull a token out of the URL fragment if /connect just redirected us
405
+ * back here. Strips the fragment on success so the token doesn't sit
406
+ * in browser history or get shared if the user copies the URL.
407
+ *
408
+ * The fragment is base64-encoded JSON with compact keys; see
409
+ * apps/landing/app/connect/RedirectComplete.tsx for the producer side.
410
+ */
411
+ readHashToken() {
412
+ if (typeof window === "undefined") return null;
413
+ const hash = window.location.hash;
414
+ if (!hash || hash.length < 2) return null;
415
+ const params = new URLSearchParams(hash.slice(1));
416
+ const payload = params.get("vizu_auth");
417
+ if (!payload) return null;
418
+ try {
419
+ const json = JSON.parse(atob(payload));
420
+ if (!json.t || !json.e || !json.u || !json.w) return null;
421
+ if (json.w !== this.workspace) return null;
422
+ const stored = {
423
+ token: json.t,
424
+ expiresAt: json.e,
425
+ userId: json.u,
426
+ user: typeof json.n === "string" ? {
427
+ id: json.u,
428
+ name: json.n,
429
+ avatarUrl: typeof json.a === "string" ? json.a : void 0
430
+ } : null
431
+ };
432
+ params.delete("vizu_auth");
433
+ const remaining = params.toString();
434
+ const newHash = remaining ? `#${remaining}` : "";
435
+ try {
436
+ const newUrl = window.location.pathname + window.location.search + newHash;
437
+ window.history.replaceState(null, "", newUrl);
438
+ } catch {
439
+ }
440
+ return stored;
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
384
445
  /**
385
446
  * Open the sign-in popup right now (or no-op if a valid token is
386
447
  * cached). Vizu calls this on enable() so the user signs in BEFORE
@@ -393,6 +454,20 @@ var CloudStorageAdapter = class {
393
454
  async preflightAuth() {
394
455
  await this.resolveToken();
395
456
  }
457
+ /**
458
+ * Whether /connect has already told us the user can't access this
459
+ * workspace. Vizu's write gate checks this before reopening the popup —
460
+ * a denied state means clicking again will yield the same "No access"
461
+ * page, so we suppress the retry until a page reload (or explicit API
462
+ * call in the future).
463
+ */
464
+ isAccessDenied() {
465
+ return this.accessDenied;
466
+ }
467
+ /** Last denial reason; null when not denied. */
468
+ accessDenialReason() {
469
+ return this.accessDeniedReason;
470
+ }
396
471
  /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
397
472
  async load(_namespace) {
398
473
  const out = [];
@@ -583,6 +658,12 @@ var CloudStorageAdapter = class {
583
658
  this.fireAuthChanged(fresh);
584
659
  return fresh;
585
660
  }
661
+ if (this.accessDenied) {
662
+ throw makeAuthError(
663
+ "access_denied",
664
+ `No access to workspace "${this.workspace}" (${this.accessDeniedReason ?? "no_access"}).`
665
+ );
666
+ }
586
667
  if (!this.autoSignIn) {
587
668
  throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
588
669
  }
@@ -629,18 +710,39 @@ var CloudStorageAdapter = class {
629
710
  `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
630
711
  );
631
712
  if (!popup) {
632
- return reject(
633
- makeAuthError(
634
- "popup_blocked",
635
- "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
636
- )
637
- );
713
+ try {
714
+ const returnTo = window.location.href;
715
+ const redirectUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin) + "&return_to=" + encodeURIComponent(returnTo);
716
+ window.location.assign(redirectUrl);
717
+ return;
718
+ } catch (err) {
719
+ return reject(
720
+ makeAuthError(
721
+ "popup_blocked",
722
+ "Sign-in popup was blocked and redirect fallback failed: " + (err instanceof Error ? err.message : String(err))
723
+ )
724
+ );
725
+ }
638
726
  }
639
727
  let resolved = false;
640
728
  const onMessage = (e) => {
641
729
  if (e.origin !== apiOrigin) return;
642
730
  const data = e.data;
643
- if (!data || data.type !== "vizu:auth") return;
731
+ if (!data) return;
732
+ if (data.type === "vizu:auth-denied" && data.workspace === this.workspace) {
733
+ resolved = true;
734
+ cleanup();
735
+ this.accessDenied = true;
736
+ this.accessDeniedReason = typeof data.reason === "string" ? data.reason : "no_access";
737
+ reject(
738
+ makeAuthError(
739
+ "access_denied",
740
+ `No access to workspace "${this.workspace}" (${this.accessDeniedReason}).`
741
+ )
742
+ );
743
+ return;
744
+ }
745
+ if (data.type !== "vizu:auth") return;
644
746
  if (data.workspace !== this.workspace) return;
645
747
  if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
646
748
  resolved = true;
@@ -3192,6 +3294,7 @@ var Vizu = class {
3192
3294
  requireUserOrAuth() {
3193
3295
  if (!(this.storage instanceof CloudStorageAdapter)) return true;
3194
3296
  if (this.user) return true;
3297
+ if (this.storage.isAccessDenied()) return false;
3195
3298
  void this.storage.preflightAuth().catch(() => {
3196
3299
  });
3197
3300
  return false;