@withpotter/lib 0.13.0-next.124 → 0.13.0-next.59
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/admin-account-types.d.ts +0 -21
- package/dist/admin-api.d.ts +2 -224
- package/dist/admin-api.js +0 -41
- package/dist/admin-api.js.map +1 -1
- package/dist/admin-case-api.js +4 -1
- package/dist/admin-case-api.js.map +1 -1
- package/dist/admin-pos-api.d.ts +1 -25
- package/dist/admin-pos-api.js +0 -24
- package/dist/admin-pos-api.js.map +1 -1
- package/dist/admin-pos-types.d.ts +0 -43
- package/dist/api/accounts.d.ts +3 -5
- package/dist/api/accounts.js +1 -1
- package/dist/api/accounts.js.map +1 -1
- package/dist/api/analytics.js +3 -1
- package/dist/api/analytics.js.map +1 -1
- package/dist/api/core.d.ts +1 -0
- package/dist/api/core.js +7 -19
- package/dist/api/core.js.map +1 -1
- package/dist/api/potter.js +3 -2
- package/dist/api/potter.js.map +1 -1
- package/dist/api/session-scope.d.ts +2 -2
- package/dist/api/session-scope.js.map +1 -1
- package/dist/api/types/auth.d.ts +1 -7
- package/dist/api/types/auth.js.map +1 -1
- package/dist/api/types/crm.d.ts +0 -2
- package/dist/api/types/templates.d.ts +0 -6
- package/dist/auth-context.d.ts +0 -2
- package/dist/auth-context.js +37 -12
- package/dist/auth-context.js.map +1 -1
- package/dist/mobile-routes.js +2 -4
- package/dist/mobile-routes.js.map +1 -1
- package/dist/plan-price.d.ts +2 -16
- package/dist/plan-price.js +0 -9
- package/dist/plan-price.js.map +1 -1
- package/dist/resolve-enabled-behaviors.d.ts +0 -2
- package/dist/resolve-enabled-behaviors.js +1 -2
- package/dist/resolve-enabled-behaviors.js.map +1 -1
- package/dist/template-types.d.ts +0 -8
- package/dist/timeline-copy.d.ts +1 -1
- package/dist/timeline-copy.js +2 -6
- package/dist/timeline-copy.js.map +1 -1
- package/dist/use-nav-groups.js +5 -5
- package/dist/use-nav-groups.js.map +1 -1
- package/dist/utils.js +4 -4
- package/dist/utils.js.map +1 -1
- package/dist/wallet-format.js +0 -1
- package/dist/wallet-format.js.map +1 -1
- package/package.json +2 -2
- package/dist/__fixtures__/demo-content-server.json +0 -484
- package/dist/admin-earnings-api.d.ts +0 -90
- package/dist/admin-earnings-api.js +0 -15
- package/dist/admin-earnings-api.js.map +0 -1
- package/dist/admin-earnings-model.d.ts +0 -30
- package/dist/admin-earnings-model.js +0 -47
- package/dist/admin-earnings-model.js.map +0 -1
- package/dist/admin-pos-olive.d.ts +0 -44
- package/dist/admin-pos-olive.js +0 -93
- package/dist/admin-pos-olive.js.map +0 -1
- package/dist/api/demo-content.d.ts +0 -104
- package/dist/api/demo-content.js +0 -36
- package/dist/api/demo-content.js.map +0 -1
- package/dist/api/plan-refusal.d.ts +0 -21
- package/dist/api/plan-refusal.js +0 -28
- package/dist/api/plan-refusal.js.map +0 -1
- package/dist/api/session-refresh.d.ts +0 -18
- package/dist/api/session-refresh.js +0 -37
- package/dist/api/session-refresh.js.map +0 -1
- package/dist/demo-content-mock.d.ts +0 -22
- package/dist/demo-content-mock.js +0 -70
- package/dist/demo-content-mock.js.map +0 -1
- package/dist/demo-content-poller.d.ts +0 -33
- package/dist/demo-content-poller.js +0 -80
- package/dist/demo-content-poller.js.map +0 -1
- package/dist/demo-content.d.ts +0 -43
- package/dist/demo-content.js +0 -78
- package/dist/demo-content.js.map +0 -1
- package/dist/stored-token.d.ts +0 -1
- package/dist/stored-token.js +0 -11
- package/dist/stored-token.js.map +0 -1
package/dist/api/core.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/api/core.ts"],"sourcesContent":["/**\n * The shared plumbing every API module needs: auth token, CSRF, the bounce\n * guard, and apiFetch itself.\n *\n * Split out of the old 7,657-line src/lib/api.ts. That file was imported by\n * 405 files, so touching any endpoint invalidated all of them, and a route\n * that wanted one call pulled the whole surface.\n */\n// Always use proxy for cookie-based auth to work (same-origin required for cookies)\nexport const IS_BROWSER = typeof window !== 'undefined';\n\nimport posthog from 'posthog-js';\nimport type { FetchOptions } from './types';\nimport { notifyWrite } from './cache-bus';\nimport { announceFeatureLocked, featureNotInPlan } from './feature-locked';\nimport { announcePlanRefusal, planRefusal } from './plan-refusal';\nimport { isUnservedRoute, unservedMessage } from './unserved-routes';\nimport { refreshSession, wantsRefresh } from './session-refresh';\nimport {\n isSessionExpiryResponse,\n merchantIdentity,\n sessionExpiryTarget,\n} from './session-scope';\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\nexport const getApiUrl = () => {\n if (IS_BROWSER) {\n // In browser, route through our proxy for same-origin cookie handling\n return '/api/proxy';\n }\n // Server-side, use direct backend URL\n return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';\n};\n\n// Helper to get account ID from localStorage\n\n// Helper to get account ID from localStorage\nexport function getAccountId(): string | null {\n if (typeof window === 'undefined') return null;\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) {\n try {\n const user = JSON.parse(pbUser);\n return user.id || null;\n } catch {\n return null;\n }\n }\n return null;\n}\n\n// Helper to get CSRF token from cookie\n\n// Helper to get CSRF token from cookie\nexport function getCsrfToken(): string | null {\n if (typeof window === 'undefined' || typeof document === 'undefined')\n return null;\n const match = document.cookie.match(/pb_csrf_token=([^;]+)/);\n return match ? match[1] : null;\n}\n\n/**\n * Thrown when the API rejects the session (401). Typed so a caller can tell an\n * expired session apart from a genuine empty result — the distinction the whole\n * dashboard was previously unable to make.\n */\nexport class SessionExpiredError extends Error {\n constructor() {\n super('Your session has expired');\n this.name = 'SessionExpiredError';\n }\n}\n\nexport class ApiRequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly details?: unknown,\n ) {\n super(message);\n this.name = 'ApiRequestError';\n }\n}\n\nexport { AUTH_ROUTES, isSessionExpiryResponse } from './session-scope';\n\n/** One redirect per page load, however many requests fail together. */\nlet sessionExpiryHandled = false;\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\nexport const BOUNCE_KEY = 'pb_session_bounce';\n/** Two bounces off the same path inside this window is a loop, not a login. */\n\n/** Two bounces off the same path inside this window is a loop, not a login. */\nexport const BOUNCE_WINDOW_MS = 15_000;\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\nexport function bouncedRecently(path: string): boolean {\n try {\n const raw = sessionStorage.getItem(BOUNCE_KEY);\n if (!raw) return false;\n const { path: last, at } = JSON.parse(raw) as { path: string; at: number };\n return last === path && Date.now() - at < BOUNCE_WINDOW_MS;\n } catch {\n return false;\n }\n}\n\nexport function recordBounce(path: string): void {\n try {\n sessionStorage.setItem(\n BOUNCE_KEY,\n JSON.stringify({ path, at: Date.now() }),\n );\n } catch {\n // Private mode / storage full. The in-memory guard still holds for this\n // page load; we just lose the cross-reload half of the protection.\n }\n}\n\n/**\n * GETS THAT OVERLAP IN TIME ARE ONE REQUEST.\n *\n * Not a cache — an entry lives only while its request is in the air, so this\n * can never hand back a stale answer. It collapses the case the dashboard\n * actually has: several components asking the same question in the same tick,\n * each with its own `useEffect` and nothing to dedupe against.\n *\n * Measured per page load before this, on a real workspace: `/pages` six times\n * on the editor, `products` five on the catalogue, `analytics/dashboard` four,\n * `orders` three. React Query dedupes what goes through it; roughly forty files\n * still fetch in a raw effect and those are all of the above.\n *\n * GET only, and keyed by the things that change the ANSWER — the URL, the\n * workspace and the account. A write is never collapsed: two POSTs that look\n * identical are two things happening.\n */\nconst inFlightGets = new Map<string, Promise<unknown>>();\n\n/**\n * POSTs that ask a question and change nothing: the filters are too large for a\n * query string. They must not announce a write. A query built on one would\n * invalidate every query on success, itself included, and so refetch for ever\n * (the New campaign screen counted its audience a few hundred times a minute).\n */\nconst READ_ONLY_POSTS = [/^\\/crm\\/segments\\/preview(\\?|$)/];\nconst onlyReads = (endpoint: string) =>\n READ_ONLY_POSTS.some((pattern) => pattern.test(endpoint));\n\nexport async function apiFetch<T = unknown>(\n endpoint: string,\n options: FetchOptions = {},\n): Promise<T> {\n const { tenantSlug, token, accountId, ...fetchOptions } = options;\n\n const headers: Record<string, string> = {\n ...(fetchOptions.headers as Record<string, string>),\n };\n\n // Let the runtime add the multipart boundary for FormData uploads.\n if (fetchOptions.body && !(fetchOptions.body instanceof FormData)) {\n headers['Content-Type'] = 'application/json';\n }\n\n if (tenantSlug) {\n headers['X-Tenant-Slug'] = tenantSlug;\n }\n\n // The merchant's account goes on merchant calls only. The session itself is\n // the HttpOnly pb_auth_token cookie, which the browser sends; only a caller\n // holding a fresh one-time token (an invite link) passes `token` here.\n const merchant = merchantIdentity(endpoint, getAccountId(), null);\n const effectiveAccountId = accountId || merchant.accountId;\n if (effectiveAccountId) {\n headers['x-account-id'] = effectiveAccountId;\n }\n\n const effectiveToken = token || merchant.token;\n if (effectiveToken) {\n headers.Authorization = `Bearer ${effectiveToken}`;\n }\n\n // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)\n const method = (fetchOptions.method || 'GET').toUpperCase();\n // Reused below to announce the write. The list is the same one CSRF cares\n // about for the same reason: these are the methods that change something.\n const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);\n if (isWrite) {\n const csrfToken = getCsrfToken();\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n }\n\n // Request tracing: unique ID per API call + PostHog session correlation\n if (IS_BROWSER) {\n headers['x-request-id'] =\n crypto.randomUUID?.() ??\n `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n const sessionId = posthog.get_session_id?.();\n if (sessionId) {\n headers['x-posthog-session-id'] = sessionId;\n }\n }\n\n // A route this box's engine does not serve fails here, as the engine would.\n if (isUnservedRoute(endpoint)) {\n throw new ApiRequestError(unservedMessage(method, endpoint), 404);\n }\n\n const url = `${getApiUrl()}${endpoint}`;\n\n if (method === 'GET') {\n const key = `${url}|${tenantSlug ?? ''}|${effectiveAccountId ?? ''}`;\n const inFlight = inFlightGets.get(key);\n if (inFlight) return inFlight as Promise<T>;\n const started = send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint)).finally(() => {\n inFlightGets.delete(key);\n });\n inFlightGets.set(key, started);\n return started;\n }\n\n return send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));\n}\n\n/** The request itself, once `apiFetch` has decided who is making it. */\nasync function send<T>(\n url: string,\n fetchOptions: Omit<FetchOptions, 'tenantSlug' | 'token' | 'accountId'>,\n headers: Record<string, string>,\n isWrite: boolean,\n retried = false,\n): Promise<T> {\n const res = await fetch(url, {\n ...fetchOptions,\n headers,\n credentials: 'include', // Include cookies for CSRF\n });\n\n if (!res.ok) {\n // The 15-minute access token ran out but the refresh cookie may still be\n // good: renew once, shared by every request that failed together, and\n // send this one again. Anything else falls through to the old handling.\n if (res.status === 401 && !retried && wantsRefresh(url)) {\n const body = await res.clone().json().catch(() => null);\n if (wantsRefresh(url, body) && (await refreshSession(getApiUrl()))) {\n return send<T>(url, fetchOptions, headers, isWrite, true);\n }\n }\n\n // 402 Payment Required — subscription inactive, redirect to billing\n // ...unless the plan guard sent it: a page the plan leaves out shows the\n // locked-feature card, and the merchant stays where they are.\n const lockedBody =\n res.status === 402\n ? await res.clone().json().catch(() => null)\n : null;\n const locked = featureNotInPlan(lockedBody);\n if (locked) {\n announceFeatureLocked(locked);\n throw new ApiRequestError(lockedBody.message ?? 'Not in your plan', 402, lockedBody);\n }\n const refusal = planRefusal(lockedBody);\n if (refusal) {\n announcePlanRefusal(refusal);\n throw new ApiRequestError(\n refusal.code === 'LIMIT_REACHED'\n ? 'You have reached the limit on your plan'\n : 'This is not available on your plan',\n 402,\n lockedBody,\n );\n }\n if (res.status === 402 && IS_BROWSER) {\n window.location.href = '/dashboard/settings/billing';\n throw new Error('Payment required');\n }\n\n /**\n * 401 — the session is gone, not the data.\n *\n * Handled here because it cannot be handled well anywhere else: every panel\n * catches its own failure and renders an empty state, so an expired session\n * was being drawn as £0 revenue, 0 bookings and \"nothing yet\" across the\n * whole dashboard. That tells a merchant their business made nothing, when\n * in fact the app cannot talk to the server. A 401 is never an empty state.\n *\n * 403 is deliberately NOT caught: that is a real permission answer for a\n * valid session, and panels are right to hide themselves for it.\n *\n * The guard matters — a dashboard fires a dozen requests at once, and all\n * of them 401 together. Without it they'd each kick off a navigation.\n */\n if (isSessionExpiryResponse(res.status, url) && IS_BROWSER) {\n // An admin 401 goes to the admin sign-in, never the merchant one: that\n // forwards a signed-in merchant to /dashboard.\n const path = window.location.pathname;\n const target = sessionExpiryTarget(url, path, window.location.search);\n if (target && !sessionExpiryHandled && !bouncedRecently(path)) {\n sessionExpiryHandled = true;\n recordBounce(path);\n window.location.href = target;\n }\n throw new SessionExpiredError();\n }\n\n const error = await res.json().catch(() => ({ message: 'Request failed' }));\n // NestJS validation pipes return message as an array of strings\n let msg: string;\n if (Array.isArray(error.message)) {\n const items = error.message\n .map((m: string) => {\n // Strip field prefix like \"maxDiscountAmount: maxDiscountAmount must...\"\n const colonIdx = m.indexOf(': ');\n return colonIdx > -1 ? m.slice(colonIdx + 2) : m;\n })\n .filter((m: string, i: number, arr: string[]) => arr.indexOf(m) === i);\n // When NestJS returns an empty message array, fall back to error.error\n msg =\n items.length > 0\n ? '• ' + items.join('\\n• ')\n : error.error || `HTTP ${res.status}`;\n } else {\n msg = error.message || error.error || `HTTP ${res.status}`;\n }\n // Carry the status. Callers were reduced to matching on message text to\n // tell \"the server refused this\" from \"the server could not be reached\",\n // and those need opposite handling: one means the session is dead, the\n // other means fall back to cache and carry on.\n //\n // `details` carries the parsed body for the same reason, one level in. An\n // API that answers a refusal with a machine-readable code and the id of the\n // thing that caused it was having all of that thrown away here, leaving the\n // caller to substring-match the human sentence — which is how a recoverable\n // refusal (\"you already have one of these, connect it\") becomes a dead end.\n throw new ApiRequestError(msg, res.status, error);\n }\n\n /**\n * THE WRITE LANDED, SO WHAT IS ON SCREEN IS NOW OUT OF DATE.\n *\n * Announced here rather than at a hundred call sites, because this is the\n * only place that knows a write happened at all — see `cache-bus.ts` for why\n * that matters and what it fixes. Only after `res.ok`: a refused write has\n * changed nothing and refetching over it would be noise.\n */\n if (isWrite) {\n notifyWrite();\n }\n\n // 204 No Content has no body — return early to avoid JSON parse error\n if (res.status === 204) {\n return undefined as unknown as T;\n }\n\n // Handle empty response body (e.g., 200 OK with no content)\n const contentType = res.headers.get('content-type');\n if (!contentType || !contentType.includes('application/json')) {\n // Check if response has content\n const text = await res.text();\n if (!text) {\n return undefined as unknown as T;\n }\n return text as T;\n // Try to parse as JSON in case content-type header is missing\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined as unknown as T;\n }\n }\n\n return res.json();\n}\n\n// ============================================\n// AUTH / ACCOUNTS API\n// ============================================\n"],"mappings":"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","names":["isSessionExpiryResponse"]}
|
|
1
|
+
{"version":3,"sources":["../../src/api/core.ts"],"sourcesContent":["/**\n * The shared plumbing every API module needs: auth token, CSRF, the bounce\n * guard, and apiFetch itself.\n *\n * Split out of the old 7,657-line src/lib/api.ts. That file was imported by\n * 405 files, so touching any endpoint invalidated all of them, and a route\n * that wanted one call pulled the whole surface.\n */\n// Always use proxy for cookie-based auth to work (same-origin required for cookies)\nexport const IS_BROWSER = typeof window !== 'undefined';\n\nimport posthog from 'posthog-js';\nimport type { FetchOptions } from './types';\nimport { notifyWrite } from './cache-bus';\nimport { announceFeatureLocked, featureNotInPlan } from './feature-locked';\nimport { isUnservedRoute, unservedMessage } from './unserved-routes';\nimport {\n isSessionExpiryResponse,\n merchantIdentity,\n sessionExpiryTarget,\n} from './session-scope';\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\n\n// Use proxy for all browser requests to ensure cookies are sent correctly\nexport const getApiUrl = () => {\n if (IS_BROWSER) {\n // In browser, route through our proxy for same-origin cookie handling\n return '/api/proxy';\n }\n // Server-side, use direct backend URL\n return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';\n};\n\n// Helper to get account ID from localStorage\n\n// Helper to get account ID from localStorage\nexport function getAccountId(): string | null {\n if (typeof window === 'undefined') return null;\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) {\n try {\n const user = JSON.parse(pbUser);\n return user.id || null;\n } catch {\n return null;\n }\n }\n return null;\n}\n\n// Helper to get auth token from localStorage\n\n// Helper to get auth token from localStorage\nexport function getAuthToken(): string | null {\n if (typeof window === 'undefined') return null;\n return localStorage.getItem('pb_token');\n}\n\n// Helper to get CSRF token from cookie\n\n// Helper to get CSRF token from cookie\nexport function getCsrfToken(): string | null {\n if (typeof window === 'undefined' || typeof document === 'undefined')\n return null;\n const match = document.cookie.match(/pb_csrf_token=([^;]+)/);\n return match ? match[1] : null;\n}\n\n/**\n * Thrown when the API rejects the session (401). Typed so a caller can tell an\n * expired session apart from a genuine empty result — the distinction the whole\n * dashboard was previously unable to make.\n */\nexport class SessionExpiredError extends Error {\n constructor() {\n super('Your session has expired');\n this.name = 'SessionExpiredError';\n }\n}\n\nexport class ApiRequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly details?: unknown,\n ) {\n super(message);\n this.name = 'ApiRequestError';\n }\n}\n\nexport { AUTH_ROUTES, isSessionExpiryResponse } from './session-scope';\n\n/** One redirect per page load, however many requests fail together. */\nlet sessionExpiryHandled = false;\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\n\n/**\n * Where we last sent someone to log in, and when. Bouncing to login is a full\n * page load, so the in-memory guard above resets every time — it cannot see a\n * loop. This can.\n */\nexport const BOUNCE_KEY = 'pb_session_bounce';\n/** Two bounces off the same path inside this window is a loop, not a login. */\n\n/** Two bounces off the same path inside this window is a loop, not a login. */\nexport const BOUNCE_WINDOW_MS = 15_000;\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\n\n/**\n * True when we already bounced this path to login and are right back on it.\n *\n * That combination means the session is fine — login only returns you here if\n * it recognises you — and some single endpoint is answering 401 for its own\n * reasons (a missing tenant header, say). Bouncing again just repeats the\n * round trip forever, which the merchant sees as a page that reloads and\n * reloads. Better to let that one panel fail and leave the page standing.\n */\nexport function bouncedRecently(path: string): boolean {\n try {\n const raw = sessionStorage.getItem(BOUNCE_KEY);\n if (!raw) return false;\n const { path: last, at } = JSON.parse(raw) as { path: string; at: number };\n return last === path && Date.now() - at < BOUNCE_WINDOW_MS;\n } catch {\n return false;\n }\n}\n\nexport function recordBounce(path: string): void {\n try {\n sessionStorage.setItem(\n BOUNCE_KEY,\n JSON.stringify({ path, at: Date.now() }),\n );\n } catch {\n // Private mode / storage full. The in-memory guard still holds for this\n // page load; we just lose the cross-reload half of the protection.\n }\n}\n\n/**\n * GETS THAT OVERLAP IN TIME ARE ONE REQUEST.\n *\n * Not a cache — an entry lives only while its request is in the air, so this\n * can never hand back a stale answer. It collapses the case the dashboard\n * actually has: several components asking the same question in the same tick,\n * each with its own `useEffect` and nothing to dedupe against.\n *\n * Measured per page load before this, on a real workspace: `/pages` six times\n * on the editor, `products` five on the catalogue, `analytics/dashboard` four,\n * `orders` three. React Query dedupes what goes through it; roughly forty files\n * still fetch in a raw effect and those are all of the above.\n *\n * GET only, and keyed by the things that change the ANSWER — the URL, the\n * workspace and the account. A write is never collapsed: two POSTs that look\n * identical are two things happening.\n */\nconst inFlightGets = new Map<string, Promise<unknown>>();\n\n/**\n * POSTs that ask a question and change nothing: the filters are too large for a\n * query string. They must not announce a write. A query built on one would\n * invalidate every query on success, itself included, and so refetch for ever\n * (the New campaign screen counted its audience a few hundred times a minute).\n */\nconst READ_ONLY_POSTS = [/^\\/crm\\/segments\\/preview(\\?|$)/];\nconst onlyReads = (endpoint: string) =>\n READ_ONLY_POSTS.some((pattern) => pattern.test(endpoint));\n\nexport async function apiFetch<T = unknown>(\n endpoint: string,\n options: FetchOptions = {},\n): Promise<T> {\n const { tenantSlug, token, accountId, ...fetchOptions } = options;\n\n const headers: Record<string, string> = {\n ...(fetchOptions.headers as Record<string, string>),\n };\n\n // Let the runtime add the multipart boundary for FormData uploads.\n if (fetchOptions.body && !(fetchOptions.body instanceof FormData)) {\n headers['Content-Type'] = 'application/json';\n }\n\n if (tenantSlug) {\n headers['X-Tenant-Slug'] = tenantSlug;\n }\n\n // The merchant's account and token go on merchant calls only: the admin\n // guard reads a bearer before its own cookie, so a merchant one would be\n // taken for a bad admin session.\n const merchant = merchantIdentity(endpoint, getAccountId(), getAuthToken());\n const effectiveAccountId = accountId || merchant.accountId;\n if (effectiveAccountId) {\n headers['x-account-id'] = effectiveAccountId;\n }\n\n const effectiveToken = token || merchant.token;\n if (effectiveToken) {\n headers.Authorization = `Bearer ${effectiveToken}`;\n }\n\n // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)\n const method = (fetchOptions.method || 'GET').toUpperCase();\n // Reused below to announce the write. The list is the same one CSRF cares\n // about for the same reason: these are the methods that change something.\n const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);\n if (isWrite) {\n const csrfToken = getCsrfToken();\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n }\n\n // Request tracing: unique ID per API call + PostHog session correlation\n if (IS_BROWSER) {\n headers['x-request-id'] =\n crypto.randomUUID?.() ??\n `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n const sessionId = posthog.get_session_id?.();\n if (sessionId) {\n headers['x-posthog-session-id'] = sessionId;\n }\n }\n\n // A route this box's engine does not serve fails here, as the engine would.\n if (isUnservedRoute(endpoint)) {\n throw new ApiRequestError(unservedMessage(method, endpoint), 404);\n }\n\n const url = `${getApiUrl()}${endpoint}`;\n\n if (method === 'GET') {\n const key = `${url}|${tenantSlug ?? ''}|${effectiveAccountId ?? ''}`;\n const inFlight = inFlightGets.get(key);\n if (inFlight) return inFlight as Promise<T>;\n const started = send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint)).finally(() => {\n inFlightGets.delete(key);\n });\n inFlightGets.set(key, started);\n return started;\n }\n\n return send<T>(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));\n}\n\n/** The request itself, once `apiFetch` has decided who is making it. */\nasync function send<T>(\n url: string,\n fetchOptions: Omit<FetchOptions, 'tenantSlug' | 'token' | 'accountId'>,\n headers: Record<string, string>,\n isWrite: boolean,\n): Promise<T> {\n const res = await fetch(url, {\n ...fetchOptions,\n headers,\n credentials: 'include', // Include cookies for CSRF\n });\n\n if (!res.ok) {\n // 402 Payment Required — subscription inactive, redirect to billing\n // ...unless the plan guard sent it: a page the plan leaves out shows the\n // locked-feature card, and the merchant stays where they are.\n const lockedBody =\n res.status === 402\n ? await res.clone().json().catch(() => null)\n : null;\n const locked = featureNotInPlan(lockedBody);\n if (locked) {\n announceFeatureLocked(locked);\n throw new ApiRequestError(lockedBody.message ?? 'Not in your plan', 402, lockedBody);\n }\n if (res.status === 402 && IS_BROWSER) {\n window.location.href = '/dashboard/settings/billing';\n throw new Error('Payment required');\n }\n\n /**\n * 401 — the session is gone, not the data.\n *\n * Handled here because it cannot be handled well anywhere else: every panel\n * catches its own failure and renders an empty state, so an expired session\n * was being drawn as £0 revenue, 0 bookings and \"nothing yet\" across the\n * whole dashboard. That tells a merchant their business made nothing, when\n * in fact the app cannot talk to the server. A 401 is never an empty state.\n *\n * 403 is deliberately NOT caught: that is a real permission answer for a\n * valid session, and panels are right to hide themselves for it.\n *\n * The guard matters — a dashboard fires a dozen requests at once, and all\n * of them 401 together. Without it they'd each kick off a navigation.\n */\n if (isSessionExpiryResponse(res.status, url) && IS_BROWSER) {\n // An admin 401 goes to the admin sign-in, never the merchant one: that\n // forwards a signed-in merchant to /dashboard.\n const path = window.location.pathname;\n const target = sessionExpiryTarget(url, path, window.location.search);\n if (target && !sessionExpiryHandled && !bouncedRecently(path)) {\n sessionExpiryHandled = true;\n recordBounce(path);\n window.location.href = target;\n }\n throw new SessionExpiredError();\n }\n\n const error = await res.json().catch(() => ({ message: 'Request failed' }));\n // NestJS validation pipes return message as an array of strings\n let msg: string;\n if (Array.isArray(error.message)) {\n const items = error.message\n .map((m: string) => {\n // Strip field prefix like \"maxDiscountAmount: maxDiscountAmount must...\"\n const colonIdx = m.indexOf(': ');\n return colonIdx > -1 ? m.slice(colonIdx + 2) : m;\n })\n .filter((m: string, i: number, arr: string[]) => arr.indexOf(m) === i);\n // When NestJS returns an empty message array, fall back to error.error\n msg =\n items.length > 0\n ? '• ' + items.join('\\n• ')\n : error.error || `HTTP ${res.status}`;\n } else {\n msg = error.message || error.error || `HTTP ${res.status}`;\n }\n // Carry the status. Callers were reduced to matching on message text to\n // tell \"the server refused this\" from \"the server could not be reached\",\n // and those need opposite handling: one means the session is dead, the\n // other means fall back to cache and carry on.\n //\n // `details` carries the parsed body for the same reason, one level in. An\n // API that answers a refusal with a machine-readable code and the id of the\n // thing that caused it was having all of that thrown away here, leaving the\n // caller to substring-match the human sentence — which is how a recoverable\n // refusal (\"you already have one of these, connect it\") becomes a dead end.\n throw new ApiRequestError(msg, res.status, error);\n }\n\n /**\n * THE WRITE LANDED, SO WHAT IS ON SCREEN IS NOW OUT OF DATE.\n *\n * Announced here rather than at a hundred call sites, because this is the\n * only place that knows a write happened at all — see `cache-bus.ts` for why\n * that matters and what it fixes. Only after `res.ok`: a refused write has\n * changed nothing and refetching over it would be noise.\n */\n if (isWrite) {\n notifyWrite();\n }\n\n // 204 No Content has no body — return early to avoid JSON parse error\n if (res.status === 204) {\n return undefined as unknown as T;\n }\n\n // Handle empty response body (e.g., 200 OK with no content)\n const contentType = res.headers.get('content-type');\n if (!contentType || !contentType.includes('application/json')) {\n // Check if response has content\n const text = await res.text();\n if (!text) {\n return undefined as unknown as T;\n }\n return text as T;\n // Try to parse as JSON in case content-type header is missing\n try {\n return JSON.parse(text) as T;\n } catch {\n return undefined as unknown as T;\n }\n }\n\n return res.json();\n}\n\n// ============================================\n// AUTH / ACCOUNTS API\n// ============================================\n"],"mappings":"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","names":["isSessionExpiryResponse"]}
|
package/dist/api/potter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { apiFetch, getAccountId, getCsrfToken } from "./core";
|
|
1
|
+
import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from "./core";
|
|
2
2
|
const potterApi = {
|
|
3
3
|
/**
|
|
4
4
|
* `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke
|
|
@@ -29,6 +29,8 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
|
|
|
29
29
|
"Content-Type": "application/json"
|
|
30
30
|
};
|
|
31
31
|
if (tenantSlug) headers["X-Tenant-Slug"] = tenantSlug;
|
|
32
|
+
const token = getAuthToken();
|
|
33
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
32
34
|
const accountId = getAccountId();
|
|
33
35
|
if (accountId) headers["x-account-id"] = accountId;
|
|
34
36
|
const csrfToken = getCsrfToken();
|
|
@@ -36,7 +38,6 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
|
|
|
36
38
|
const res = await fetch("/api/proxy/potter/ask/stream", {
|
|
37
39
|
method: "POST",
|
|
38
40
|
headers,
|
|
39
|
-
credentials: "include",
|
|
40
41
|
body: JSON.stringify({ question, history }),
|
|
41
42
|
signal
|
|
42
43
|
});
|
package/dist/api/potter.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/api/potter.ts"],"sourcesContent":["import { apiFetch, getAccountId, getCsrfToken } from './core';\n\n/**\n * Box's answering route — the model, not the regex.\n *\n * `potter-answer.ts` is still here and still answers when this cannot: a\n * keyword router over a help corpus that reaches four hand-written lookups. It\n * could only ever answer sentences somebody had already thought of, which is\n * why \"which items are running low\" matched and \"am I about to run out of\n * anything\" did not.\n *\n * This calls `POST /potter/ask`, which decides which question a sentence IS,\n * in whatever words it arrived in, and answers from the merchant's own rows\n * through the same usecases the analytics screens read.\n */\n\nexport interface PotterTurn {\n role: 'you' | 'potter';\n text: string;\n}\n\nexport interface PotterAnswer {\n text: string;\n /** Which lookups ran. Nothing renders this yet; it is here so an answer can\n * be audited against the rows it claims to have read. */\n toolsUsed: string[];\n}\n\n/**\n * NULL MEANS \"NOT AVAILABLE\", NOT \"NO ANSWER\", and the caller must tell those\n * apart. The route answers 503 wherever `ANTHROPIC_API_KEY` is unset — which is\n * every local checkout and staging until the key is deployed — and a merchant\n * must not see an error for a deployment state they cannot act on. The caller\n * falls back to the local router, which is exactly what shipped before this\n * existed.\n *\n * A thrown error is different and is NOT swallowed here: a 500 from a route\n * that is configured is a real failure and the surface should say so.\n */\nexport const potterApi = {\n /**\n * `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke\n * this route. `/potter/ask` reads `@TenantId()` behind `PermissionsGuard`,\n * which throws `Tenant context required` — a 401 — when no workspace was\n * named. `apiFetch` only sends `X-Tenant-Slug` when it is handed one, so\n * every question from the browser took a 401, which is not 503 and so was\n * rethrown rather than falling back. The surface said \"I could not reach\n * that just now. Try again in a moment.\", which was untrue in the only way\n * that matters: trying again could never work.\n */\n async ask(\n question: string,\n history: PotterTurn[] = [],\n tenantSlug?: string,\n ): Promise<PotterAnswer | null> {\n try {\n return await apiFetch<PotterAnswer>('/potter/ask', {\n method: 'POST',\n tenantSlug,\n body: JSON.stringify({ question, history }),\n });\n } catch (err) {\n const status = (err as { status?: number } | null)?.status;\n // 503: not configured here. 404: the route is older than this client.\n if (status === 503 || status === 404) return null;\n throw err;\n }\n },\n};\n\n/** One frame of a streamed turn, mirroring the API's own union. */\nexport type PotterStreamEvent =\n | { type: 'text'; text: string }\n | { type: 'tool'; name: string }\n | { type: 'done'; toolsUsed: string[] }\n | { type: 'error'; message: string };\n\n/**\n * The same answer, read as it is written.\n *\n * NOT `EventSource`, and not because of a preference: it only speaks GET, and a\n * question plus ten turns of history does not belong in a URL. So this posts and\n * reads the body stream, which is a few lines of framing here and nothing at all\n * on the server.\n *\n * SSE FRAMING IS LINE-ORIENTED AND A CHUNK IS NOT A FRAME. A read can end\n * mid-event, so the tail of the buffer is kept until a blank line proves the\n * frame is whole. Parsing per chunk instead is the bug that shows up only under\n * load, when messages are long enough to split.\n *\n * FALSE MEANS \"NOT AVAILABLE HERE\", the same contract `ask` uses: an\n * unconfigured environment cannot answer 503 once the headers are out, so it\n * says so in an `error` frame instead, and both paths return the caller to the\n * keyword router rather than to an error a merchant cannot act on.\n */\n/**\n * A RAW `fetch` HAS TO CARRY WHAT `apiFetch` WOULD HAVE CARRIED.\n *\n * This posted `Content-Type` and nothing else, and a body stream is the one\n * thing `apiFetch` cannot return — so the route lost the four headers every\n * other call in the app gets for free. Two of them were load-bearing:\n * `X-CSRF-Token`, without which the api's global `CsrfGuard` refuses every\n * POST, and `X-Tenant-Slug`, without which `PermissionsGuard` has no workspace\n * to check a permission against. The stream therefore answered 403 on every\n * environment since it shipped, silently, because a failed stream falls back\n * to the whole-turn route and that route was broken the same way.\n *\n * They are read off the same helpers `apiFetch` uses rather than passed in, so\n * the two paths cannot drift on where a token lives.\n */\nexport async function askPotterStream(\n question: string,\n history: PotterTurn[],\n on: (event: PotterStreamEvent) => void,\n signal?: AbortSignal,\n tenantSlug?: string,\n): Promise<boolean> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n if (tenantSlug) headers['X-Tenant-Slug'] = tenantSlug;\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const csrfToken = getCsrfToken();\n if (csrfToken) headers['X-CSRF-Token'] = csrfToken;\n\n const res = await fetch('/api/proxy/potter/ask/stream', {\n method: 'POST',\n headers,\n
|
|
1
|
+
{"version":3,"sources":["../../src/api/potter.ts"],"sourcesContent":["import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from './core';\n\n/**\n * Box's answering route — the model, not the regex.\n *\n * `potter-answer.ts` is still here and still answers when this cannot: a\n * keyword router over a help corpus that reaches four hand-written lookups. It\n * could only ever answer sentences somebody had already thought of, which is\n * why \"which items are running low\" matched and \"am I about to run out of\n * anything\" did not.\n *\n * This calls `POST /potter/ask`, which decides which question a sentence IS,\n * in whatever words it arrived in, and answers from the merchant's own rows\n * through the same usecases the analytics screens read.\n */\n\nexport interface PotterTurn {\n role: 'you' | 'potter';\n text: string;\n}\n\nexport interface PotterAnswer {\n text: string;\n /** Which lookups ran. Nothing renders this yet; it is here so an answer can\n * be audited against the rows it claims to have read. */\n toolsUsed: string[];\n}\n\n/**\n * NULL MEANS \"NOT AVAILABLE\", NOT \"NO ANSWER\", and the caller must tell those\n * apart. The route answers 503 wherever `ANTHROPIC_API_KEY` is unset — which is\n * every local checkout and staging until the key is deployed — and a merchant\n * must not see an error for a deployment state they cannot act on. The caller\n * falls back to the local router, which is exactly what shipped before this\n * existed.\n *\n * A thrown error is different and is NOT swallowed here: a 500 from a route\n * that is configured is a real failure and the surface should say so.\n */\nexport const potterApi = {\n /**\n * `tenantSlug` IS NOT OPTIONAL IN PRACTICE, and leaving it off is what broke\n * this route. `/potter/ask` reads `@TenantId()` behind `PermissionsGuard`,\n * which throws `Tenant context required` — a 401 — when no workspace was\n * named. `apiFetch` only sends `X-Tenant-Slug` when it is handed one, so\n * every question from the browser took a 401, which is not 503 and so was\n * rethrown rather than falling back. The surface said \"I could not reach\n * that just now. Try again in a moment.\", which was untrue in the only way\n * that matters: trying again could never work.\n */\n async ask(\n question: string,\n history: PotterTurn[] = [],\n tenantSlug?: string,\n ): Promise<PotterAnswer | null> {\n try {\n return await apiFetch<PotterAnswer>('/potter/ask', {\n method: 'POST',\n tenantSlug,\n body: JSON.stringify({ question, history }),\n });\n } catch (err) {\n const status = (err as { status?: number } | null)?.status;\n // 503: not configured here. 404: the route is older than this client.\n if (status === 503 || status === 404) return null;\n throw err;\n }\n },\n};\n\n/** One frame of a streamed turn, mirroring the API's own union. */\nexport type PotterStreamEvent =\n | { type: 'text'; text: string }\n | { type: 'tool'; name: string }\n | { type: 'done'; toolsUsed: string[] }\n | { type: 'error'; message: string };\n\n/**\n * The same answer, read as it is written.\n *\n * NOT `EventSource`, and not because of a preference: it only speaks GET, and a\n * question plus ten turns of history does not belong in a URL. So this posts and\n * reads the body stream, which is a few lines of framing here and nothing at all\n * on the server.\n *\n * SSE FRAMING IS LINE-ORIENTED AND A CHUNK IS NOT A FRAME. A read can end\n * mid-event, so the tail of the buffer is kept until a blank line proves the\n * frame is whole. Parsing per chunk instead is the bug that shows up only under\n * load, when messages are long enough to split.\n *\n * FALSE MEANS \"NOT AVAILABLE HERE\", the same contract `ask` uses: an\n * unconfigured environment cannot answer 503 once the headers are out, so it\n * says so in an `error` frame instead, and both paths return the caller to the\n * keyword router rather than to an error a merchant cannot act on.\n */\n/**\n * A RAW `fetch` HAS TO CARRY WHAT `apiFetch` WOULD HAVE CARRIED.\n *\n * This posted `Content-Type` and nothing else, and a body stream is the one\n * thing `apiFetch` cannot return — so the route lost the four headers every\n * other call in the app gets for free. Two of them were load-bearing:\n * `X-CSRF-Token`, without which the api's global `CsrfGuard` refuses every\n * POST, and `X-Tenant-Slug`, without which `PermissionsGuard` has no workspace\n * to check a permission against. The stream therefore answered 403 on every\n * environment since it shipped, silently, because a failed stream falls back\n * to the whole-turn route and that route was broken the same way.\n *\n * They are read off the same helpers `apiFetch` uses rather than passed in, so\n * the two paths cannot drift on where a token lives.\n */\nexport async function askPotterStream(\n question: string,\n history: PotterTurn[],\n on: (event: PotterStreamEvent) => void,\n signal?: AbortSignal,\n tenantSlug?: string,\n): Promise<boolean> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n if (tenantSlug) headers['X-Tenant-Slug'] = tenantSlug;\n const token = getAuthToken();\n if (token) headers.Authorization = `Bearer ${token}`;\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const csrfToken = getCsrfToken();\n if (csrfToken) headers['X-CSRF-Token'] = csrfToken;\n\n const res = await fetch('/api/proxy/potter/ask/stream', {\n method: 'POST',\n headers,\n body: JSON.stringify({ question, history }),\n signal,\n });\n\n if (res.status === 503 || res.status === 404 || !res.body) return false;\n if (!res.ok) throw new Error(`Box answered ${res.status}`);\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let available = true;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const { events, rest } = takeSseEvents(\n buffer + decoder.decode(value, { stream: true }),\n );\n buffer = rest;\n\n for (const event of events) {\n /* An unconfigured environment says so in-band, because a 503 cannot be\n sent once the headers are out. Treated as \"not available\" rather than\n as a failure, so the caller falls back exactly as it would on a 503. */\n if (event.type === 'error' && event.message.includes('not configured')) {\n available = false;\n } else {\n on(event);\n }\n }\n }\n\n return available;\n}\n\n/**\n * Whole SSE events out of a buffer, and whatever is left over.\n *\n * SEPARATED FROM THE FETCH SO IT CAN BE TESTED, because this is the part that\n * is quietly wrong in most hand-rolled SSE clients and the part that cannot be\n * checked by looking at it. A chunk is not a frame: a read can end anywhere,\n * including halfway through a JSON payload, so anything after the last blank\n * line is held back rather than parsed. Parsing per chunk works perfectly until\n * answers get long enough to split across reads — which is exactly when nobody\n * is watching.\n *\n * A frame that is not JSON is dropped rather than thrown: that is a proxy or a\n * gateway talking, not Box, and crashing a merchant's turn on somebody\n * else's error page helps nobody.\n */\nexport function takeSseEvents(buffer: string): {\n events: PotterStreamEvent[];\n rest: string;\n} {\n const events: PotterStreamEvent[] = [];\n let rest = buffer;\n\n for (;;) {\n const cut = rest.indexOf('\\n\\n');\n if (cut === -1) break;\n\n const frame = rest.slice(0, cut);\n rest = rest.slice(cut + 2);\n\n const line = frame.split('\\n').find((l) => l.startsWith('data:'));\n if (!line) continue;\n\n try {\n events.push(JSON.parse(line.slice(5).trim()) as PotterStreamEvent);\n } catch {\n /* Not ours. See above. */\n }\n }\n\n return { events, rest };\n}\n"],"mappings":"AAAA,SAAS,UAAU,cAAc,cAAc,oBAAoB;AAuC5D,MAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWvB,MAAM,IACJ,UACA,UAAwB,CAAC,GACzB,YAC8B;AAC9B,QAAI;AACF,aAAO,MAAM,SAAuB,eAAe;AAAA,QACjD,QAAQ;AAAA,QACR;AAAA,QACA,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,MAC5C,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,YAAM,SAAU,KAAoC;AAEpD,UAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,YAAM;AAAA,IACR;AAAA,EACF;AACF;AA0CA,eAAsB,gBACpB,UACA,SACA,IACA,QACA,YACkB;AAClB,QAAM,UAAkC;AAAA,IACtC,gBAAgB;AAAA,EAClB;AACA,MAAI,WAAY,SAAQ,eAAe,IAAI;AAC3C,QAAM,QAAQ,aAAa;AAC3B,MAAI,MAAO,SAAQ,gBAAgB,UAAU,KAAK;AAClD,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AACzC,QAAM,YAAY,aAAa;AAC/B,MAAI,UAAW,SAAQ,cAAc,IAAI;AAEzC,QAAM,MAAM,MAAM,MAAM,gCAAgC;AAAA,IACtD,QAAQ;AAAA,IACR;AAAA,IACA,MAAM,KAAK,UAAU,EAAE,UAAU,QAAQ,CAAC;AAAA,IAC1C;AAAA,EACF,CAAC;AAED,MAAI,IAAI,WAAW,OAAO,IAAI,WAAW,OAAO,CAAC,IAAI,KAAM,QAAO;AAClE,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,gBAAgB,IAAI,MAAM,EAAE;AAEzD,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,QAAM,UAAU,IAAI,YAAY;AAChC,MAAI,SAAS;AACb,MAAI,YAAY;AAEhB,aAAS;AACP,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,QAAI,KAAM;AAEV,UAAM,EAAE,QAAQ,KAAK,IAAI;AAAA,MACvB,SAAS,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAAA,IACjD;AACA,aAAS;AAET,eAAW,SAAS,QAAQ;AAI1B,UAAI,MAAM,SAAS,WAAW,MAAM,QAAQ,SAAS,gBAAgB,GAAG;AACtE,oBAAY;AAAA,MACd,OAAO;AACL,WAAG,KAAK;AAAA,MACV;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAiBO,SAAS,cAAc,QAG5B;AACA,QAAM,SAA8B,CAAC;AACrC,MAAI,OAAO;AAEX,aAAS;AACP,UAAM,MAAM,KAAK,QAAQ,MAAM;AAC/B,QAAI,QAAQ,GAAI;AAEhB,UAAM,QAAQ,KAAK,MAAM,GAAG,GAAG;AAC/B,WAAO,KAAK,MAAM,MAAM,CAAC;AAEzB,UAAM,OAAO,MAAM,MAAM,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO,CAAC;AAChE,QAAI,CAAC,KAAM;AAEX,QAAI;AACF,aAAO,KAAK,KAAK,MAAM,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC,CAAsB;AAAA,IACnE,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,KAAK;AACxB;","names":[]}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which session a request belongs to, and where a 401 sends the person.
|
|
3
3
|
*
|
|
4
|
-
* One console serves two sign-ins: the merchant dashboard (
|
|
5
|
-
*
|
|
4
|
+
* One console serves two sign-ins: the merchant dashboard (`pb_token`, sent as a
|
|
5
|
+
* bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They
|
|
6
6
|
* must never mix. The admin guard reads a bearer before its cookie, so a merchant
|
|
7
7
|
* bearer on an admin call is read as a bad admin session; and a 401 from the
|
|
8
8
|
* admin must not send the person to the merchant sign-in, which forwards a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/api/session-scope.ts"],"sourcesContent":["/**\n * Which session a request belongs to, and where a 401 sends the person.\n *\n * One console serves two sign-ins: the merchant dashboard (
|
|
1
|
+
{"version":3,"sources":["../../src/api/session-scope.ts"],"sourcesContent":["/**\n * Which session a request belongs to, and where a 401 sends the person.\n *\n * One console serves two sign-ins: the merchant dashboard (`pb_token`, sent as a\n * bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They\n * must never mix. The admin guard reads a bearer before its cookie, so a merchant\n * bearer on an admin call is read as a bad admin session; and a 401 from the\n * admin must not send the person to the merchant sign-in, which forwards a\n * signed-in merchant to `/dashboard`.\n */\n\nconst ADMIN_API = '/platform-admin/';\nconst ADMIN_LOGIN_API = '/platform-admin/v1/auth/login';\nconst ADMIN_PAGE = '/admin';\nexport const ADMIN_LOGIN_PAGE = '/admin/login';\n/** Admin pages that run without an admin session; a 401 there must not bounce. */\nconst ADMIN_AUTH_PAGES = [ADMIN_LOGIN_PAGE, '/admin/change-password'];\n/** Merchant pages that must never bounce to login, or the redirect loops. */\nexport const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];\n\nfunction pathOf(url: string): string {\n return new URL(url, 'http://localhost').pathname;\n}\n\n/** True for a call to the platform-admin API. */\nexport function isAdminRequest(url: string): boolean {\n return pathOf(url).includes(ADMIN_API);\n}\n\n/** True for a page of the admin console. */\nexport function isAdminPage(pagePath: string): boolean {\n return pagePath === ADMIN_PAGE || pagePath.startsWith(`${ADMIN_PAGE}/`);\n}\n\n/** A 401 means the session is gone, except wrong admin sign-in details. */\nexport function isSessionExpiryResponse(status: number, url: string): boolean {\n return status === 401 && !pathOf(url).endsWith(ADMIN_LOGIN_API);\n}\n\n/**\n * Where to send someone whose session has gone, or null to stay put.\n * Admin calls and admin pages go to the admin sign-in; everything else to the\n * merchant sign-in. Either way they come back to the page they were on.\n */\nexport function sessionExpiryTarget(\n requestUrl: string,\n pagePath: string,\n search = '',\n): string | null {\n const back = encodeURIComponent(pagePath + search);\n if (isAdminRequest(requestUrl) || isAdminPage(pagePath)) {\n if (ADMIN_AUTH_PAGES.includes(pagePath)) return null;\n return isAdminPage(pagePath)\n ? `${ADMIN_LOGIN_PAGE}?redirect=${back}`\n : ADMIN_LOGIN_PAGE;\n }\n if (AUTH_ROUTES.some((route) => pagePath.startsWith(route))) return null;\n return `/login?redirect=${back}`;\n}\n\n/**\n * The admin page to open after admin sign-in: the `redirect` it was sent with\n * when that is an admin page on this site, otherwise Overview.\n */\nexport function adminReturnPath(redirect: string | null | undefined): string {\n if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) {\n return ADMIN_PAGE;\n }\n const url = new URL(redirect, 'http://localhost');\n if (url.origin !== 'http://localhost' || !isAdminPage(url.pathname)) {\n return ADMIN_PAGE;\n }\n if (ADMIN_AUTH_PAGES.includes(url.pathname)) return ADMIN_PAGE;\n return url.pathname + url.search;\n}\n\n/** The merchant's own headers go only on merchant calls. */\nexport function merchantIdentity(\n requestUrl: string,\n accountId: string | null,\n token: string | null,\n): { accountId: string | null; token: string | null } {\n if (isAdminRequest(requestUrl)) return { accountId: null, token: null };\n return { accountId, token };\n}\n"],"mappings":"AAWA,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,aAAa;AACZ,MAAM,mBAAmB;AAEhC,MAAM,mBAAmB,CAAC,kBAAkB,wBAAwB;AAE7D,MAAM,cAAc,CAAC,UAAU,WAAW,SAAS,SAAS;AAEnE,SAAS,OAAO,KAAqB;AACnC,SAAO,IAAI,IAAI,KAAK,kBAAkB,EAAE;AAC1C;AAGO,SAAS,eAAe,KAAsB;AACnD,SAAO,OAAO,GAAG,EAAE,SAAS,SAAS;AACvC;AAGO,SAAS,YAAY,UAA2B;AACrD,SAAO,aAAa,cAAc,SAAS,WAAW,GAAG,UAAU,GAAG;AACxE;AAGO,SAAS,wBAAwB,QAAgB,KAAsB;AAC5E,SAAO,WAAW,OAAO,CAAC,OAAO,GAAG,EAAE,SAAS,eAAe;AAChE;AAOO,SAAS,oBACd,YACA,UACA,SAAS,IACM;AACf,QAAM,OAAO,mBAAmB,WAAW,MAAM;AACjD,MAAI,eAAe,UAAU,KAAK,YAAY,QAAQ,GAAG;AACvD,QAAI,iBAAiB,SAAS,QAAQ,EAAG,QAAO;AAChD,WAAO,YAAY,QAAQ,IACvB,GAAG,gBAAgB,aAAa,IAAI,KACpC;AAAA,EACN;AACA,MAAI,YAAY,KAAK,CAAC,UAAU,SAAS,WAAW,KAAK,CAAC,EAAG,QAAO;AACpE,SAAO,mBAAmB,IAAI;AAChC;AAMO,SAAS,gBAAgB,UAA6C;AAC3E,MAAI,CAAC,YAAY,CAAC,SAAS,WAAW,GAAG,KAAK,SAAS,WAAW,IAAI,GAAG;AACvE,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,IAAI,UAAU,kBAAkB;AAChD,MAAI,IAAI,WAAW,sBAAsB,CAAC,YAAY,IAAI,QAAQ,GAAG;AACnE,WAAO;AAAA,EACT;AACA,MAAI,iBAAiB,SAAS,IAAI,QAAQ,EAAG,QAAO;AACpD,SAAO,IAAI,WAAW,IAAI;AAC5B;AAGO,SAAS,iBACd,YACA,WACA,OACoD;AACpD,MAAI,eAAe,UAAU,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,KAAK;AACtE,SAAO,EAAE,WAAW,MAAM;AAC5B;","names":[]}
|
package/dist/api/types/auth.d.ts
CHANGED
|
@@ -29,8 +29,6 @@ export interface SignupResult {
|
|
|
29
29
|
email: string;
|
|
30
30
|
name: string;
|
|
31
31
|
avatarUrl?: string;
|
|
32
|
-
/** Optional; null or absent when the account has none. */
|
|
33
|
-
phone?: string | null;
|
|
34
32
|
authProvider: string;
|
|
35
33
|
status: string;
|
|
36
34
|
mfaEnabled?: boolean;
|
|
@@ -41,8 +39,6 @@ export interface SignupResult {
|
|
|
41
39
|
export interface UpdateAccountInput {
|
|
42
40
|
name?: string;
|
|
43
41
|
avatarUrl?: string;
|
|
44
|
-
/** Optional. An empty string clears it. */
|
|
45
|
-
phone?: string;
|
|
46
42
|
}
|
|
47
43
|
export interface ChangePasswordInput {
|
|
48
44
|
currentPassword: string;
|
|
@@ -73,7 +69,6 @@ export interface AuthAccount {
|
|
|
73
69
|
email: string;
|
|
74
70
|
name: string;
|
|
75
71
|
avatarUrl?: string | null;
|
|
76
|
-
phone?: string | null;
|
|
77
72
|
authProvider: string;
|
|
78
73
|
status: string;
|
|
79
74
|
emailVerified?: boolean;
|
|
@@ -83,8 +78,7 @@ export interface AuthAccount {
|
|
|
83
78
|
/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */
|
|
84
79
|
export interface AuthSessionPayload {
|
|
85
80
|
account: AuthAccount;
|
|
86
|
-
|
|
87
|
-
token?: string;
|
|
81
|
+
token: string;
|
|
88
82
|
tenants: LoginTenantInfo[];
|
|
89
83
|
organizationId: string | null;
|
|
90
84
|
sessionId?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n
|
|
1
|
+
{"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n authProvider: string;\n status: string;\n mfaEnabled?: boolean;\n };\n organizationId: string;\n token?: string;\n}\n\nexport interface UpdateAccountInput {\n name?: string;\n avatarUrl?: string;\n}\n\nexport interface ChangePasswordInput {\n currentPassword: string;\n newPassword: string;\n}\n\nexport interface ChangeEmailInput {\n newEmail: string;\n password: string;\n}\n\nexport interface LoginTenantInfo {\n id: string;\n name: string;\n slug: string;\n templateId: string;\n role: string;\n permissions?: Record<string, boolean>;\n organizationId: string | null;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n settings: Record<string, unknown> | null;\n}\n\n/** Account payload from authenticated endpoints. */\nexport interface AuthAccount {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string | null;\n authProvider: string;\n status: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string | null;\n}\n\n/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */\nexport interface AuthSessionPayload {\n account: AuthAccount;\n token: string;\n tenants: LoginTenantInfo[];\n organizationId: string | null;\n sessionId?: string;\n}\n\n/** Returned when a Google email matches an existing password-only account. */\nexport interface GoogleSignInCollisionPayload {\n email: string;\n accountId: string;\n googleSub: string;\n name: string | null;\n avatarUrl: string | null;\n}\n\n/** /accounts/google/callback response. `kind` discriminates the next step. */\nexport interface GoogleSignInCallbackResult extends Partial<AuthSessionPayload> {\n kind: 'signed-in' | 'created' | 'collision';\n collision?: GoogleSignInCollisionPayload;\n}\n\nexport interface GoogleLinkInput {\n accountId: string;\n password: string;\n googleSub: string;\n}\n\n/** Per-account communication + analytics consent. */\nexport interface AccountConsent {\n productEmails: boolean;\n productSms: boolean;\n analytics: boolean;\n /** ISO timestamp of the last change, when the account has ever set it. */\n updatedAt?: string;\n}\n\nexport type ConsentInput = Pick<\n AccountConsent,\n 'productEmails' | 'productSms' | 'analytics'\n>;\n\nexport interface MfaSetupResult {\n /** Base32 secret, for manual entry when a QR can't be scanned. */\n secret: string;\n /** otpauth:// URI encoded in the QR. */\n otpauthUrl: string;\n /** Ready-to-render data: URL of the QR image. */\n qrDataUri: string;\n}\n\nexport interface MfaVerifyResult {\n enabled: true;\n /** One-time backup codes — display once, never retrievable again. */\n backupCodes: string[];\n}\n\nexport interface SendLoginCodeResult {\n message: string;\n}\n\n/** One enrolled authenticator, as the settings list sees it. */\nexport interface Passkey {\n id: string;\n /** What the owner named it. Null until they do. */\n name: string | null;\n /** `singleDevice` keys die with the hardware; `multiDevice` ones sync. */\n deviceType: string | null;\n backedUp: boolean;\n createdAt: string;\n lastUsedAt: string | null;\n}\n\nexport interface BeginPasskeyLoginResult {\n /** Ties the response back to the challenge the server issued. */\n handle: string;\n options: PublicKeyCredentialRequestOptionsJSON;\n}\n\nexport interface PasskeyLoginResult extends AuthSessionPayload {\n /**\n * Whether the authenticator verified the person, not just held the key.\n *\n * True means the gesture already carried both factors, so an MFA-enabled\n * account must NOT then be asked for a TOTP code — that would make the safer\n * route the more annoying one.\n */\n mfaSatisfied: boolean;\n}\n\nexport interface VerifyLoginCodeSession extends AuthSessionPayload {\n /** True when this verify created the account (route to onboarding). */\n isNewAccount: boolean;\n}\n\n/**\n * What a door returns when the first factor passed but the account owes a\n * second.\n *\n * No token and no account: nothing here authenticates anything. The `mfaToken`\n * is single-use, short-lived and attempt-capped, and is accepted only by\n * /accounts/mfa/challenge.\n */\nexport interface MfaChallengeRequired {\n mfaRequired: true;\n mfaToken: string;\n}\n\n/**\n * A union rather than an optional field, so a caller cannot read `.token` off a\n * challenge by accident. It caught a real mismatch on the API side.\n */\nexport type VerifyLoginCodeResult =\n | VerifyLoginCodeSession\n | MfaChallengeRequired;\n\nexport function isMfaChallenge(\n result: AuthSessionPayload | MfaChallengeRequired,\n): result is MfaChallengeRequired {\n return (result as MfaChallengeRequired).mfaRequired === true;\n}\n\nexport type DeviceKind = 'mobile' | 'tablet' | 'desktop' | 'web';\n\nexport interface ConnectedDeviceRecord {\n id: string;\n name: string;\n kind: DeviceKind;\n location?: string;\n lastSeen: string;\n isCurrent?: boolean;\n}\n\nexport interface ProvisionTenantInput {\n organizationId: string;\n /** Omit it — the API derives the starting look from `intents`. Send one only\n * when the merchant deliberately chose a template. */\n templateId?: string;\n businessName: string;\n slug?: string;\n email?: string;\n phone?: string;\n category?: string;\n /** ISO 4217 default currency, derived from country. */\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n}\n"],"mappings":"AA6MO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
|
package/dist/api/types/crm.d.ts
CHANGED
|
@@ -111,8 +111,6 @@ export interface MerchantNotification {
|
|
|
111
111
|
link: string | null;
|
|
112
112
|
readAt: string | null;
|
|
113
113
|
createdAt: string;
|
|
114
|
-
/** What the sender attached: for a plan notice, its tone and the next step offered on the row. */
|
|
115
|
-
metadata?: Record<string, unknown> | null;
|
|
116
114
|
}
|
|
117
115
|
export interface MerchantNotificationList {
|
|
118
116
|
notifications: MerchantNotification[];
|
|
@@ -264,12 +264,6 @@ export interface TenantSettings {
|
|
|
264
264
|
perHours?: number;
|
|
265
265
|
applyToCampaigns?: boolean;
|
|
266
266
|
};
|
|
267
|
-
/**
|
|
268
|
-
* Moves a paid order from Pending to Confirmed by itself. Off unless this says
|
|
269
|
-
* `true`; in-person orders and cash or transfer orders confirmed by hand are
|
|
270
|
-
* never touched by it.
|
|
271
|
-
*/
|
|
272
|
-
autoConfirmPaidOrders?: boolean;
|
|
273
267
|
businessName?: string;
|
|
274
268
|
tagline?: string;
|
|
275
269
|
logo?: {
|
package/dist/auth-context.d.ts
CHANGED
|
@@ -11,8 +11,6 @@ export interface User {
|
|
|
11
11
|
email: string;
|
|
12
12
|
name: string;
|
|
13
13
|
avatarUrl?: string;
|
|
14
|
-
/** The account's own phone number, when they gave one. */
|
|
15
|
-
phone?: string | null;
|
|
16
14
|
emailVerified: boolean;
|
|
17
15
|
/** Two-factor authentication is active on this account. */
|
|
18
16
|
mfaEnabled?: boolean;
|
package/dist/auth-context.js
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
accountsApi,
|
|
14
14
|
tenantApi
|
|
15
15
|
} from "./api";
|
|
16
|
-
import { removeLegacyToken } from "./stored-token";
|
|
17
16
|
import { resetPostHogIdentity } from "@withpotter/components/analytics";
|
|
18
17
|
import { uploadImage } from "@withpotter/lib/media-service";
|
|
19
18
|
import { endSession } from "@withpotter/lib/end-session";
|
|
@@ -225,10 +224,32 @@ function AuthProvider({ children }) {
|
|
|
225
224
|
setLoading(false);
|
|
226
225
|
return;
|
|
227
226
|
}
|
|
228
|
-
|
|
227
|
+
const token = localStorage.getItem("pb_token");
|
|
229
228
|
const pbUser = localStorage.getItem("pb_user");
|
|
230
229
|
const storedOrgId = localStorage.getItem("organizationId");
|
|
230
|
+
if (!token) {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const parts = token.split(".");
|
|
236
|
+
if (parts.length === 3) {
|
|
237
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
238
|
+
if (payload.exp && payload.exp <= Math.floor(Date.now() / 1e3)) {
|
|
239
|
+
clearStoredAuth();
|
|
240
|
+
setLoading(false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
clearStoredAuth();
|
|
246
|
+
setLoading(false);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
231
249
|
if (!pbUser) {
|
|
250
|
+
if (process.env.NODE_ENV === "development") {
|
|
251
|
+
console.warn("No user data found, clearing auth state");
|
|
252
|
+
}
|
|
232
253
|
clearStoredAuth();
|
|
233
254
|
setLoading(false);
|
|
234
255
|
return;
|
|
@@ -259,7 +280,6 @@ function AuthProvider({ children }) {
|
|
|
259
280
|
email: userData.email,
|
|
260
281
|
name: userData.name || userData.email.split("@")[0],
|
|
261
282
|
avatarUrl: userData.avatarUrl || void 0,
|
|
262
|
-
phone: userData.phone ?? null,
|
|
263
283
|
emailVerified: userData.emailVerified ?? false,
|
|
264
284
|
mfaEnabled: userData.mfaEnabled ?? false,
|
|
265
285
|
organizationId: storedOrgId || userData?.organizationId || cachedWorkspace.tenant.organizationId || "",
|
|
@@ -269,7 +289,9 @@ function AuthProvider({ children }) {
|
|
|
269
289
|
setLoading(false);
|
|
270
290
|
}
|
|
271
291
|
try {
|
|
272
|
-
const freshAccount = await apiFetch("/accounts/me"
|
|
292
|
+
const freshAccount = await apiFetch("/accounts/me", {
|
|
293
|
+
token
|
|
294
|
+
});
|
|
273
295
|
if (freshAccount) {
|
|
274
296
|
userData = freshAccount;
|
|
275
297
|
localStorage.setItem("pb_user", JSON.stringify(freshAccount));
|
|
@@ -303,7 +325,6 @@ function AuthProvider({ children }) {
|
|
|
303
325
|
email: userData.email,
|
|
304
326
|
name: userData.name || userData.email.split("@")[0],
|
|
305
327
|
avatarUrl: userData.avatarUrl || void 0,
|
|
306
|
-
phone: userData.phone ?? null,
|
|
307
328
|
emailVerified: userData.emailVerified ?? false,
|
|
308
329
|
mfaEnabled: userData.mfaEnabled ?? false,
|
|
309
330
|
organizationId: userOrgId ?? "",
|
|
@@ -319,7 +340,7 @@ function AuthProvider({ children }) {
|
|
|
319
340
|
};
|
|
320
341
|
}, [fetchTenant, loadWorkspaces]);
|
|
321
342
|
const establishSession = async (data, method = "email", connection) => {
|
|
322
|
-
const { account, tenants, organizationId } = data;
|
|
343
|
+
const { account, token, tenants, organizationId } = data;
|
|
323
344
|
const workspaces = [];
|
|
324
345
|
for (const t of tenants || []) {
|
|
325
346
|
workspaces.push({
|
|
@@ -350,6 +371,7 @@ function AuthProvider({ children }) {
|
|
|
350
371
|
}
|
|
351
372
|
}
|
|
352
373
|
const userOrgId = organizationId || tenants?.[0]?.organizationId || currentWorkspace?.tenant?.organizationId || null;
|
|
374
|
+
localStorage.setItem("pb_token", token);
|
|
353
375
|
localStorage.setItem("pb_account_id", account.id);
|
|
354
376
|
localStorage.setItem("pb_user", JSON.stringify(account));
|
|
355
377
|
localStorage.setItem("userEmail", account.email);
|
|
@@ -377,7 +399,6 @@ function AuthProvider({ children }) {
|
|
|
377
399
|
email: account.email,
|
|
378
400
|
name: account.name || account.email.split("@")[0],
|
|
379
401
|
avatarUrl: account.avatarUrl || void 0,
|
|
380
|
-
phone: account.phone ?? null,
|
|
381
402
|
emailVerified: account.emailVerified ?? false,
|
|
382
403
|
mfaEnabled: account.mfaEnabled ?? false,
|
|
383
404
|
organizationId: userOrgId ?? "",
|
|
@@ -474,7 +495,8 @@ function AuthProvider({ children }) {
|
|
|
474
495
|
const slug = input.slug || businessName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
475
496
|
let organizationId = user?.organizationId || "";
|
|
476
497
|
if (!organizationId) {
|
|
477
|
-
const
|
|
498
|
+
const token = localStorage.getItem("pb_token") || "";
|
|
499
|
+
const orgs = await accountsApi.getMyOrganizations(token);
|
|
478
500
|
if (!orgs || orgs.length === 0) {
|
|
479
501
|
throw new Error("No organization found. Please sign up first.");
|
|
480
502
|
}
|
|
@@ -599,11 +621,13 @@ function AuthProvider({ children }) {
|
|
|
599
621
|
const refreshUser = useCallback(async () => {
|
|
600
622
|
if (!user) return;
|
|
601
623
|
try {
|
|
624
|
+
const token = localStorage.getItem("pb_token");
|
|
602
625
|
let userData = null;
|
|
603
|
-
{
|
|
626
|
+
if (token) {
|
|
604
627
|
try {
|
|
605
628
|
const freshAccount = await apiFetch(
|
|
606
|
-
"/accounts/me"
|
|
629
|
+
"/accounts/me",
|
|
630
|
+
{ token }
|
|
607
631
|
);
|
|
608
632
|
if (freshAccount) {
|
|
609
633
|
userData = freshAccount;
|
|
@@ -619,6 +643,9 @@ function AuthProvider({ children }) {
|
|
|
619
643
|
const pbUser = localStorage.getItem("pb_user");
|
|
620
644
|
if (pbUser) userData = JSON.parse(pbUser);
|
|
621
645
|
}
|
|
646
|
+
} else {
|
|
647
|
+
const pbUser = localStorage.getItem("pb_user");
|
|
648
|
+
if (pbUser) userData = JSON.parse(pbUser);
|
|
622
649
|
}
|
|
623
650
|
if (!userData) return;
|
|
624
651
|
const result = await loadWorkspaces();
|
|
@@ -636,8 +663,6 @@ function AuthProvider({ children }) {
|
|
|
636
663
|
name: userData?.name || prev.name,
|
|
637
664
|
email: userData?.email || prev.email,
|
|
638
665
|
avatarUrl: userData?.avatarUrl || prev.avatarUrl,
|
|
639
|
-
// Absent from an older API response: keep what we had.
|
|
640
|
-
phone: userData && "phone" in userData ? userData.phone ?? null : prev.phone,
|
|
641
666
|
emailVerified: userData?.emailVerified ?? prev.emailVerified,
|
|
642
667
|
mfaEnabled: userData?.mfaEnabled ?? prev.mfaEnabled,
|
|
643
668
|
workspaces,
|