@viibestack/ui 0.6.3 → 0.6.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@viibestack/ui",
3
- "version": "0.6.3",
4
- "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), a real per-app data client (listRows/upsertRow/deleteRow/captureClientError), and a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError) backed by ViibeStack's own platform-managed data store.",
3
+ "version": "0.6.5",
4
+ "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), a real per-app data client (listRows/upsertRow/deleteRow/captureClientError), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
package/src/auth.ts CHANGED
@@ -171,6 +171,39 @@ export async function logOut(): Promise<void> {
171
171
  await callAppAuth("/logout", { method: "POST", body: JSON.stringify({ session_id: stored.sessionId }) });
172
172
  }
173
173
 
174
+ // Single-flight guard for the refresh call below -- without this, calling
175
+ // getCurrentUser() from more than one place on the same page load (a nav
176
+ // component AND a page component each checking auth in their own
177
+ // useEffect, a completely normal shape for a generated app) fires two
178
+ // concurrent /session/refresh calls with the SAME still-stored session_id.
179
+ // The auth service rotates that id on every refresh (see services/auth's
180
+ // own comment on that route), so only the first of the two UPDATEs
181
+ // actually matches a row -- the second gets back "session expired" even
182
+ // though the first one just succeeded, and its `saveStoredSession(null)`
183
+ // then wipes out the perfectly valid session the first call just saved,
184
+ // logging a real signed-in user out. Confirmed as the root cause of a
185
+ // real_accounts logout report (2026-07-19) -- funneling every concurrent
186
+ // caller through the SAME in-flight promise means only one refresh request
187
+ // is ever sent for a given expiring token, regardless of how many
188
+ // components independently call getCurrentUser() at once. Does not cover
189
+ // two separate browser TABS refreshing at once (each has its own module
190
+ // state) -- that's a rarer trigger and would need cross-tab coordination
191
+ // this fix doesn't attempt.
192
+ let refreshInFlight: Promise<AppUser | null> | null = null;
193
+
194
+ async function refreshCurrentSession(sessionId: string): Promise<AppUser | null> {
195
+ const result = await callAppAuth<SessionResponse>("/session/refresh", {
196
+ method: "POST",
197
+ body: JSON.stringify({ session_id: sessionId }),
198
+ });
199
+ if (!result.ok) {
200
+ saveStoredSession(null);
201
+ return null;
202
+ }
203
+ saveStoredSession(result.body);
204
+ return result.body.user;
205
+ }
206
+
174
207
  // The one function generated app code is expected to call on every page
175
208
  // load to find out "is anyone logged in" -- returns null if there's no
176
209
  // session, or if the session has expired/been revoked server-side (in
@@ -183,16 +216,12 @@ export async function getCurrentUser(): Promise<AppUser | null> {
183
216
  const needsRefresh = exp == null || exp - Math.floor(Date.now() / 1000) < 120;
184
217
  if (!needsRefresh) return stored.user;
185
218
 
186
- const result = await callAppAuth<SessionResponse>("/session/refresh", {
187
- method: "POST",
188
- body: JSON.stringify({ session_id: stored.sessionId }),
189
- });
190
- if (!result.ok) {
191
- saveStoredSession(null);
192
- return null;
219
+ if (!refreshInFlight) {
220
+ refreshInFlight = refreshCurrentSession(stored.sessionId).finally(() => {
221
+ refreshInFlight = null;
222
+ });
193
223
  }
194
- saveStoredSession(result.body);
195
- return result.body.user;
224
+ return refreshInFlight;
196
225
  }
197
226
 
198
227
  export async function requestPasswordReset(email: string): Promise<ActionResult> {
package/src/badge.tsx ADDED
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ // Small fixed "Powered by ViibeStack" badge every generated app is required
4
+ // to render once near its root layout (see integrationRequirements() item
5
+ // 11 in services/deploy/src/mcp-agent.ts, and scratch-scaffold.ts's
6
+ // layout.tsx template, which injects this automatically for from-scratch
7
+ // builds). Self-gating: resolves this app's own tenant plan at runtime via
8
+ // GET /api/apps/:id/badge-status and renders nothing once that tenant is
9
+ // actively paying for a plan that waives the badge (Growing Business and
10
+ // above) -- no build-time branching needed, so the same shipped code keeps
11
+ // working correctly if a tenant upgrades/downgrades after the app was
12
+ // already deployed.
13
+ import { useEffect, useState } from "react";
14
+
15
+ let showBadgePromise: Promise<boolean> | null = null;
16
+
17
+ function resolveShowBadge(): Promise<boolean> {
18
+ if (!showBadgePromise) {
19
+ showBadgePromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
20
+ .then((res) => (res.ok ? res.json() : null))
21
+ .then((data: { app_id?: string } | null) => {
22
+ if (!data?.app_id) return true;
23
+ return fetch(`${window.location.origin}/api/apps/${data.app_id}/badge-status`)
24
+ .then((res) => (res.ok ? res.json() : null))
25
+ .then((status: { show_badge?: boolean } | null) => status?.show_badge ?? true);
26
+ })
27
+ .catch(() => true);
28
+ }
29
+ return showBadgePromise;
30
+ }
31
+
32
+ export function PoweredByBadge() {
33
+ const [show, setShow] = useState(false);
34
+
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ resolveShowBadge().then((result) => {
38
+ if (!cancelled) setShow(result);
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, []);
44
+
45
+ if (!show) return null;
46
+
47
+ return (
48
+ <a
49
+ href="https://viibestack.ai?utm_source=powered_by_badge"
50
+ target="_blank"
51
+ rel="noopener noreferrer"
52
+ style={{
53
+ position: "fixed",
54
+ bottom: "1rem",
55
+ right: "1rem",
56
+ zIndex: 9999,
57
+ display: "flex",
58
+ alignItems: "center",
59
+ gap: "0.375rem",
60
+ padding: "0.375rem 0.75rem",
61
+ borderRadius: "9999px",
62
+ backgroundColor: "rgba(17, 17, 17, 0.85)",
63
+ color: "#fff",
64
+ fontSize: "0.75rem",
65
+ fontFamily: "system-ui, -apple-system, sans-serif",
66
+ fontWeight: 500,
67
+ textDecoration: "none",
68
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.2)",
69
+ }}
70
+ >
71
+ Powered by ViibeStack
72
+ </a>
73
+ );
74
+ }
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./components";
3
3
  export * from "./nav";
4
4
  export * from "./data";
5
5
  export * from "./auth";
6
+ export * from "./badge";