@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.d.cts CHANGED
@@ -458,7 +458,29 @@ declare class CloudStorageAdapter implements StorageAdapter {
458
458
  private pendingAuth;
459
459
  /** Whether we've already fired onAuthChanged for the current token. */
460
460
  private lastNotifiedTokenId;
461
+ /**
462
+ * Sticky flag set when /connect tells us this workspace is inaccessible
463
+ * to the signed-in user (no role, origin not allowed, workspace gone).
464
+ * Future resolveToken calls reject immediately instead of reopening the
465
+ * popup — the answer won't change without a Clerk account switch, which
466
+ * a page reload covers. Cleared only by reload (or future explicit API).
467
+ */
468
+ private accessDenied;
469
+ /**
470
+ * Last denial reason from /connect's postMessage — useful for hosts that
471
+ * want to render a workspace-level "no access" banner. Null until denied.
472
+ */
473
+ private accessDeniedReason;
461
474
  constructor(opts: CloudStorageOptions);
475
+ /**
476
+ * Pull a token out of the URL fragment if /connect just redirected us
477
+ * back here. Strips the fragment on success so the token doesn't sit
478
+ * in browser history or get shared if the user copies the URL.
479
+ *
480
+ * The fragment is base64-encoded JSON with compact keys; see
481
+ * apps/landing/app/connect/RedirectComplete.tsx for the producer side.
482
+ */
483
+ private readHashToken;
462
484
  /**
463
485
  * Open the sign-in popup right now (or no-op if a valid token is
464
486
  * cached). Vizu calls this on enable() so the user signs in BEFORE
@@ -469,6 +491,16 @@ declare class CloudStorageAdapter implements StorageAdapter {
469
491
  * state.
470
492
  */
471
493
  preflightAuth(): Promise<void>;
494
+ /**
495
+ * Whether /connect has already told us the user can't access this
496
+ * workspace. Vizu's write gate checks this before reopening the popup —
497
+ * a denied state means clicking again will yield the same "No access"
498
+ * page, so we suppress the retry until a page reload (or explicit API
499
+ * call in the future).
500
+ */
501
+ isAccessDenied(): boolean;
502
+ /** Last denial reason; null when not denied. */
503
+ accessDenialReason(): string | null;
472
504
  load(_namespace: string): Promise<VizuComment[]>;
473
505
  addComment(_namespace: string, comment: VizuComment): Promise<void>;
474
506
  updateComment(_namespace: string, id: string, patch: Partial<VizuComment>): Promise<VizuComment | null>;
@@ -521,7 +553,9 @@ declare class CloudStorageAdapter implements StorageAdapter {
521
553
  private fetchAuthed;
522
554
  private parseJson;
523
555
  }
524
- type CloudErrorCode = 'auth_required' | 'auth_canceled' | 'auth_unavailable' | 'popup_blocked' | 'origin_not_allowed' | 'workspace_not_found' | 'http_error';
556
+ type CloudErrorCode = 'auth_required' | 'auth_canceled' | 'auth_unavailable' | 'popup_blocked' | 'origin_not_allowed' | 'workspace_not_found'
557
+ /** /connect resolved access for the signed-in user → no role on the workspace. Sticky for the session. */
558
+ | 'access_denied' | 'http_error';
525
559
  interface CloudError extends Error {
526
560
  code: CloudErrorCode;
527
561
  status?: number;
package/dist/index.d.ts CHANGED
@@ -458,7 +458,29 @@ declare class CloudStorageAdapter implements StorageAdapter {
458
458
  private pendingAuth;
459
459
  /** Whether we've already fired onAuthChanged for the current token. */
460
460
  private lastNotifiedTokenId;
461
+ /**
462
+ * Sticky flag set when /connect tells us this workspace is inaccessible
463
+ * to the signed-in user (no role, origin not allowed, workspace gone).
464
+ * Future resolveToken calls reject immediately instead of reopening the
465
+ * popup — the answer won't change without a Clerk account switch, which
466
+ * a page reload covers. Cleared only by reload (or future explicit API).
467
+ */
468
+ private accessDenied;
469
+ /**
470
+ * Last denial reason from /connect's postMessage — useful for hosts that
471
+ * want to render a workspace-level "no access" banner. Null until denied.
472
+ */
473
+ private accessDeniedReason;
461
474
  constructor(opts: CloudStorageOptions);
475
+ /**
476
+ * Pull a token out of the URL fragment if /connect just redirected us
477
+ * back here. Strips the fragment on success so the token doesn't sit
478
+ * in browser history or get shared if the user copies the URL.
479
+ *
480
+ * The fragment is base64-encoded JSON with compact keys; see
481
+ * apps/landing/app/connect/RedirectComplete.tsx for the producer side.
482
+ */
483
+ private readHashToken;
462
484
  /**
463
485
  * Open the sign-in popup right now (or no-op if a valid token is
464
486
  * cached). Vizu calls this on enable() so the user signs in BEFORE
@@ -469,6 +491,16 @@ declare class CloudStorageAdapter implements StorageAdapter {
469
491
  * state.
470
492
  */
471
493
  preflightAuth(): Promise<void>;
494
+ /**
495
+ * Whether /connect has already told us the user can't access this
496
+ * workspace. Vizu's write gate checks this before reopening the popup —
497
+ * a denied state means clicking again will yield the same "No access"
498
+ * page, so we suppress the retry until a page reload (or explicit API
499
+ * call in the future).
500
+ */
501
+ isAccessDenied(): boolean;
502
+ /** Last denial reason; null when not denied. */
503
+ accessDenialReason(): string | null;
472
504
  load(_namespace: string): Promise<VizuComment[]>;
473
505
  addComment(_namespace: string, comment: VizuComment): Promise<void>;
474
506
  updateComment(_namespace: string, id: string, patch: Partial<VizuComment>): Promise<VizuComment | null>;
@@ -521,7 +553,9 @@ declare class CloudStorageAdapter implements StorageAdapter {
521
553
  private fetchAuthed;
522
554
  private parseJson;
523
555
  }
524
- type CloudErrorCode = 'auth_required' | 'auth_canceled' | 'auth_unavailable' | 'popup_blocked' | 'origin_not_allowed' | 'workspace_not_found' | 'http_error';
556
+ type CloudErrorCode = 'auth_required' | 'auth_canceled' | 'auth_unavailable' | 'popup_blocked' | 'origin_not_allowed' | 'workspace_not_found'
557
+ /** /connect resolved access for the signed-in user → no role on the workspace. Sticky for the session. */
558
+ | 'access_denied' | 'http_error';
525
559
  interface CloudError extends Error {
526
560
  code: CloudErrorCode;
527
561
  status?: number;
package/dist/index.js CHANGED
@@ -336,15 +336,76 @@ var CloudStorageAdapter = class {
336
336
  this.pendingAuth = null;
337
337
  /** Whether we've already fired onAuthChanged for the current token. */
338
338
  this.lastNotifiedTokenId = null;
339
+ /**
340
+ * Sticky flag set when /connect tells us this workspace is inaccessible
341
+ * to the signed-in user (no role, origin not allowed, workspace gone).
342
+ * Future resolveToken calls reject immediately instead of reopening the
343
+ * popup — the answer won't change without a Clerk account switch, which
344
+ * a page reload covers. Cleared only by reload (or future explicit API).
345
+ */
346
+ this.accessDenied = false;
347
+ /**
348
+ * Last denial reason from /connect's postMessage — useful for hosts that
349
+ * want to render a workspace-level "no access" banner. Null until denied.
350
+ */
351
+ this.accessDeniedReason = null;
339
352
  this.workspace = opts.workspace;
340
353
  this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
341
354
  this.autoSignIn = opts.autoSignIn !== false;
342
355
  this.onAuthChanged = opts.onAuthChanged;
343
- this.cachedToken = this.readStoredToken();
356
+ const fromHash = this.readHashToken();
357
+ if (fromHash) {
358
+ this.cachedToken = fromHash;
359
+ this.writeStoredToken(fromHash);
360
+ } else {
361
+ this.cachedToken = this.readStoredToken();
362
+ }
344
363
  if (this.cachedToken && !this.isExpired(this.cachedToken)) {
345
364
  this.fireAuthChanged(this.cachedToken);
346
365
  }
347
366
  }
367
+ /**
368
+ * Pull a token out of the URL fragment if /connect just redirected us
369
+ * back here. Strips the fragment on success so the token doesn't sit
370
+ * in browser history or get shared if the user copies the URL.
371
+ *
372
+ * The fragment is base64-encoded JSON with compact keys; see
373
+ * apps/landing/app/connect/RedirectComplete.tsx for the producer side.
374
+ */
375
+ readHashToken() {
376
+ if (typeof window === "undefined") return null;
377
+ const hash = window.location.hash;
378
+ if (!hash || hash.length < 2) return null;
379
+ const params = new URLSearchParams(hash.slice(1));
380
+ const payload = params.get("vizu_auth");
381
+ if (!payload) return null;
382
+ try {
383
+ const json = JSON.parse(atob(payload));
384
+ if (!json.t || !json.e || !json.u || !json.w) return null;
385
+ if (json.w !== this.workspace) return null;
386
+ const stored = {
387
+ token: json.t,
388
+ expiresAt: json.e,
389
+ userId: json.u,
390
+ user: typeof json.n === "string" ? {
391
+ id: json.u,
392
+ name: json.n,
393
+ avatarUrl: typeof json.a === "string" ? json.a : void 0
394
+ } : null
395
+ };
396
+ params.delete("vizu_auth");
397
+ const remaining = params.toString();
398
+ const newHash = remaining ? `#${remaining}` : "";
399
+ try {
400
+ const newUrl = window.location.pathname + window.location.search + newHash;
401
+ window.history.replaceState(null, "", newUrl);
402
+ } catch {
403
+ }
404
+ return stored;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
348
409
  /**
349
410
  * Open the sign-in popup right now (or no-op if a valid token is
350
411
  * cached). Vizu calls this on enable() so the user signs in BEFORE
@@ -357,6 +418,20 @@ var CloudStorageAdapter = class {
357
418
  async preflightAuth() {
358
419
  await this.resolveToken();
359
420
  }
421
+ /**
422
+ * Whether /connect has already told us the user can't access this
423
+ * workspace. Vizu's write gate checks this before reopening the popup —
424
+ * a denied state means clicking again will yield the same "No access"
425
+ * page, so we suppress the retry until a page reload (or explicit API
426
+ * call in the future).
427
+ */
428
+ isAccessDenied() {
429
+ return this.accessDenied;
430
+ }
431
+ /** Last denial reason; null when not denied. */
432
+ accessDenialReason() {
433
+ return this.accessDeniedReason;
434
+ }
360
435
  /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
361
436
  async load(_namespace) {
362
437
  const out = [];
@@ -547,6 +622,12 @@ var CloudStorageAdapter = class {
547
622
  this.fireAuthChanged(fresh);
548
623
  return fresh;
549
624
  }
625
+ if (this.accessDenied) {
626
+ throw makeAuthError(
627
+ "access_denied",
628
+ `No access to workspace "${this.workspace}" (${this.accessDeniedReason ?? "no_access"}).`
629
+ );
630
+ }
550
631
  if (!this.autoSignIn) {
551
632
  throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
552
633
  }
@@ -593,18 +674,39 @@ var CloudStorageAdapter = class {
593
674
  `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
594
675
  );
595
676
  if (!popup) {
596
- return reject(
597
- makeAuthError(
598
- "popup_blocked",
599
- "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
600
- )
601
- );
677
+ try {
678
+ const returnTo = window.location.href;
679
+ const redirectUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin) + "&return_to=" + encodeURIComponent(returnTo);
680
+ window.location.assign(redirectUrl);
681
+ return;
682
+ } catch (err) {
683
+ return reject(
684
+ makeAuthError(
685
+ "popup_blocked",
686
+ "Sign-in popup was blocked and redirect fallback failed: " + (err instanceof Error ? err.message : String(err))
687
+ )
688
+ );
689
+ }
602
690
  }
603
691
  let resolved = false;
604
692
  const onMessage = (e) => {
605
693
  if (e.origin !== apiOrigin) return;
606
694
  const data = e.data;
607
- if (!data || data.type !== "vizu:auth") return;
695
+ if (!data) return;
696
+ if (data.type === "vizu:auth-denied" && data.workspace === this.workspace) {
697
+ resolved = true;
698
+ cleanup();
699
+ this.accessDenied = true;
700
+ this.accessDeniedReason = typeof data.reason === "string" ? data.reason : "no_access";
701
+ reject(
702
+ makeAuthError(
703
+ "access_denied",
704
+ `No access to workspace "${this.workspace}" (${this.accessDeniedReason}).`
705
+ )
706
+ );
707
+ return;
708
+ }
709
+ if (data.type !== "vizu:auth") return;
608
710
  if (data.workspace !== this.workspace) return;
609
711
  if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
610
712
  resolved = true;
@@ -3156,6 +3258,7 @@ var Vizu = class {
3156
3258
  requireUserOrAuth() {
3157
3259
  if (!(this.storage instanceof CloudStorageAdapter)) return true;
3158
3260
  if (this.user) return true;
3261
+ if (this.storage.isAccessDenied()) return false;
3159
3262
  void this.storage.preflightAuth().catch(() => {
3160
3263
  });
3161
3264
  return false;