@withpotter/lib 0.13.0-next.64 → 0.13.0-next.65

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.
@@ -2,8 +2,7 @@ import { apiFetch } from "./api";
2
2
  import {
3
3
  ApiRequestError,
4
4
  getAccountId,
5
- getApiUrl,
6
- getAuthToken
5
+ getApiUrl
7
6
  } from "./api/core";
8
7
  const BASE = "/platform-admin/v1/compliance/cases";
9
8
  const caseUrl = (tenantId) => `${BASE}/${encodeURIComponent(tenantId)}`;
@@ -12,9 +11,7 @@ const photoPath = (tenantId, personId, kind) => kind === "selfie" ? `${personUrl
12
11
  async function fetchImage(path) {
13
12
  const headers = {};
14
13
  const account = getAccountId();
15
- const token = getAuthToken();
16
14
  if (account) headers["x-account-id"] = account;
17
- if (token) headers.Authorization = `Bearer ${token}`;
18
15
  const res = await fetch(`${getApiUrl()}${path}`, {
19
16
  headers,
20
17
  credentials: "include",
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/admin-case-api.ts"],"sourcesContent":["import { apiFetch } from './api';\nimport type {\n CaseList,\n CaseStatus,\n DecisionBody,\n DocumentLink,\n VerificationCase,\n} from './admin-case-types';\nimport {\n ApiRequestError,\n getAccountId,\n getApiUrl,\n getAuthToken,\n} from './api/core';\n\n/**\n * One business's wallet verification case, as a platform admin reviews it.\n * Same `apiFetch` and cookie session as the rest of the console; the routes\n * answer with the record itself, no envelope. Shapes: `admin-case-types.ts`.\n */\n\nexport type PhotoKind = 'selfie' | 'bvn' | 'nin';\n\nconst BASE = '/platform-admin/v1/compliance/cases';\n\nconst caseUrl = (tenantId: string) => `${BASE}/${encodeURIComponent(tenantId)}`;\n\nconst personUrl = (tenantId: string, personId: string) =>\n `${caseUrl(tenantId)}/people/${encodeURIComponent(personId)}`;\n\nexport const photoPath = (\n tenantId: string,\n personId: string,\n kind: PhotoKind,\n) =>\n kind === 'selfie'\n ? `${personUrl(tenantId, personId)}/photo`\n : `${personUrl(tenantId, personId)}/reference-photo?source=${kind}`;\n\n/**\n * Image bytes, not JSON, so not `apiFetch`: the same proxy, cookies and\n * headers, read as a blob. Never cached; the caller makes the object URL.\n */\nasync function fetchImage(path: string): Promise<Blob> {\n const headers: Record<string, string> = {};\n const account = getAccountId();\n const token = getAuthToken();\n\n if (account) headers['x-account-id'] = account;\n\n if (token) headers.Authorization = `Bearer ${token}`;\n\n const res = await fetch(`${getApiUrl()}${path}`, {\n headers,\n credentials: 'include',\n cache: 'no-store',\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => null);\n\n throw new ApiRequestError(\n body?.message || `HTTP ${res.status}`,\n res.status,\n body,\n );\n }\n\n return res.blob();\n}\n\nconst post = <T>(path: string, body: unknown) =>\n apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) });\n\nexport const adminCaseApi = {\n /** 404 when the business has not started verification. */\n get: (tenantId: string) => apiFetch<VerificationCase>(caseUrl(tenantId)),\n\n /** Oldest wait first. */\n list: (q: { status?: CaseStatus; page?: number; limit?: number } = {}) => {\n const params = new URLSearchParams();\n\n if (q.status) params.set('status', q.status);\n\n if (q.page) params.set('page', String(q.page));\n\n if (q.limit) params.set('limit', String(q.limit));\n\n const qs = params.toString();\n\n return apiFetch<CaseList>(qs ? `${BASE}?${qs}` : BASE);\n },\n\n /** `total` is all the queue needs; one row keeps the answer small. */\n waitingCount: () =>\n apiFetch<{ total: number }>(`${BASE}?status=WAITING_REVIEW&limit=1`),\n\n photo: (tenantId: string, personId: string, kind: PhotoKind) =>\n fetchImage(photoPath(tenantId, personId, kind)),\n\n reveal: (tenantId: string, personId: string, field: 'bvn' | 'nin') =>\n post<{ value: string }>(`${personUrl(tenantId, personId)}/reveal`, {\n field,\n }),\n\n /** A short-lived link to an ID photo or certificate; 502 when it could not be opened. */\n viewDocument: (tenantId: string, documentId: string) =>\n post<DocumentLink>(\n `${caseUrl(tenantId)}/documents/${encodeURIComponent(documentId)}/view`,\n {},\n ),\n\n /** 409 when the case is not waiting for review. */\n decide: (tenantId: string, body: DecisionBody) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/decision`, body),\n\n /** 403 for the admin who decided; 409 when there is nothing to reverse. */\n reverse: (tenantId: string, note: string) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/reverse`, { note }),\n};\n"],"mappings":"AAAA,SAAS,gBAAgB;AAQzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,MAAM,OAAO;AAEb,MAAM,UAAU,CAAC,aAAqB,GAAG,IAAI,IAAI,mBAAmB,QAAQ,CAAC;AAE7E,MAAM,YAAY,CAAC,UAAkB,aACnC,GAAG,QAAQ,QAAQ,CAAC,WAAW,mBAAmB,QAAQ,CAAC;AAEtD,MAAM,YAAY,CACvB,UACA,UACA,SAEA,SAAS,WACL,GAAG,UAAU,UAAU,QAAQ,CAAC,WAChC,GAAG,UAAU,UAAU,QAAQ,CAAC,2BAA2B,IAAI;AAMrE,eAAe,WAAW,MAA6B;AACrD,QAAM,UAAkC,CAAC;AACzC,QAAM,UAAU,aAAa;AAC7B,QAAM,QAAQ,aAAa;AAE3B,MAAI,QAAS,SAAQ,cAAc,IAAI;AAEvC,MAAI,MAAO,SAAQ,gBAAgB,UAAU,KAAK;AAElD,QAAM,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,IAAI,IAAI;AAAA,IAC/C;AAAA,IACA,aAAa;AAAA,IACb,OAAO;AAAA,EACT,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAE9C,UAAM,IAAI;AAAA,MACR,MAAM,WAAW,QAAQ,IAAI,MAAM;AAAA,MACnC,IAAI;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AAEA,SAAO,IAAI,KAAK;AAClB;AAEA,MAAM,OAAO,CAAI,MAAc,SAC7B,SAAY,MAAM,EAAE,QAAQ,QAAQ,MAAM,KAAK,UAAU,IAAI,EAAE,CAAC;AAE3D,MAAM,eAAe;AAAA;AAAA,EAE1B,KAAK,CAAC,aAAqB,SAA2B,QAAQ,QAAQ,CAAC;AAAA;AAAA,EAGvE,MAAM,CAAC,IAA4D,CAAC,MAAM;AACxE,UAAM,SAAS,IAAI,gBAAgB;AAEnC,QAAI,EAAE,OAAQ,QAAO,IAAI,UAAU,EAAE,MAAM;AAE3C,QAAI,EAAE,KAAM,QAAO,IAAI,QAAQ,OAAO,EAAE,IAAI,CAAC;AAE7C,QAAI,EAAE,MAAO,QAAO,IAAI,SAAS,OAAO,EAAE,KAAK,CAAC;AAEhD,UAAM,KAAK,OAAO,SAAS;AAE3B,WAAO,SAAmB,KAAK,GAAG,IAAI,IAAI,EAAE,KAAK,IAAI;AAAA,EACvD;AAAA;AAAA,EAGA,cAAc,MACZ,SAA4B,GAAG,IAAI,gCAAgC;AAAA,EAErE,OAAO,CAAC,UAAkB,UAAkB,SAC1C,WAAW,UAAU,UAAU,UAAU,IAAI,CAAC;AAAA,EAEhD,QAAQ,CAAC,UAAkB,UAAkB,UAC3C,KAAwB,GAAG,UAAU,UAAU,QAAQ,CAAC,WAAW;AAAA,IACjE;AAAA,EACF,CAAC;AAAA;AAAA,EAGH,cAAc,CAAC,UAAkB,eAC/B;AAAA,IACE,GAAG,QAAQ,QAAQ,CAAC,cAAc,mBAAmB,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH;AAAA;AAAA,EAGF,QAAQ,CAAC,UAAkB,SACzB,KAAuB,GAAG,QAAQ,QAAQ,CAAC,aAAa,IAAI;AAAA;AAAA,EAG9D,SAAS,CAAC,UAAkB,SAC1B,KAAuB,GAAG,QAAQ,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC;AACnE;","names":[]}
1
+ {"version":3,"sources":["../src/admin-case-api.ts"],"sourcesContent":["import { apiFetch } from './api';\nimport type {\n CaseList,\n CaseStatus,\n DecisionBody,\n DocumentLink,\n VerificationCase,\n} from './admin-case-types';\nimport {\n ApiRequestError,\n getAccountId,\n getApiUrl,\n} from './api/core';\n\n/**\n * One business's wallet verification case, as a platform admin reviews it.\n * Same `apiFetch` and cookie session as the rest of the console; the routes\n * answer with the record itself, no envelope. Shapes: `admin-case-types.ts`.\n */\n\nexport type PhotoKind = 'selfie' | 'bvn' | 'nin';\n\nconst BASE = '/platform-admin/v1/compliance/cases';\n\nconst caseUrl = (tenantId: string) => `${BASE}/${encodeURIComponent(tenantId)}`;\n\nconst personUrl = (tenantId: string, personId: string) =>\n `${caseUrl(tenantId)}/people/${encodeURIComponent(personId)}`;\n\nexport const photoPath = (\n tenantId: string,\n personId: string,\n kind: PhotoKind,\n) =>\n kind === 'selfie'\n ? `${personUrl(tenantId, personId)}/photo`\n : `${personUrl(tenantId, personId)}/reference-photo?source=${kind}`;\n\n/**\n * Image bytes, not JSON, so not `apiFetch`: the same proxy, cookies and\n * headers, read as a blob. Never cached; the caller makes the object URL.\n */\nasync function fetchImage(path: string): Promise<Blob> {\n const headers: Record<string, string> = {};\n const account = getAccountId();\n\n if (account) headers['x-account-id'] = account;\n\n const res = await fetch(`${getApiUrl()}${path}`, {\n headers,\n credentials: 'include',\n cache: 'no-store',\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => null);\n\n throw new ApiRequestError(\n body?.message || `HTTP ${res.status}`,\n res.status,\n body,\n );\n }\n\n return res.blob();\n}\n\nconst post = <T>(path: string, body: unknown) =>\n apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) });\n\nexport const adminCaseApi = {\n /** 404 when the business has not started verification. */\n get: (tenantId: string) => apiFetch<VerificationCase>(caseUrl(tenantId)),\n\n /** Oldest wait first. */\n list: (q: { status?: CaseStatus; page?: number; limit?: number } = {}) => {\n const params = new URLSearchParams();\n\n if (q.status) params.set('status', q.status);\n\n if (q.page) params.set('page', String(q.page));\n\n if (q.limit) params.set('limit', String(q.limit));\n\n const qs = params.toString();\n\n return apiFetch<CaseList>(qs ? `${BASE}?${qs}` : BASE);\n },\n\n /** `total` is all the queue needs; one row keeps the answer small. */\n waitingCount: () =>\n apiFetch<{ total: number }>(`${BASE}?status=WAITING_REVIEW&limit=1`),\n\n photo: (tenantId: string, personId: string, kind: PhotoKind) =>\n fetchImage(photoPath(tenantId, personId, kind)),\n\n reveal: (tenantId: string, personId: string, field: 'bvn' | 'nin') =>\n post<{ value: string }>(`${personUrl(tenantId, personId)}/reveal`, {\n field,\n }),\n\n /** A short-lived link to an ID photo or certificate; 502 when it could not be opened. */\n viewDocument: (tenantId: string, documentId: string) =>\n post<DocumentLink>(\n `${caseUrl(tenantId)}/documents/${encodeURIComponent(documentId)}/view`,\n {},\n ),\n\n /** 409 when the case is not waiting for review. */\n decide: (tenantId: string, body: DecisionBody) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/decision`, body),\n\n /** 403 for the admin who decided; 409 when there is nothing to reverse. */\n reverse: (tenantId: string, note: string) =>\n post<VerificationCase>(`${caseUrl(tenantId)}/reverse`, { note }),\n};\n"],"mappings":"AAAA,SAAS,gBAAgB;AAQzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,MAAM,OAAO;AAEb,MAAM,UAAU,CAAC,aAAqB,GAAG,IAAI,IAAI,mBAAmB,QAAQ,CAAC;AAE7E,MAAM,YAAY,CAAC,UAAkB,aACnC,GAAG,QAAQ,QAAQ,CAAC,WAAW,mBAAmB,QAAQ,CAAC;AAEtD,MAAM,YAAY,CACvB,UACA,UACA,SAEA,SAAS,WACL,GAAG,UAAU,UAAU,QAAQ,CAAC,WAChC,GAAG,UAAU,UAAU,QAAQ,CAAC,2BAA2B,IAAI;AAMrE,eAAe,WAAW,MAA6B;AACrD,QAAM,UAAkC,CAAC;AACzC,QAAM,UAAU,aAAa;AAE7B,MAAI,QAAS,SAAQ,cAAc,IAAI;AAEvC,QAAM,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,IAAI,IAAI;AAAA,IAC/C;AAAA,IACA,aAAa;AAAA,IACb,OAAO;AAAA,EACT,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAE9C,UAAM,IAAI;AAAA,MACR,MAAM,WAAW,QAAQ,IAAI,MAAM;AAAA,MACnC,IAAI;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AAEA,SAAO,IAAI,KAAK;AAClB;AAEA,MAAM,OAAO,CAAI,MAAc,SAC7B,SAAY,MAAM,EAAE,QAAQ,QAAQ,MAAM,KAAK,UAAU,IAAI,EAAE,CAAC;AAE3D,MAAM,eAAe;AAAA;AAAA,EAE1B,KAAK,CAAC,aAAqB,SAA2B,QAAQ,QAAQ,CAAC;AAAA;AAAA,EAGvE,MAAM,CAAC,IAA4D,CAAC,MAAM;AACxE,UAAM,SAAS,IAAI,gBAAgB;AAEnC,QAAI,EAAE,OAAQ,QAAO,IAAI,UAAU,EAAE,MAAM;AAE3C,QAAI,EAAE,KAAM,QAAO,IAAI,QAAQ,OAAO,EAAE,IAAI,CAAC;AAE7C,QAAI,EAAE,MAAO,QAAO,IAAI,SAAS,OAAO,EAAE,KAAK,CAAC;AAEhD,UAAM,KAAK,OAAO,SAAS;AAE3B,WAAO,SAAmB,KAAK,GAAG,IAAI,IAAI,EAAE,KAAK,IAAI;AAAA,EACvD;AAAA;AAAA,EAGA,cAAc,MACZ,SAA4B,GAAG,IAAI,gCAAgC;AAAA,EAErE,OAAO,CAAC,UAAkB,UAAkB,SAC1C,WAAW,UAAU,UAAU,UAAU,IAAI,CAAC;AAAA,EAEhD,QAAQ,CAAC,UAAkB,UAAkB,UAC3C,KAAwB,GAAG,UAAU,UAAU,QAAQ,CAAC,WAAW;AAAA,IACjE;AAAA,EACF,CAAC;AAAA;AAAA,EAGH,cAAc,CAAC,UAAkB,eAC/B;AAAA,IACE,GAAG,QAAQ,QAAQ,CAAC,cAAc,mBAAmB,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH;AAAA;AAAA,EAGF,QAAQ,CAAC,UAAkB,SACzB,KAAuB,GAAG,QAAQ,QAAQ,CAAC,aAAa,IAAI;AAAA;AAAA,EAG9D,SAAS,CAAC,UAAkB,SAC1B,KAAuB,GAAG,QAAQ,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC;AACnE;","names":[]}
@@ -10,9 +10,9 @@ export declare const accountsApi: {
10
10
  password: string;
11
11
  }) => Promise<AuthSessionPayload>;
12
12
  /**
13
- * Get current account (requires auth token)
13
+ * Get current account (the session cookie, or an explicit token)
14
14
  */
15
- getMe: (token: string) => Promise<{
15
+ getMe: (token?: string) => Promise<{
16
16
  id: string;
17
17
  email: string;
18
18
  name: string;
@@ -54,7 +54,7 @@ export declare const accountsApi: {
54
54
  /**
55
55
  * List organizations I belong to
56
56
  */
57
- getMyOrganizations: (token: string) => Promise<{
57
+ getMyOrganizations: (token?: string) => Promise<{
58
58
  id: string;
59
59
  name: string;
60
60
  slug: string;
@@ -12,7 +12,7 @@ const accountsApi = {
12
12
  body: JSON.stringify(data)
13
13
  }),
14
14
  /**
15
- * Get current account (requires auth token)
15
+ * Get current account (the session cookie, or an explicit token)
16
16
  */
17
17
  getMe: (token) => apiFetch("/accounts/me", { token }),
18
18
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/accounts.ts"],"sourcesContent":["import { apiFetch } from './core';\nimport type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';\nimport type {\n SignupInput,\n SignupResult,\n UpdateAccountInput,\n ChangePasswordInput,\n ChangeEmailInput,\n AuthSessionPayload,\n GoogleSignInCallbackResult,\n GoogleLinkInput,\n AccountConsent,\n ConsentInput,\n MfaSetupResult,\n MfaVerifyResult,\n SendLoginCodeResult,\n VerifyLoginCodeResult,\n Passkey,\n BeginPasskeyLoginResult,\n PasskeyLoginResult,\n} from './types';\n\nexport const accountsApi = {\n /**\n * Create a new account (signup)\n */\n signup: (data: SignupInput) =>\n apiFetch<SignupResult>('/accounts', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n registerTeamInvite: (\n token: string,\n data: { name: string; password: string },\n ) =>\n apiFetch<AuthSessionPayload>(`/accounts/team-invites/${token}/register`, {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n /**\n * Get current account (requires auth token)\n */\n getMe: (token: string) =>\n apiFetch<SignupResult['account']>('/accounts/me', { token }),\n\n /**\n * Update current account profile\n */\n updateMe: (data: UpdateAccountInput) =>\n apiFetch<SignupResult['account']>('/accounts/me', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change password\n */\n changePassword: (data: ChangePasswordInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/password', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change email address\n */\n changeEmail: (data: ChangeEmailInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/email', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Delete account\n */\n deleteAccount: () =>\n apiFetch<{ success: boolean }>('/accounts/me', {\n method: 'DELETE',\n }),\n\n /**\n * List organizations I belong to\n */\n getMyOrganizations: (token: string) =>\n apiFetch<{ id: string; name: string; slug: string }[]>(\n '/accounts/me/organizations',\n { token },\n ),\n\n /**\n * Verify email address using token from verification email\n */\n verifyEmail: (token: string) =>\n apiFetch<{ message: string }>('/accounts/verify-email', {\n method: 'POST',\n body: JSON.stringify({ token }),\n }),\n\n /**\n * Resend verification email to the current account\n */\n resendVerification: () =>\n apiFetch<{ message: string }>('/accounts/resend-verification', {\n method: 'POST',\n }),\n\n forgotPassword: (email: string) =>\n apiFetch<{ message: string }>('/accounts/forgot-password', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n resetPassword: (token: string, newPassword: string) =>\n apiFetch<{ message: string }>('/accounts/reset-password', {\n method: 'POST',\n body: JSON.stringify({ token, newPassword }),\n }),\n\n /**\n * Get the Google OAuth consent URL. Caller navigates to `url`; Google\n * redirects back to the configured callback with `?code=&state=`.\n */\n googleAuthUrl: () =>\n apiFetch<{ url: string; state: string }>('/accounts/google/url'),\n\n /**\n * Exchange the OAuth code for an account + session. Returns one of:\n * - `signed-in` : existing google account, session issued\n * - `created` : brand-new account, session issued\n * - `collision` : email matches a password account; prompt for password\n * then call `googleLink` with `collision.accountId`.\n */\n googleCallback: (input: { code: string; state: string }) =>\n apiFetch<GoogleSignInCallbackResult>('/accounts/google/callback', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Confirm password ownership on collision and link Google to the account.\n * Returns a normal signed-in session payload.\n */\n googleLink: (input: GoogleLinkInput) =>\n apiFetch<AuthSessionPayload>('/accounts/google/link', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Passwordless: send a one-time login code to the email. Always resolves the\n * same way whether or not an account exists (no enumeration).\n */\n sendLoginCode: (email: string) =>\n apiFetch<SendLoginCodeResult>('/accounts/code/send', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n /**\n * Verify the code. On success the account is signed in (created first if it\n * didn't exist), and the same session payload as login is returned.\n * `isNewAccount` tells the client whether to route into onboarding.\n */\n verifyLoginCode: (input: { email: string; code: string }) =>\n apiFetch<VerifyLoginCodeResult>('/accounts/code/verify', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Communication & privacy consent ────────────────────────────────\n /** Current consent preferences for the signed-in account. */\n getConsent: () => apiFetch<AccountConsent>('/accounts/me/consent'),\n\n /** Patch one or more consent switches. Server merges + re-stamps. */\n updateConsent: (data: Partial<ConsentInput>) =>\n apiFetch<AccountConsent>('/accounts/me/consent', {\n method: 'PATCH',\n body: JSON.stringify(data),\n }),\n\n /**\n * The second factor, for a sign-in that cleared its first.\n *\n * Takes a live authenticator code or one of the ten backup codes — those had\n * never been usable before, because nothing ever asked.\n */\n completeMfaChallenge: (input: { mfaToken: string; code: string }) =>\n apiFetch<AuthSessionPayload>('/accounts/mfa/challenge', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Passkeys ───────────────────────────────────────────────────────\n //\n // Enrolment is authenticated, signing in is not. You prove who you are with\n // an emailed code once, then attach a faster way back in from the device in\n // your hand.\n\n /** Options for creating a passkey. Requires a signed-in session. */\n beginPasskeyRegistration: () =>\n apiFetch<PublicKeyCredentialCreationOptionsJSON>(\n '/accounts/passkeys/register/begin',\n { method: 'POST' },\n ),\n\n /** Hand the created credential back to be verified and kept. */\n finishPasskeyRegistration: (input: { response: unknown; name?: string }) =>\n apiFetch<Passkey>('/accounts/passkeys/register/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Start a passkey sign-in.\n *\n * `email` is optional and usually omitted: without it the browser offers\n * whatever it holds for this site, which is how the login gets to be one tap\n * with nothing typed. Passing it narrows the prompt to one account, which is\n * what the Welcome back card wants.\n */\n beginPasskeyLogin: (email?: string) =>\n apiFetch<BeginPasskeyLoginResult>('/accounts/passkeys/login/begin', {\n method: 'POST',\n body: JSON.stringify(email ? { email } : {}),\n }),\n\n finishPasskeyLogin: (input: { handle: string; response: unknown }) =>\n apiFetch<PasskeyLoginResult>('/accounts/passkeys/login/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n listPasskeys: () => apiFetch<Passkey[]>('/accounts/passkeys'),\n\n renamePasskey: (id: string, name: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'PATCH',\n body: JSON.stringify({ name }),\n }),\n\n removePasskey: (id: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'DELETE',\n }),\n\n // ── Two-factor authentication (TOTP) ───────────────────────────────\n /**\n * Begin enrolment. Stores an encrypted secret with mfaEnabled=false and\n * returns the QR + secret to display. Not active until `verifyMfa` succeeds.\n */\n setupMfa: () =>\n apiFetch<MfaSetupResult>('/accounts/me/mfa/setup', { method: 'POST' }),\n\n /**\n * Confirm the first code to switch 2FA on. Returns the one-time backup\n * codes — shown ONCE; only their hashes are stored server-side.\n */\n verifyMfa: (code: string) =>\n apiFetch<MfaVerifyResult>('/accounts/me/mfa/verify', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n\n /** Turn 2FA off. Requires a valid current code (or a backup code). */\n disableMfa: (code: string) =>\n apiFetch<{ enabled: false }>('/accounts/me/mfa/disable', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n};\n\n/** Per-account communication + analytics consent. */\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/accounts.ts"],"sourcesContent":["import { apiFetch } from './core';\nimport type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';\nimport type {\n SignupInput,\n SignupResult,\n UpdateAccountInput,\n ChangePasswordInput,\n ChangeEmailInput,\n AuthSessionPayload,\n GoogleSignInCallbackResult,\n GoogleLinkInput,\n AccountConsent,\n ConsentInput,\n MfaSetupResult,\n MfaVerifyResult,\n SendLoginCodeResult,\n VerifyLoginCodeResult,\n Passkey,\n BeginPasskeyLoginResult,\n PasskeyLoginResult,\n} from './types';\n\nexport const accountsApi = {\n /**\n * Create a new account (signup)\n */\n signup: (data: SignupInput) =>\n apiFetch<SignupResult>('/accounts', {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n registerTeamInvite: (\n token: string,\n data: { name: string; password: string },\n ) =>\n apiFetch<AuthSessionPayload>(`/accounts/team-invites/${token}/register`, {\n method: 'POST',\n body: JSON.stringify(data),\n }),\n\n /**\n * Get current account (the session cookie, or an explicit token)\n */\n getMe: (token?: string) =>\n apiFetch<SignupResult['account']>('/accounts/me', { token }),\n\n /**\n * Update current account profile\n */\n updateMe: (data: UpdateAccountInput) =>\n apiFetch<SignupResult['account']>('/accounts/me', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change password\n */\n changePassword: (data: ChangePasswordInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/password', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Change email address\n */\n changeEmail: (data: ChangeEmailInput) =>\n apiFetch<{ success: boolean }>('/accounts/me/email', {\n method: 'PUT',\n body: JSON.stringify(data),\n }),\n\n /**\n * Delete account\n */\n deleteAccount: () =>\n apiFetch<{ success: boolean }>('/accounts/me', {\n method: 'DELETE',\n }),\n\n /**\n * List organizations I belong to\n */\n getMyOrganizations: (token?: string) =>\n apiFetch<{ id: string; name: string; slug: string }[]>(\n '/accounts/me/organizations',\n { token },\n ),\n\n /**\n * Verify email address using token from verification email\n */\n verifyEmail: (token: string) =>\n apiFetch<{ message: string }>('/accounts/verify-email', {\n method: 'POST',\n body: JSON.stringify({ token }),\n }),\n\n /**\n * Resend verification email to the current account\n */\n resendVerification: () =>\n apiFetch<{ message: string }>('/accounts/resend-verification', {\n method: 'POST',\n }),\n\n forgotPassword: (email: string) =>\n apiFetch<{ message: string }>('/accounts/forgot-password', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n resetPassword: (token: string, newPassword: string) =>\n apiFetch<{ message: string }>('/accounts/reset-password', {\n method: 'POST',\n body: JSON.stringify({ token, newPassword }),\n }),\n\n /**\n * Get the Google OAuth consent URL. Caller navigates to `url`; Google\n * redirects back to the configured callback with `?code=&state=`.\n */\n googleAuthUrl: () =>\n apiFetch<{ url: string; state: string }>('/accounts/google/url'),\n\n /**\n * Exchange the OAuth code for an account + session. Returns one of:\n * - `signed-in` : existing google account, session issued\n * - `created` : brand-new account, session issued\n * - `collision` : email matches a password account; prompt for password\n * then call `googleLink` with `collision.accountId`.\n */\n googleCallback: (input: { code: string; state: string }) =>\n apiFetch<GoogleSignInCallbackResult>('/accounts/google/callback', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Confirm password ownership on collision and link Google to the account.\n * Returns a normal signed-in session payload.\n */\n googleLink: (input: GoogleLinkInput) =>\n apiFetch<AuthSessionPayload>('/accounts/google/link', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Passwordless: send a one-time login code to the email. Always resolves the\n * same way whether or not an account exists (no enumeration).\n */\n sendLoginCode: (email: string) =>\n apiFetch<SendLoginCodeResult>('/accounts/code/send', {\n method: 'POST',\n body: JSON.stringify({ email }),\n }),\n\n /**\n * Verify the code. On success the account is signed in (created first if it\n * didn't exist), and the same session payload as login is returned.\n * `isNewAccount` tells the client whether to route into onboarding.\n */\n verifyLoginCode: (input: { email: string; code: string }) =>\n apiFetch<VerifyLoginCodeResult>('/accounts/code/verify', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Communication & privacy consent ────────────────────────────────\n /** Current consent preferences for the signed-in account. */\n getConsent: () => apiFetch<AccountConsent>('/accounts/me/consent'),\n\n /** Patch one or more consent switches. Server merges + re-stamps. */\n updateConsent: (data: Partial<ConsentInput>) =>\n apiFetch<AccountConsent>('/accounts/me/consent', {\n method: 'PATCH',\n body: JSON.stringify(data),\n }),\n\n /**\n * The second factor, for a sign-in that cleared its first.\n *\n * Takes a live authenticator code or one of the ten backup codes — those had\n * never been usable before, because nothing ever asked.\n */\n completeMfaChallenge: (input: { mfaToken: string; code: string }) =>\n apiFetch<AuthSessionPayload>('/accounts/mfa/challenge', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n // ── Passkeys ───────────────────────────────────────────────────────\n //\n // Enrolment is authenticated, signing in is not. You prove who you are with\n // an emailed code once, then attach a faster way back in from the device in\n // your hand.\n\n /** Options for creating a passkey. Requires a signed-in session. */\n beginPasskeyRegistration: () =>\n apiFetch<PublicKeyCredentialCreationOptionsJSON>(\n '/accounts/passkeys/register/begin',\n { method: 'POST' },\n ),\n\n /** Hand the created credential back to be verified and kept. */\n finishPasskeyRegistration: (input: { response: unknown; name?: string }) =>\n apiFetch<Passkey>('/accounts/passkeys/register/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n /**\n * Start a passkey sign-in.\n *\n * `email` is optional and usually omitted: without it the browser offers\n * whatever it holds for this site, which is how the login gets to be one tap\n * with nothing typed. Passing it narrows the prompt to one account, which is\n * what the Welcome back card wants.\n */\n beginPasskeyLogin: (email?: string) =>\n apiFetch<BeginPasskeyLoginResult>('/accounts/passkeys/login/begin', {\n method: 'POST',\n body: JSON.stringify(email ? { email } : {}),\n }),\n\n finishPasskeyLogin: (input: { handle: string; response: unknown }) =>\n apiFetch<PasskeyLoginResult>('/accounts/passkeys/login/finish', {\n method: 'POST',\n body: JSON.stringify(input),\n }),\n\n listPasskeys: () => apiFetch<Passkey[]>('/accounts/passkeys'),\n\n renamePasskey: (id: string, name: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'PATCH',\n body: JSON.stringify({ name }),\n }),\n\n removePasskey: (id: string) =>\n apiFetch<{ success: true }>(`/accounts/passkeys/${id}`, {\n method: 'DELETE',\n }),\n\n // ── Two-factor authentication (TOTP) ───────────────────────────────\n /**\n * Begin enrolment. Stores an encrypted secret with mfaEnabled=false and\n * returns the QR + secret to display. Not active until `verifyMfa` succeeds.\n */\n setupMfa: () =>\n apiFetch<MfaSetupResult>('/accounts/me/mfa/setup', { method: 'POST' }),\n\n /**\n * Confirm the first code to switch 2FA on. Returns the one-time backup\n * codes — shown ONCE; only their hashes are stored server-side.\n */\n verifyMfa: (code: string) =>\n apiFetch<MfaVerifyResult>('/accounts/me/mfa/verify', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n\n /** Turn 2FA off. Requires a valid current code (or a backup code). */\n disableMfa: (code: string) =>\n apiFetch<{ enabled: false }>('/accounts/me/mfa/disable', {\n method: 'POST',\n body: JSON.stringify({ code }),\n }),\n};\n\n/** Per-account communication + analytics consent. */\n"],"mappings":"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","names":[]}
@@ -1,4 +1,4 @@
1
- import { getApiUrl, getAccountId, getAuthToken, apiFetch } from "./core";
1
+ import { getApiUrl, getAccountId, apiFetch } from "./core";
2
2
  import { scopeParams } from "./analytics-patterns";
3
3
  const analyticsApi = {
4
4
  /**
@@ -365,8 +365,6 @@ const analyticsApi = {
365
365
  const headers = { "X-Tenant-Slug": tenantSlug };
366
366
  const accountId = getAccountId();
367
367
  if (accountId) headers["x-account-id"] = accountId;
368
- const token = getAuthToken();
369
- if (token) headers.Authorization = `Bearer ${token}`;
370
368
  const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {
371
369
  headers,
372
370
  credentials: "include"
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/analytics.ts"],"sourcesContent":["import { getApiUrl, getAccountId, getAuthToken, apiFetch } from './core';\nimport { scopeParams } from './analytics-patterns';\nimport type {\n CohortRow,\n SalesLedger,\n OrderReport,\n RevenueSplit,\n OverviewStats,\n SalesStats,\n TopContent,\n TopProduct,\n TrafficSource,\n DeviceStats,\n FunnelStats,\n BookingAnalytics,\n EventAnalytics,\n ProductPerformance,\n HomeSummary,\n OutstandingInvoices,\n PaymentsMix,\n Insight,\n MetricTree,\n Utilization,\n Rebooking,\n WinBack,\n LoyaltyEffect,\n DashboardStats,\n SearchTermStat,\n AttributionReport,\n AnalyticsQuery,\n RecentEvent,\n StockCover,\n BookingPerformance,\n Fulfilment,\n CollectionSpeed,\n DineIn,\n EventSales,\n Benchmarks,\n WeeklyDigest,\n} from './types';\n\nexport const analyticsApi = {\n /**\n * One event's ticketing: bookings, heads, revenue, check-in rate and the\n * split by ticket tier.\n *\n * The route has existed since event-tickets shipped and had no wrapper here\n * at all, so nine measurements the server computes on request reached no\n * screen. `contentId` is the event's Content row.\n */\n getEventAnalytics: (tenantSlug: string, contentId: string) =>\n apiFetch<EventAnalytics>(`/analytics/events/${contentId}`, { tenantSlug }),\n\n /**\n * What one item did, and where its buyers came from.\n *\n * The ordinary-product counterpart to `getEventAnalytics` above. Until this\n * existed the return leg of the loop was built for exactly one archetype: a\n * venue could see how a night went, and everybody else could open an item\n * they had created and find nothing that said what it earned.\n *\n * Its own request rather than a field on the product, because it is scoped\n * to a WINDOW and the product record is not — and because the item screen\n * should render its record immediately and let the money arrive after.\n */\n getProductPerformance: (\n tenantSlug: string,\n contentId: string,\n query: { startDate: string; endDate: string },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<ProductPerformance>(\n `/analytics/products/${contentId}?${params}`,\n { tenantSlug },\n );\n },\n\n /**\n * One measure, cut by one dimension, over a window.\n *\n * Grouped in the database. The view this replaces fetched 250 orders and\n * grouped them in the browser, so a busy quarter drew a chart from a slice.\n */\n getOrderReport: (\n tenantSlug: string,\n query: AnalyticsQuery & {\n dimension: string;\n measure: string;\n },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n dimension: query.dimension,\n measure: query.measure,\n ...(query.granularity && { granularity: query.granularity }),\n });\n return apiFetch<OrderReport>(`/analytics/order-report?${params}`, {\n tenantSlug,\n });\n },\n\n /** New against returning revenue, by first-paid-order. */\n getRevenueSplit: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<RevenueSplit>(`/analytics/revenue-split?${params}`, {\n tenantSlug,\n });\n },\n\n /** Gross to net over one window, from `orders` rather than from beacons. */\n getSalesLedger: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...scopeParams(query),\n });\n return apiFetch<SalesLedger>(`/analytics/sales-ledger?${params}`, {\n tenantSlug,\n });\n },\n\n getDashboard: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<DashboardStats>(`/analytics/dashboard?${params}`, {\n tenantSlug,\n });\n },\n\n getOverview: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<OverviewStats>(`/analytics/overview?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Retention by first-purchase month.\n *\n * No date range, deliberately: a cohort table reads backwards from today by\n * definition, and handing it the hub's period picker would let a merchant\n * ask for the six months ending last March, which is a different question\n * with the same shape. `months` is how many cohorts to draw, clamped 3 to 12\n * server-side because the grid is a triangle nobody can read at sixty.\n */\n getCohorts: (tenantSlug: string, months = 6) =>\n apiFetch<{ cohorts: CohortRow[] }>(`/analytics/cohorts?months=${months}`, {\n tenantSlug,\n }),\n\n getSales: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<SalesStats>(`/analytics/sales?${params}`, { tenantSlug });\n },\n\n /**\n * The last N events, newest first.\n *\n * The route has existed since analytics shipped and nothing in the dashboard\n * called it. `useLivePresence` does now: an event carries `sessionId` and\n * `createdAt`, so \"who is on the site this minute\" is a filter over this\n * payload rather than an endpoint of its own.\n *\n * `limit` matters more than it looks. The window is counted client-side, so\n * a busy storefront can have more events in five minutes than the limit\n * returns, and the count would silently cap. The caller detects that by\n * checking whether the OLDEST event returned is still inside the window.\n */\n getRecentEvents: (tenantSlug: string, limit = 60) =>\n apiFetch<RecentEvent[]>(`/analytics/recent-events?limit=${limit}`, {\n tenantSlug,\n }),\n\n getTopContent: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<TopContent[]>(`/analytics/top-content?${params}`, {\n tenantSlug,\n });\n },\n\n getTopProducts: (\n tenantSlug: string,\n query: AnalyticsQuery & { sortBy?: 'revenue' | 'units' },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n // Server-side, because it has to be the ORDER BY: re-sorting a\n // revenue-ranked page gives the units leaders AMONG the revenue leaders.\n ...(query.sortBy && { sortBy: query.sortBy }),\n });\n return apiFetch<TopProduct[]>(`/analytics/top-products?${params}`, {\n tenantSlug,\n });\n },\n\n getTrafficSources: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<TrafficSource[]>(`/analytics/traffic-sources?${params}`, {\n tenantSlug,\n });\n },\n\n getDevices: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DeviceStats[]>(`/analytics/devices?${params}`, {\n tenantSlug,\n });\n },\n\n // Not part of `/analytics/dashboard`. That payload is already twenty-odd\n // aggregates fetched on every view of the hub, and only the traffic report\n // asks this question.\n getSearchTerms: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n limit: String(query.limit ?? 20),\n });\n return apiFetch<SearchTermStat[]>(`/analytics/search-terms?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Revenue by where the orders came from.\n *\n * Its own request for the same reason search-terms is: `/analytics/dashboard`\n * is already twenty-odd aggregates on every view of the hub, and this is four\n * grouped queries only one screen asks for.\n *\n * `model` is sendable again, and this time the API whitelists it.\n *\n * It was dropped from this signature because the endpoint's DTO rejected it\n * outright — `property model should not exist` — so every request carrying\n * one 400d, which was every request, because the parameter defaulted. The\n * controller read it and Swagger documented the enum; only the DTO had never\n * heard of it.\n *\n * Sent only when a model is actually chosen, so the default request is\n * byte-identical to what a build without the fix expects. Callers must still\n * gate the CHOICE on `features.attributionModels` — an old API will 400 on\n * `first_touch`, and a control that errors is worse than no control.\n */\n getAttribution: (\n tenantSlug: string,\n query: AnalyticsQuery,\n model?: 'last_touch' | 'first_touch',\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(model ? { model } : {}),\n });\n return apiFetch<AttributionReport>(`/analytics/attribution?${params}`, {\n tenantSlug,\n });\n },\n\n getFunnel: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<FunnelStats>(`/analytics/funnel?${params}`, { tenantSlug });\n },\n\n getBookingAnalytics: (tenantSlug: string) => {\n return apiFetch<BookingAnalytics>(`/analytics/booking-stats`, {\n tenantSlug,\n });\n },\n\n /**\n * The home money band in one request.\n *\n * Replaces three: two full `/analytics/dashboard` payloads (current and\n * prior) plus an `/analytics/outstanding` route that does not exist on the\n * API and had been 404ing silently on every load. The band renders four\n * numbers and a series; this returns exactly that.\n */\n getHomeSummary: (\n tenantSlug: string,\n params: { startDate: string; endDate: string },\n ) =>\n apiFetch<HomeSummary>(\n `/analytics/home-summary?startDate=${params.startDate}&endDate=${params.endDate}`,\n { tenantSlug },\n ),\n\n /** When each tracked product runs out, and which are not moving. */\n getStockCover: (tenantSlug: string) =>\n apiFetch<StockCover>('/analytics/stock-cover', { tenantSlug }),\n\n /** No-show and cancellation rates, and by who was booked. */\n getBookingPerformance: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<BookingPerformance>(\n `/analytics/booking-performance?${params}`,\n { tenantSlug },\n );\n },\n\n /** Paid orders still to send, and how long sending takes. */\n getFulfilment: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Fulfilment>(`/analytics/fulfilment?${params}`, {\n tenantSlug,\n });\n },\n\n /** How many days invoices take to be paid. */\n getCollectionSpeed: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<CollectionSpeed>(`/analytics/collection-speed?${params}`, {\n tenantSlug,\n });\n },\n\n /** Tabs open now, average bill and how long tables are held. */\n getDineIn: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DineIn>(`/analytics/dine-in?${params}`, { tenantSlug });\n },\n\n /** Tickets by type for each ticketed event, and who checked in. */\n getEventSales: (tenantSlug: string) =>\n apiFetch<EventSales>('/analytics/event-sales', { tenantSlug }),\n\n /** How this business compares with similar ones. Aggregates only. */\n getBenchmarks: (tenantSlug: string) =>\n apiFetch<Benchmarks>('/analytics/benchmarks', { tenantSlug }),\n\n /** The last seven days in a few plain lines, ready to read or send. */\n getWeeklyDigest: (tenantSlug: string) =>\n apiFetch<WeeklyDigest>('/analytics/weekly-digest', { tenantSlug }),\n\n getOutstanding: (tenantSlug: string, limit?: number) => {\n const params = new URLSearchParams(limit ? { limit: String(limit) } : {});\n const qs = params.toString();\n return apiFetch<OutstandingInvoices>(\n `/analytics/outstanding${qs ? `?${qs}` : ''}`,\n { tenantSlug },\n );\n },\n\n getPaymentsMix: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<PaymentsMix>(`/analytics/payments-mix?${params}`, {\n tenantSlug,\n });\n },\n\n getInsights: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Insight[]>(`/analytics/insights?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * The prior window is not sent: the API derives the equally-long stretch\n * immediately before this one, so the comparison cannot drift between\n * callers.\n */\n getMetricTree: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<MetricTree>(`/analytics/metric-tree?${params}`, {\n tenantSlug,\n });\n },\n\n getLoyaltyEffect: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<LoyaltyEffect>(`/analytics/loyalty-effect?${params}`, {\n tenantSlug,\n });\n },\n\n getRebooking: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Rebooking>(`/analytics/rebooking?${params}`, {\n tenantSlug,\n });\n },\n\n getWinBack: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<WinBack>(`/analytics/win-back?${params}`, { tenantSlug });\n },\n\n getUtilization: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Utilization>(`/analytics/utilization?${params}`, {\n tenantSlug,\n });\n },\n\n // CSV, not JSON — so it bypasses apiFetch's JSON parsing and returns raw text.\n exportCsv: async (\n tenantSlug: string,\n query: AnalyticsQuery,\n ): Promise<string> => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n const headers: Record<string, string> = { 'X-Tenant-Slug': tenantSlug };\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n const token = getAuthToken();\n if (token) headers.Authorization = `Bearer ${token}`;\n\n const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {\n headers,\n credentials: 'include',\n });\n if (!res.ok) throw new Error(`Export failed (HTTP ${res.status})`);\n return res.text();\n },\n};\n\n// ============================================\n// ORDERS API\n// ============================================\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/analytics.ts"],"sourcesContent":["import { getApiUrl, getAccountId, apiFetch } from './core';\nimport { scopeParams } from './analytics-patterns';\nimport type {\n CohortRow,\n SalesLedger,\n OrderReport,\n RevenueSplit,\n OverviewStats,\n SalesStats,\n TopContent,\n TopProduct,\n TrafficSource,\n DeviceStats,\n FunnelStats,\n BookingAnalytics,\n EventAnalytics,\n ProductPerformance,\n HomeSummary,\n OutstandingInvoices,\n PaymentsMix,\n Insight,\n MetricTree,\n Utilization,\n Rebooking,\n WinBack,\n LoyaltyEffect,\n DashboardStats,\n SearchTermStat,\n AttributionReport,\n AnalyticsQuery,\n RecentEvent,\n StockCover,\n BookingPerformance,\n Fulfilment,\n CollectionSpeed,\n DineIn,\n EventSales,\n Benchmarks,\n WeeklyDigest,\n} from './types';\n\nexport const analyticsApi = {\n /**\n * One event's ticketing: bookings, heads, revenue, check-in rate and the\n * split by ticket tier.\n *\n * The route has existed since event-tickets shipped and had no wrapper here\n * at all, so nine measurements the server computes on request reached no\n * screen. `contentId` is the event's Content row.\n */\n getEventAnalytics: (tenantSlug: string, contentId: string) =>\n apiFetch<EventAnalytics>(`/analytics/events/${contentId}`, { tenantSlug }),\n\n /**\n * What one item did, and where its buyers came from.\n *\n * The ordinary-product counterpart to `getEventAnalytics` above. Until this\n * existed the return leg of the loop was built for exactly one archetype: a\n * venue could see how a night went, and everybody else could open an item\n * they had created and find nothing that said what it earned.\n *\n * Its own request rather than a field on the product, because it is scoped\n * to a WINDOW and the product record is not — and because the item screen\n * should render its record immediately and let the money arrive after.\n */\n getProductPerformance: (\n tenantSlug: string,\n contentId: string,\n query: { startDate: string; endDate: string },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<ProductPerformance>(\n `/analytics/products/${contentId}?${params}`,\n { tenantSlug },\n );\n },\n\n /**\n * One measure, cut by one dimension, over a window.\n *\n * Grouped in the database. The view this replaces fetched 250 orders and\n * grouped them in the browser, so a busy quarter drew a chart from a slice.\n */\n getOrderReport: (\n tenantSlug: string,\n query: AnalyticsQuery & {\n dimension: string;\n measure: string;\n },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n dimension: query.dimension,\n measure: query.measure,\n ...(query.granularity && { granularity: query.granularity }),\n });\n return apiFetch<OrderReport>(`/analytics/order-report?${params}`, {\n tenantSlug,\n });\n },\n\n /** New against returning revenue, by first-paid-order. */\n getRevenueSplit: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<RevenueSplit>(`/analytics/revenue-split?${params}`, {\n tenantSlug,\n });\n },\n\n /** Gross to net over one window, from `orders` rather than from beacons. */\n getSalesLedger: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...scopeParams(query),\n });\n return apiFetch<SalesLedger>(`/analytics/sales-ledger?${params}`, {\n tenantSlug,\n });\n },\n\n getDashboard: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.granularity && { granularity: query.granularity }),\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<DashboardStats>(`/analytics/dashboard?${params}`, {\n tenantSlug,\n });\n },\n\n getOverview: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<OverviewStats>(`/analytics/overview?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Retention by first-purchase month.\n *\n * No date range, deliberately: a cohort table reads backwards from today by\n * definition, and handing it the hub's period picker would let a merchant\n * ask for the six months ending last March, which is a different question\n * with the same shape. `months` is how many cohorts to draw, clamped 3 to 12\n * server-side because the grid is a triangle nobody can read at sixty.\n */\n getCohorts: (tenantSlug: string, months = 6) =>\n apiFetch<{ cohorts: CohortRow[] }>(`/analytics/cohorts?months=${months}`, {\n tenantSlug,\n }),\n\n getSales: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<SalesStats>(`/analytics/sales?${params}`, { tenantSlug });\n },\n\n /**\n * The last N events, newest first.\n *\n * The route has existed since analytics shipped and nothing in the dashboard\n * called it. `useLivePresence` does now: an event carries `sessionId` and\n * `createdAt`, so \"who is on the site this minute\" is a filter over this\n * payload rather than an endpoint of its own.\n *\n * `limit` matters more than it looks. The window is counted client-side, so\n * a busy storefront can have more events in five minutes than the limit\n * returns, and the count would silently cap. The caller detects that by\n * checking whether the OLDEST event returned is still inside the window.\n */\n getRecentEvents: (tenantSlug: string, limit = 60) =>\n apiFetch<RecentEvent[]>(`/analytics/recent-events?limit=${limit}`, {\n tenantSlug,\n }),\n\n getTopContent: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n });\n return apiFetch<TopContent[]>(`/analytics/top-content?${params}`, {\n tenantSlug,\n });\n },\n\n getTopProducts: (\n tenantSlug: string,\n query: AnalyticsQuery & { sortBy?: 'revenue' | 'units' },\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(query.limit && { limit: String(query.limit) }),\n // Server-side, because it has to be the ORDER BY: re-sorting a\n // revenue-ranked page gives the units leaders AMONG the revenue leaders.\n ...(query.sortBy && { sortBy: query.sortBy }),\n });\n return apiFetch<TopProduct[]>(`/analytics/top-products?${params}`, {\n tenantSlug,\n });\n },\n\n getTrafficSources: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<TrafficSource[]>(`/analytics/traffic-sources?${params}`, {\n tenantSlug,\n });\n },\n\n getDevices: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DeviceStats[]>(`/analytics/devices?${params}`, {\n tenantSlug,\n });\n },\n\n // Not part of `/analytics/dashboard`. That payload is already twenty-odd\n // aggregates fetched on every view of the hub, and only the traffic report\n // asks this question.\n getSearchTerms: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n limit: String(query.limit ?? 20),\n });\n return apiFetch<SearchTermStat[]>(`/analytics/search-terms?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * Revenue by where the orders came from.\n *\n * Its own request for the same reason search-terms is: `/analytics/dashboard`\n * is already twenty-odd aggregates on every view of the hub, and this is four\n * grouped queries only one screen asks for.\n *\n * `model` is sendable again, and this time the API whitelists it.\n *\n * It was dropped from this signature because the endpoint's DTO rejected it\n * outright — `property model should not exist` — so every request carrying\n * one 400d, which was every request, because the parameter defaulted. The\n * controller read it and Swagger documented the enum; only the DTO had never\n * heard of it.\n *\n * Sent only when a model is actually chosen, so the default request is\n * byte-identical to what a build without the fix expects. Callers must still\n * gate the CHOICE on `features.attributionModels` — an old API will 400 on\n * `first_touch`, and a control that errors is worse than no control.\n */\n getAttribution: (\n tenantSlug: string,\n query: AnalyticsQuery,\n model?: 'last_touch' | 'first_touch',\n ) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n ...(model ? { model } : {}),\n });\n return apiFetch<AttributionReport>(`/analytics/attribution?${params}`, {\n tenantSlug,\n });\n },\n\n getFunnel: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<FunnelStats>(`/analytics/funnel?${params}`, { tenantSlug });\n },\n\n getBookingAnalytics: (tenantSlug: string) => {\n return apiFetch<BookingAnalytics>(`/analytics/booking-stats`, {\n tenantSlug,\n });\n },\n\n /**\n * The home money band in one request.\n *\n * Replaces three: two full `/analytics/dashboard` payloads (current and\n * prior) plus an `/analytics/outstanding` route that does not exist on the\n * API and had been 404ing silently on every load. The band renders four\n * numbers and a series; this returns exactly that.\n */\n getHomeSummary: (\n tenantSlug: string,\n params: { startDate: string; endDate: string },\n ) =>\n apiFetch<HomeSummary>(\n `/analytics/home-summary?startDate=${params.startDate}&endDate=${params.endDate}`,\n { tenantSlug },\n ),\n\n /** When each tracked product runs out, and which are not moving. */\n getStockCover: (tenantSlug: string) =>\n apiFetch<StockCover>('/analytics/stock-cover', { tenantSlug }),\n\n /** No-show and cancellation rates, and by who was booked. */\n getBookingPerformance: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<BookingPerformance>(\n `/analytics/booking-performance?${params}`,\n { tenantSlug },\n );\n },\n\n /** Paid orders still to send, and how long sending takes. */\n getFulfilment: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Fulfilment>(`/analytics/fulfilment?${params}`, {\n tenantSlug,\n });\n },\n\n /** How many days invoices take to be paid. */\n getCollectionSpeed: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<CollectionSpeed>(`/analytics/collection-speed?${params}`, {\n tenantSlug,\n });\n },\n\n /** Tabs open now, average bill and how long tables are held. */\n getDineIn: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<DineIn>(`/analytics/dine-in?${params}`, { tenantSlug });\n },\n\n /** Tickets by type for each ticketed event, and who checked in. */\n getEventSales: (tenantSlug: string) =>\n apiFetch<EventSales>('/analytics/event-sales', { tenantSlug }),\n\n /** How this business compares with similar ones. Aggregates only. */\n getBenchmarks: (tenantSlug: string) =>\n apiFetch<Benchmarks>('/analytics/benchmarks', { tenantSlug }),\n\n /** The last seven days in a few plain lines, ready to read or send. */\n getWeeklyDigest: (tenantSlug: string) =>\n apiFetch<WeeklyDigest>('/analytics/weekly-digest', { tenantSlug }),\n\n getOutstanding: (tenantSlug: string, limit?: number) => {\n const params = new URLSearchParams(limit ? { limit: String(limit) } : {});\n const qs = params.toString();\n return apiFetch<OutstandingInvoices>(\n `/analytics/outstanding${qs ? `?${qs}` : ''}`,\n { tenantSlug },\n );\n },\n\n getPaymentsMix: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<PaymentsMix>(`/analytics/payments-mix?${params}`, {\n tenantSlug,\n });\n },\n\n getInsights: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Insight[]>(`/analytics/insights?${params}`, {\n tenantSlug,\n });\n },\n\n /**\n * The prior window is not sent: the API derives the equally-long stretch\n * immediately before this one, so the comparison cannot drift between\n * callers.\n */\n getMetricTree: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<MetricTree>(`/analytics/metric-tree?${params}`, {\n tenantSlug,\n });\n },\n\n getLoyaltyEffect: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<LoyaltyEffect>(`/analytics/loyalty-effect?${params}`, {\n tenantSlug,\n });\n },\n\n getRebooking: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Rebooking>(`/analytics/rebooking?${params}`, {\n tenantSlug,\n });\n },\n\n getWinBack: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<WinBack>(`/analytics/win-back?${params}`, { tenantSlug });\n },\n\n getUtilization: (tenantSlug: string, query: AnalyticsQuery) => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n return apiFetch<Utilization>(`/analytics/utilization?${params}`, {\n tenantSlug,\n });\n },\n\n // CSV, not JSON — so it bypasses apiFetch's JSON parsing and returns raw text.\n exportCsv: async (\n tenantSlug: string,\n query: AnalyticsQuery,\n ): Promise<string> => {\n const params = new URLSearchParams({\n startDate: query.startDate,\n endDate: query.endDate,\n });\n const headers: Record<string, string> = { 'X-Tenant-Slug': tenantSlug };\n const accountId = getAccountId();\n if (accountId) headers['x-account-id'] = accountId;\n\n const res = await fetch(`${getApiUrl()}/analytics/export?${params}`, {\n headers,\n credentials: 'include',\n });\n if (!res.ok) throw new Error(`Export failed (HTTP ${res.status})`);\n return res.text();\n },\n};\n\n// ============================================\n// ORDERS API\n// ============================================\n"],"mappings":"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","names":[]}
@@ -10,7 +10,6 @@ export declare const IS_BROWSER: boolean;
10
10
  import type { FetchOptions } from './types';
11
11
  export declare const getApiUrl: () => string;
12
12
  export declare function getAccountId(): string | null;
13
- export declare function getAuthToken(): string | null;
14
13
  export declare function getCsrfToken(): string | null;
15
14
  /**
16
15
  * Thrown when the API rejects the session (401). Typed so a caller can tell an
package/dist/api/core.js CHANGED
@@ -3,6 +3,7 @@ import posthog from "posthog-js";
3
3
  import { notifyWrite } from "./cache-bus";
4
4
  import { announceFeatureLocked, featureNotInPlan } from "./feature-locked";
5
5
  import { isUnservedRoute, unservedMessage } from "./unserved-routes";
6
+ import { refreshSession, wantsRefresh } from "./session-refresh";
6
7
  import {
7
8
  isSessionExpiryResponse,
8
9
  merchantIdentity,
@@ -27,10 +28,6 @@ function getAccountId() {
27
28
  }
28
29
  return null;
29
30
  }
30
- function getAuthToken() {
31
- if (typeof window === "undefined") return null;
32
- return localStorage.getItem("pb_token");
33
- }
34
31
  function getCsrfToken() {
35
32
  if (typeof window === "undefined" || typeof document === "undefined")
36
33
  return null;
@@ -90,7 +87,7 @@ async function apiFetch(endpoint, options = {}) {
90
87
  if (tenantSlug) {
91
88
  headers["X-Tenant-Slug"] = tenantSlug;
92
89
  }
93
- const merchant = merchantIdentity(endpoint, getAccountId(), getAuthToken());
90
+ const merchant = merchantIdentity(endpoint, getAccountId(), null);
94
91
  const effectiveAccountId = accountId || merchant.accountId;
95
92
  if (effectiveAccountId) {
96
93
  headers["x-account-id"] = effectiveAccountId;
@@ -130,7 +127,7 @@ async function apiFetch(endpoint, options = {}) {
130
127
  }
131
128
  return send(url, fetchOptions, headers, isWrite && !onlyReads(endpoint));
132
129
  }
133
- async function send(url, fetchOptions, headers, isWrite) {
130
+ async function send(url, fetchOptions, headers, isWrite, retried = false) {
134
131
  const res = await fetch(url, {
135
132
  ...fetchOptions,
136
133
  headers,
@@ -138,6 +135,12 @@ async function send(url, fetchOptions, headers, isWrite) {
138
135
  // Include cookies for CSRF
139
136
  });
140
137
  if (!res.ok) {
138
+ if (res.status === 401 && !retried && wantsRefresh(url)) {
139
+ const body = await res.clone().json().catch(() => null);
140
+ if (wantsRefresh(url, body) && await refreshSession(getApiUrl())) {
141
+ return send(url, fetchOptions, headers, isWrite, true);
142
+ }
143
+ }
141
144
  const lockedBody = res.status === 402 ? await res.clone().json().catch(() => null) : null;
142
145
  const locked = featureNotInPlan(lockedBody);
143
146
  if (locked) {
@@ -203,7 +206,6 @@ export {
203
206
  bouncedRecently,
204
207
  getAccountId,
205
208
  getApiUrl,
206
- getAuthToken,
207
209
  getCsrfToken,
208
210
  isSessionExpiryResponse2 as isSessionExpiryResponse,
209
211
  recordBounce
@@ -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 { 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"]}
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 { 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 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,4 +1,4 @@
1
- import { apiFetch, getAccountId, getAuthToken, getCsrfToken } from "./core";
1
+ import { apiFetch, getAccountId, 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,8 +29,6 @@ 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}`;
34
32
  const accountId = getAccountId();
35
33
  if (accountId) headers["x-account-id"] = accountId;
36
34
  const csrfToken = getCsrfToken();
@@ -38,6 +36,7 @@ async function askPotterStream(question, history, on, signal, tenantSlug) {
38
36
  const res = await fetch("/api/proxy/potter/ask/stream", {
39
37
  method: "POST",
40
38
  headers,
39
+ credentials: "include",
41
40
  body: JSON.stringify({ question, history }),
42
41
  signal
43
42
  });
@@ -1 +1 @@
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
+ {"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 credentials: 'include',\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,oBAAoB;AAuC9C,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,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,aAAa;AAAA,IACb,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":[]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Renewing the session when its short-lived access token has run out.
3
+ *
4
+ * The engine keeps the session in HttpOnly cookies: a 15-minute access token
5
+ * and a rotating refresh token. This code cannot read either; it only asks
6
+ * `POST /accounts/refresh` to swap them when a request comes back "expired".
7
+ */
8
+ /**
9
+ * Whether a 401 from `url` could be an expired access token. Called once with
10
+ * the URL alone (to skip the body read) and again with the body.
11
+ */
12
+ export declare function wantsRefresh(url: string, body?: unknown): boolean;
13
+ /**
14
+ * Ask the engine for a new session. Every request that failed together shares
15
+ * one call. False when it cannot be renewed (refused, or an engine without the
16
+ * route), so the caller falls back to ending the session.
17
+ */
18
+ export declare function refreshSession(apiBase: string): Promise<boolean>;
@@ -0,0 +1,37 @@
1
+ const REFRESH_PATH = "/accounts/refresh";
2
+ const NEVER_REFRESH = [REFRESH_PATH, "/accounts/login", "/accounts/logout"];
3
+ const ADMIN_API = "/platform-admin/";
4
+ const EXPIRED = /expired|no authentication token/i;
5
+ const IDLE = /session_idle/i;
6
+ let inFlight = null;
7
+ function wantsRefresh(url, body) {
8
+ const path = new URL(url, "http://localhost").pathname;
9
+ if (path.includes(ADMIN_API)) return false;
10
+ if (NEVER_REFRESH.some((never) => path.endsWith(never))) return false;
11
+ if (body === void 0) return true;
12
+ const { message, code } = body ?? {};
13
+ const text = [code, ...Array.isArray(message) ? message : [message]].filter((part) => typeof part === "string").join(" ");
14
+ return EXPIRED.test(text) && !IDLE.test(text);
15
+ }
16
+ function refreshSession(apiBase) {
17
+ inFlight ??= renew(apiBase).finally(() => {
18
+ inFlight = null;
19
+ });
20
+ return inFlight;
21
+ }
22
+ async function renew(apiBase) {
23
+ try {
24
+ const res = await fetch(`${apiBase}${REFRESH_PATH}`, {
25
+ method: "POST",
26
+ credentials: "include"
27
+ });
28
+ return res.ok;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ export {
34
+ refreshSession,
35
+ wantsRefresh
36
+ };
37
+ //# sourceMappingURL=session-refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/api/session-refresh.ts"],"sourcesContent":["/**\n * Renewing the session when its short-lived access token has run out.\n *\n * The engine keeps the session in HttpOnly cookies: a 15-minute access token\n * and a rotating refresh token. This code cannot read either; it only asks\n * `POST /accounts/refresh` to swap them when a request comes back \"expired\".\n */\n\nconst REFRESH_PATH = '/accounts/refresh';\n/** Calls whose 401 means something else (wrong details, nothing to end). */\nconst NEVER_REFRESH = [REFRESH_PATH, '/accounts/login', '/accounts/logout'];\nconst ADMIN_API = '/platform-admin/';\nconst EXPIRED = /expired|no authentication token/i;\nconst IDLE = /session_idle/i;\n\nlet inFlight: Promise<boolean> | null = null;\n\n/**\n * Whether a 401 from `url` could be an expired access token. Called once with\n * the URL alone (to skip the body read) and again with the body.\n */\nexport function wantsRefresh(url: string, body?: unknown): boolean {\n const path = new URL(url, 'http://localhost').pathname;\n if (path.includes(ADMIN_API)) return false;\n if (NEVER_REFRESH.some((never) => path.endsWith(never))) return false;\n if (body === undefined) return true;\n\n const { message, code } = (body ?? {}) as { message?: unknown; code?: unknown };\n const text = [code, ...(Array.isArray(message) ? message : [message])]\n .filter((part): part is string => typeof part === 'string')\n .join(' ');\n return EXPIRED.test(text) && !IDLE.test(text);\n}\n\n/**\n * Ask the engine for a new session. Every request that failed together shares\n * one call. False when it cannot be renewed (refused, or an engine without the\n * route), so the caller falls back to ending the session.\n */\nexport function refreshSession(apiBase: string): Promise<boolean> {\n inFlight ??= renew(apiBase).finally(() => {\n inFlight = null;\n });\n return inFlight;\n}\n\nasync function renew(apiBase: string): Promise<boolean> {\n try {\n const res = await fetch(`${apiBase}${REFRESH_PATH}`, {\n method: 'POST',\n credentials: 'include',\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n"],"mappings":"AAQA,MAAM,eAAe;AAErB,MAAM,gBAAgB,CAAC,cAAc,mBAAmB,kBAAkB;AAC1E,MAAM,YAAY;AAClB,MAAM,UAAU;AAChB,MAAM,OAAO;AAEb,IAAI,WAAoC;AAMjC,SAAS,aAAa,KAAa,MAAyB;AACjE,QAAM,OAAO,IAAI,IAAI,KAAK,kBAAkB,EAAE;AAC9C,MAAI,KAAK,SAAS,SAAS,EAAG,QAAO;AACrC,MAAI,cAAc,KAAK,CAAC,UAAU,KAAK,SAAS,KAAK,CAAC,EAAG,QAAO;AAChE,MAAI,SAAS,OAAW,QAAO;AAE/B,QAAM,EAAE,SAAS,KAAK,IAAK,QAAQ,CAAC;AACpC,QAAM,OAAO,CAAC,MAAM,GAAI,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,CAAE,EAClE,OAAO,CAAC,SAAyB,OAAO,SAAS,QAAQ,EACzD,KAAK,GAAG;AACX,SAAO,QAAQ,KAAK,IAAI,KAAK,CAAC,KAAK,KAAK,IAAI;AAC9C;AAOO,SAAS,eAAe,SAAmC;AAChE,eAAa,MAAM,OAAO,EAAE,QAAQ,MAAM;AACxC,eAAW;AAAA,EACb,CAAC;AACD,SAAO;AACT;AAEA,eAAe,MAAM,SAAmC;AACtD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,OAAO,GAAG,YAAY,IAAI;AAAA,MACnD,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","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 (`pb_token`, sent as a
5
- * bearer) and the platform admin (`pb_admin_token`, an HttpOnly cookie). They
4
+ * One console serves two sign-ins: the merchant dashboard (the HttpOnly `pb_auth_token`
5
+ * cookie) 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 (`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":[]}
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 (the HttpOnly `pb_auth_token`\n * cookie) 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":[]}
@@ -78,7 +78,8 @@ export interface AuthAccount {
78
78
  /** Successful session payload (Google sign-in `signed-in`/`created`, and login). */
79
79
  export interface AuthSessionPayload {
80
80
  account: AuthAccount;
81
- token: string;
81
+ /** Only a native client gets one; the browser session is a cookie. Never stored. */
82
+ token?: string;
82
83
  tenants: LoginTenantInfo[];
83
84
  organizationId: string | null;
84
85
  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 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":[]}
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 /** Only a native client gets one; the browser session is a cookie. Never stored. */\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":"AA8MO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
@@ -13,6 +13,7 @@ import {
13
13
  accountsApi,
14
14
  tenantApi
15
15
  } from "./api";
16
+ import { removeLegacyToken } from "./stored-token";
16
17
  import { resetPostHogIdentity } from "@withpotter/components/analytics";
17
18
  import { uploadImage } from "@withpotter/lib/media-service";
18
19
  import { endSession } from "@withpotter/lib/end-session";
@@ -224,32 +225,10 @@ function AuthProvider({ children }) {
224
225
  setLoading(false);
225
226
  return;
226
227
  }
227
- const token = localStorage.getItem("pb_token");
228
+ removeLegacyToken();
228
229
  const pbUser = localStorage.getItem("pb_user");
229
230
  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
- }
249
231
  if (!pbUser) {
250
- if (process.env.NODE_ENV === "development") {
251
- console.warn("No user data found, clearing auth state");
252
- }
253
232
  clearStoredAuth();
254
233
  setLoading(false);
255
234
  return;
@@ -289,9 +268,7 @@ function AuthProvider({ children }) {
289
268
  setLoading(false);
290
269
  }
291
270
  try {
292
- const freshAccount = await apiFetch("/accounts/me", {
293
- token
294
- });
271
+ const freshAccount = await apiFetch("/accounts/me");
295
272
  if (freshAccount) {
296
273
  userData = freshAccount;
297
274
  localStorage.setItem("pb_user", JSON.stringify(freshAccount));
@@ -340,7 +317,7 @@ function AuthProvider({ children }) {
340
317
  };
341
318
  }, [fetchTenant, loadWorkspaces]);
342
319
  const establishSession = async (data, method = "email", connection) => {
343
- const { account, token, tenants, organizationId } = data;
320
+ const { account, tenants, organizationId } = data;
344
321
  const workspaces = [];
345
322
  for (const t of tenants || []) {
346
323
  workspaces.push({
@@ -371,7 +348,6 @@ function AuthProvider({ children }) {
371
348
  }
372
349
  }
373
350
  const userOrgId = organizationId || tenants?.[0]?.organizationId || currentWorkspace?.tenant?.organizationId || null;
374
- localStorage.setItem("pb_token", token);
375
351
  localStorage.setItem("pb_account_id", account.id);
376
352
  localStorage.setItem("pb_user", JSON.stringify(account));
377
353
  localStorage.setItem("userEmail", account.email);
@@ -495,8 +471,7 @@ function AuthProvider({ children }) {
495
471
  const slug = input.slug || businessName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
496
472
  let organizationId = user?.organizationId || "";
497
473
  if (!organizationId) {
498
- const token = localStorage.getItem("pb_token") || "";
499
- const orgs = await accountsApi.getMyOrganizations(token);
474
+ const orgs = await accountsApi.getMyOrganizations();
500
475
  if (!orgs || orgs.length === 0) {
501
476
  throw new Error("No organization found. Please sign up first.");
502
477
  }
@@ -621,13 +596,11 @@ function AuthProvider({ children }) {
621
596
  const refreshUser = useCallback(async () => {
622
597
  if (!user) return;
623
598
  try {
624
- const token = localStorage.getItem("pb_token");
625
599
  let userData = null;
626
- if (token) {
600
+ {
627
601
  try {
628
602
  const freshAccount = await apiFetch(
629
- "/accounts/me",
630
- { token }
603
+ "/accounts/me"
631
604
  );
632
605
  if (freshAccount) {
633
606
  userData = freshAccount;
@@ -643,9 +616,6 @@ function AuthProvider({ children }) {
643
616
  const pbUser = localStorage.getItem("pb_user");
644
617
  if (pbUser) userData = JSON.parse(pbUser);
645
618
  }
646
- } else {
647
- const pbUser = localStorage.getItem("pb_user");
648
- if (pbUser) userData = JSON.parse(pbUser);
649
619
  }
650
620
  if (!userData) return;
651
621
  const result = await loadWorkspaces();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/auth-context.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n ReactNode,\n useCallback,\n} from 'react';\nimport {\n getApiUrl,\n apiFetch,\n accountsApi,\n tenantApi,\n type AuthSessionPayload,\n type Tenant,\n} from './api';\nimport { resetPostHogIdentity } from '@withpotter/components/analytics';\nimport { uploadImage } from '@withpotter/lib/media-service';\nimport { endSession } from '@withpotter/lib/end-session';\nimport {\n pickWorkspace,\n readJoinedWorkspace,\n clearJoinedWorkspace,\n} from './active-workspace';\n\n// Tenant is defined once, canonically, in ./api. Re-exported here so the many\n// modules that historically imported it from auth-context keep working — there\n// is now a single Tenant shape (with the correct string id) across the app.\nexport type { Tenant };\n\nexport interface Workspace {\n tenant: Tenant;\n role: 'owner' | 'manager' | 'staff' | 'viewer';\n permissions?: Record<string, boolean>;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n emailVerified: boolean;\n /** Two-factor authentication is active on this account. */\n mfaEnabled?: boolean;\n organizationId: string; // Created during signup\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n}\n\ninterface AuthContextType {\n user: User | null;\n tenant: Tenant | null;\n workspaces: Workspace[];\n loading: boolean;\n connectionError: boolean;\n login: (email: string, password: string) => Promise<void>;\n loginWithGoogle: (payload: AuthSessionPayload) => Promise<void>;\n loginWithCode: (\n payload: AuthSessionPayload,\n method?: SignInMethod,\n connection?: string,\n ) => Promise<void>;\n logout: () => Promise<void>;\n refreshTenant: () => Promise<void>;\n refreshUser: () => Promise<void>;\n switchWorkspace: (tenantSlug: string) => Promise<void>;\n createWorkspace: (input: CreateWorkspaceInput) => Promise<Tenant>;\n duplicateWorkspace: (\n tenantId: string,\n businessName?: string,\n ) => Promise<Tenant>;\n deleteWorkspace: (tenantSlug: string) => void;\n retryConnection: () => Promise<void>;\n}\n\n/**\n * The single input for creating a business. One shape for both entry paths\n * (first-run /onboarding and in-dashboard /workspaces/new) so every business is\n * provisioned identically. Fields not in the provision contract (salesChannel,\n * address) are persisted onto tenant.settings right after.\n */\nexport interface CreateWorkspaceInput {\n businessName: string;\n /**\n * Only for a deliberate choice of storefront look, which today means the\n * Website hub. Business creation leaves it unset: the API derives the\n * starting template from `intents`, so no client keeps a list of template ids\n * it cannot validate. Three copies of that list had already drifted.\n */\n templateId?: string;\n slug?: string;\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n category?: string;\n salesChannel?: string;\n address?: string;\n phone?: string;\n /** ISO-3166 alpha-2. Not in the provision contract, so it rides the same\n * settings patch as salesChannel. Onboarding used to collect this, derive a\n * currency from it and then throw it away — which is why Settings › General\n * showed an empty Country on every workspace ever created. */\n country?: string;\n /** Optional business logo, uploaded after provision (the media endpoint needs\n * the tenant to exist). Non-fatal — a failed upload never fails creation. */\n logoFile?: File | null;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\n/**\n * Local-dev open access: when running `next dev` with NEXT_PUBLIC_DEV_OPEN_ACCESS=1,\n * skip auth entirely so any dashboard page is directly viewable without a\n * login/OTP (backend data just shows empty states if the API isn't running).\n * NEVER active in staging/prod — those build with NODE_ENV=production, so this\n * is false regardless of the flag.\n */\nconst DEV_OPEN_ACCESS =\n process.env.NODE_ENV === 'development' &&\n process.env.NEXT_PUBLIC_DEV_OPEN_ACCESS === '1';\n\n/**\n * The door an account came through. `connection` is a sign-in the host app provides, such as a payment or bank\n * provider's login; which one is kept beside it (see `LastAccount.connection`).\n */\nexport type SignInMethod = 'google' | 'email' | 'passkey' | 'connection';\n\n/**\n * THE PRECEDENCE RULE FOR \"WHICH WORKSPACE AM I IN\".\n *\n * `user.currentWorkspace` is the only authoritative answer. Four storage keys\n * cache it — `currentWorkspace`, `pb_current_tenant`, `tenant` and\n * `pb_last_workspace` — and they exist because consumers outside React need the\n * answer synchronously: the media proxy resolves `x-tenant-slug` straight out\n * of them (lib/media-service.ts), and the login door names the business it is\n * about to resume into before any provider has mounted.\n *\n * They are a projection of the resolved workspace, never an input to it, so\n * every path that resolves one must write all four together through here.\n * Written piecemeal they drift apart permanently: resolution falls back to\n * `workspaces[0]` whenever the stored slug is no longer in the account's tenant\n * list, and the paths that resolved by fallback used to write nothing back — so\n * storage kept naming a business the app was not in, uploads were tagged with\n * it, and the slug-keyed arrival flag never matched.\n *\n * Passing null clears the active-workspace cache without touching\n * `pb_last_workspace`, which deliberately outlives a session so a returning\n * owner resumes where they left off.\n */\nfunction persistActiveWorkspace(workspace: Workspace | null): void {\n if (!workspace) {\n localStorage.removeItem('currentWorkspace');\n localStorage.removeItem('pb_current_tenant');\n localStorage.removeItem('tenant');\n return;\n }\n const { slug } = workspace.tenant;\n localStorage.setItem('currentWorkspace', slug);\n localStorage.setItem('pb_current_tenant', slug);\n localStorage.setItem('tenant', JSON.stringify(workspace.tenant));\n localStorage.setItem('pb_last_workspace', slug);\n}\n\n/**\n * The slug to prefer when resolving the active workspace, in one order for\n * every entry path. Reload and login used to disagree on it, which let the two\n * doors into the same account land in two different businesses.\n *\n * It is a preference, not an answer: a slug that is not in the account's tenant\n * list loses to `workspaces[0]`, and the winner is written back immediately.\n */\nfunction readPreferredSlug(): string | null {\n return (\n localStorage.getItem('currentWorkspace') ||\n localStorage.getItem('pb_last_workspace') ||\n localStorage.getItem('pb_current_tenant')\n );\n}\n\n/**\n * The workspace this load lands in, for every entry path.\n *\n * Reload, login and refresh each wrote `find(slug) || workspaces[0]` inline,\n * which is why the accept-invite screen could not say \"land me in the business\n * I just joined\" — there was nowhere to say it. `pass authoritative` when the\n * list came from the API: a just-joined tenant missing from a real list is a\n * membership that no longer exists, and the handoff is dropped rather than\n * retried against every later load.\n */\nfunction resolveActiveWorkspace(\n workspaces: Workspace[],\n authoritative: boolean,\n): Workspace | null {\n const joinedTenantId = readJoinedWorkspace();\n const picked = pickWorkspace(workspaces, {\n joinedTenantId,\n preferredSlug: readPreferredSlug(),\n });\n if (\n joinedTenantId &&\n (authoritative || picked?.tenant.id === joinedTenantId)\n ) {\n clearJoinedWorkspace();\n }\n return picked;\n}\n\n/**\n * The workspace list cache, written as one unit. `workspaces` (slugs) is the\n * legacy shape, `pb_workspaces_cache` the full one; both are read as fallbacks\n * when the API is unreachable, so a business missing from either reads as a\n * business the owner does not have.\n */\nfunction persistWorkspaceList(workspaces: Workspace[]): void {\n localStorage.setItem(\n 'workspaces',\n JSON.stringify(workspaces.map((w) => w.tenant.slug)),\n );\n localStorage.setItem('pb_workspaces_cache', JSON.stringify(workspaces));\n}\n\n/**\n * Everything that makes the browser believe it is signed in.\n *\n * Hoisted out of the auth check because it was trapped in one effect's closure,\n * so any other place that discovered a dead session had no way to end it and\n * fell back to cached state instead.\n */\nfunction clearStoredAuth(): void {\n localStorage.removeItem('pb_token');\n localStorage.removeItem('pb_user');\n localStorage.removeItem('pb_account_id');\n localStorage.removeItem('userEmail');\n localStorage.removeItem('organizationId');\n persistActiveWorkspace(null);\n}\n\n/**\n * The workspace we can name WITHOUT asking the server.\n *\n * Every tenant-scoped query in the app is gated on `loading` being false, and\n * `loading` was only cleared after `/team/my-tenants` came back. So the whole\n * dashboard — twenty-odd requests on Home alone — sat waiting on one round trip\n * before it was allowed to ask for anything, on every single page load, even\n * though the answer was already sitting in localStorage from last time.\n *\n * This reads that cache so the app can start immediately. It is a HINT, not an\n * answer: `checkAuth` still calls the API and reconciles, so a workspace the\n * account has lost is corrected a moment later. Nothing is trusted for\n * authorisation either way — the API enforces access on every request, so the\n * worst case is a card that renders and then corrects itself.\n */\nfunction readCachedWorkspace(): Workspace | null {\n try {\n const raw = localStorage.getItem('pb_workspaces_cache');\n if (!raw) return null;\n const cached = JSON.parse(raw) as Workspace[];\n if (!Array.isArray(cached) || cached.length === 0) return null;\n const match =\n pickWorkspace(cached, {\n joinedTenantId: readJoinedWorkspace(),\n preferredSlug: readPreferredSlug(),\n }) ?? cached[0];\n // A slug-only legacy entry has no real id; starting on one would fire every\n // query against a tenant id that does not exist.\n return match?.tenant?.slug && match.tenant.id !== match.tenant.slug\n ? match\n : null;\n } catch {\n return null;\n }\n}\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n const [user, setUser] = useState<User | null>(null);\n const [loading, setLoading] = useState(true);\n const [connectionError, setConnectionError] = useState(false);\n\n const fetchTenant = useCallback(\n async (slug: string): Promise<Tenant | null> => {\n try {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n const res = await fetch(`${getApiUrl()}/tenants/${slug}`, {\n credentials: 'include',\n });\n if (!res.ok) return null;\n\n const data = await res.json();\n return {\n id: data.id,\n slug: data.slug,\n businessName: data.businessName,\n // Synthesize businessLogo from settings.logo.url (GET /tenants/:slug returns raw entity without businessLogo)\n businessLogo:\n data.businessLogo || data.settings?.logo?.url || undefined,\n contactEmail: data.contactEmail || data.email,\n contactPhone: data.contactPhone || data.phone,\n templateId: data.templateId || 'luxe-fashion-01',\n customDomain: data.customDomain || undefined,\n status: data.status,\n settings: data.settings,\n organizationId: data.organizationId,\n };\n } catch {\n return null;\n }\n },\n [],\n );\n\n // Load workspaces from API\n // Returns { workspaces, fromApi } to track if data came from API\n const loadWorkspaces = useCallback(async (): Promise<{\n workspaces: Workspace[];\n fromApi: boolean;\n }> => {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n try {\n const res = await fetch(`${getApiUrl()}/team/my-tenants`, {\n credentials: 'include',\n });\n\n // 401 means the session/token is invalid — force re-login\n if (res.status === 401 || res.status === 403) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n 'Auth expired (status',\n res.status,\n '), redirecting to login',\n );\n }\n // Clear stale auth data\n clearStoredAuth();\n // Redirect to login — throw to prevent checkAuth from updating\n // React state, which would race with this hard navigation\n if (typeof window !== 'undefined') {\n window.location.href = '/login';\n }\n throw new Error('AUTH_EXPIRED');\n }\n\n if (res.ok) {\n const apiTenants = (await res.json()) as Array<{\n id: string;\n tenantId: string;\n tenantSlug: string;\n tenantName: string;\n role: string;\n permissions?: Record<string, boolean>;\n templateId?: string | null;\n organizationId?: string | null;\n tenant?: {\n id: string;\n slug: string;\n businessName: string;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n industryKey?: string | null;\n settings: Record<string, unknown> | null;\n };\n }>;\n\n /**\n * A DELETED WORKSPACE IS NOT A WORKSPACE.\n *\n * The membership row outlives the tenant, so deleting a workspace left\n * it in this list and therefore in the switcher. Choosing one looked\n * completely normal and then every request 401'd with \"Tenant context\n * required\" — the API's tenant middleware resolves only ACTIVE and\n * PENDING — so the whole dashboard rendered empty: no orders, no\n * customers, no analytics, no marketing. Nothing anywhere said the\n * workspace was gone, so it read as the product being broken rather\n * than the workspace not existing.\n *\n * Measured on staging: three of six workspaces in this list were\n * deleted, and all three 401 on every endpoint.\n */\n const liveTenants = apiTenants.filter(\n (t) => (t.tenant?.status ?? 'active') !== 'deleted',\n );\n\n // Use the full tenant details from the API response (no N+1 calls needed)\n const workspaces: Workspace[] = liveTenants.map((t) => ({\n tenant: {\n id: t.tenant?.id || t.tenantId,\n slug: t.tenant?.slug || t.tenantSlug,\n businessName: t.tenant?.businessName || t.tenantName,\n businessLogo: t.tenant?.businessLogo || undefined,\n contactEmail: t.tenant?.contactEmail || undefined,\n contactPhone: t.tenant?.contactPhone || undefined,\n customDomain: t.tenant?.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.tenant?.status as\n | 'active'\n | 'suspended'\n | 'pending'\n | 'deleted') || 'active',\n // What kind of business this is. The switcher labels every row\n // with it so four workspaces are four distinguishable things, and\n // dropping it here was why they all read as a bare name.\n industryKey: t.tenant?.industryKey ?? null,\n settings: t.tenant?.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n }));\n\n // Sync localStorage with API response (cache full workspace data for resilient fallback)\n if (workspaces.length > 0) {\n persistWorkspaceList(workspaces);\n }\n\n return { workspaces, fromApi: true };\n }\n\n // API returned non-ok status (e.g., 429, 500) — fall back to cached workspaces\n if (process.env.NODE_ENV === 'development') {\n console.warn('API returned error status:', res.status);\n }\n // Try full workspace cache first (has proper UUIDs)\n try {\n const cachedRaw = localStorage.getItem('pb_workspaces_cache');\n if (cachedRaw) {\n const cachedWorkspaces = JSON.parse(cachedRaw) as Workspace[];\n if (cachedWorkspaces.length > 0) {\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n }\n } catch {\n // Corrupted cache — fall through\n }\n // Legacy fallback: slugs-only cache (no UUIDs available — use connection error state)\n const cachedSlugs = JSON.parse(\n localStorage.getItem('workspaces') || '[]',\n ) as string[];\n if (cachedSlugs.length > 0) {\n const cachedWorkspaces: Workspace[] = cachedSlugs.map((slug) => ({\n tenant: {\n id: slug,\n slug,\n businessName: slug,\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n },\n role: 'owner' as const,\n }));\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n return { workspaces: [], fromApi: false };\n } catch (error) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to fetch workspaces from API:', error);\n }\n // Network error or other failure\n return { workspaces: [], fromApi: false };\n }\n }, []);\n\n useEffect(() => {\n const checkAuth = async () => {\n // Local-dev open access: bypass auth so any page renders directly.\n if (DEV_OPEN_ACCESS) {\n const slug = process.env.NEXT_PUBLIC_DEV_TENANT || 'demo';\n const tenant = {\n id: slug,\n slug,\n businessName: 'Dev Business',\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n };\n const workspace = { tenant, role: 'owner' as const };\n setUser({\n id: 'dev-user',\n email: 'dev@local',\n name: 'Dev',\n emailVerified: true,\n mfaEnabled: false,\n organizationId: '',\n workspaces: [workspace],\n currentWorkspace: workspace,\n });\n setLoading(false);\n return;\n }\n\n // Require valid auth token\n const token = localStorage.getItem('pb_token');\n const pbUser = localStorage.getItem('pb_user');\n const storedOrgId = localStorage.getItem('organizationId');\n\n // No token = not authenticated\n if (!token) {\n setLoading(false);\n return;\n }\n\n // Check JWT expiry client-side (7-day TTL)\n try {\n const parts = token.split('.');\n if (parts.length === 3) {\n const payload = JSON.parse(atob(parts[1]));\n if (payload.exp && payload.exp <= Math.floor(Date.now() / 1000)) {\n clearStoredAuth();\n setLoading(false);\n return;\n }\n }\n } catch {\n // Malformed token — treat as expired\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n // No user data = invalid session\n if (!pbUser) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('No user data found, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n let userData: {\n id: string;\n email: string;\n name?: string;\n avatarUrl?: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string;\n } | null = null;\n try {\n userData = JSON.parse(pbUser);\n } catch {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Invalid user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n if (!userData?.email) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('No email in user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n // ── Optimistic start ──────────────────────────────────────────────────\n // Everything tenant-scoped is gated on `loading`, so clearing it here\n // lets the dashboard begin fetching against the workspace we already\n // know, instead of idling through a round trip first. The API call below\n // still runs and reconciles; this only decides when the app is allowed\n // to START, not what is true.\n const cachedWorkspace = readCachedWorkspace();\n if (cachedWorkspace && !cancelled) {\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId:\n storedOrgId ||\n userData?.organizationId ||\n cachedWorkspace.tenant.organizationId ||\n '',\n workspaces: [cachedWorkspace],\n currentWorkspace: cachedWorkspace,\n });\n setLoading(false);\n }\n\n try {\n const freshAccount = await apiFetch<typeof userData>('/accounts/me', {\n token,\n });\n if (freshAccount) {\n userData = freshAccount;\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch {\n // Keep the cached account when the API is temporarily unavailable.\n }\n\n // Load workspaces — may throw AUTH_EXPIRED if session is invalid\n let result;\n try {\n result = await loadWorkspaces();\n } catch (e: unknown) {\n if (e instanceof Error && e.message === 'AUTH_EXPIRED') return;\n throw e;\n }\n if (cancelled) return;\n const workspaces = result.workspaces;\n setConnectionError(!result.fromApi && workspaces.length === 0);\n\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n\n // Find current workspace or use first one\n let currentWorkspace = resolveActiveWorkspace(workspaces, result.fromApi);\n\n // If no workspaces but we have signup tenant, fetch it\n if (workspaces.length === 0 && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n persistWorkspaceList(workspaces);\n }\n }\n\n // Write the resolution back before rendering it. This is the reload path\n // that fell through with a stored slug the account no longer has: it\n // resolved `workspaces[0]` and left the cache naming the old business.\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId - must be a valid UUID\n const userOrgId =\n storedOrgId ||\n userData?.organizationId ||\n currentWorkspace?.tenant?.organizationId;\n\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n setLoading(false);\n };\n\n let cancelled = false;\n checkAuth();\n return () => {\n cancelled = true;\n };\n }, [fetchTenant, loadWorkspaces]);\n\n // Establish the client session from an auth payload (the /accounts/login\n // response, or a Google callback result of the same shape). Both the email\n // and Google doors funnel through here so they hydrate identically.\n /** Which door an account came through, remembered for the next visit. */\n // `method` is the door the account actually came through. It is stored on the\n // recognition hint so /login can offer that same door on return: greeting a\n // Google user by name and then emailing them a code is worse than not\n // recognising them at all.\n const establishSession = async (\n data: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n const { account, token, tenants, organizationId } = data;\n\n // Build workspaces directly from enriched tenant data in the payload\n const workspaces: Workspace[] = [];\n for (const t of tenants || []) {\n workspaces.push({\n tenant: {\n id: t.id,\n slug: t.slug,\n businessName: t.name,\n businessLogo:\n t.businessLogo ||\n (t.settings as { logo?: { url?: string } } | null | undefined)?.logo\n ?.url ||\n undefined,\n contactEmail: t.contactEmail || undefined,\n contactPhone: t.contactPhone || undefined,\n customDomain: t.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.status as 'active' | 'suspended' | 'pending' | 'deleted') ||\n 'active',\n settings: t.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n });\n }\n\n // Resume the business the owner last worked in (matches the reload path),\n // falling back to the first workspace. Without this, a multi-business owner\n // whose session expired lands in an arbitrary business, not where they left.\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n // `pb_last_workspace` deliberately survives logout (like pb_last_account),\n // so a returning owner resumes into the business they were last in rather\n // than an arbitrary first workspace — and the \"Welcome back\" card names it.\n let currentWorkspace = resolveActiveWorkspace(workspaces, true);\n if (!currentWorkspace && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n }\n }\n\n // Get organizationId from response, or from first tenant\n const userOrgId =\n organizationId ||\n tenants?.[0]?.organizationId ||\n currentWorkspace?.tenant?.organizationId ||\n null;\n\n // Save auth data\n localStorage.setItem('pb_token', token);\n localStorage.setItem('pb_account_id', account.id);\n localStorage.setItem('pb_user', JSON.stringify(account));\n localStorage.setItem('userEmail', account.email);\n persistWorkspaceList(workspaces);\n if (userOrgId) {\n localStorage.setItem('organizationId', userOrgId);\n }\n persistActiveWorkspace(currentWorkspace);\n\n // Recognition hint: a small, non-sensitive record of the last account to\n // sign in on this device. Unlike the session keys above it deliberately\n // survives logout, so /login can greet a returning owner by name. It holds\n // no token — the emailed code is still the only proof of identity.\n try {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n businessName:\n currentWorkspace?.tenant.businessName ||\n tenants?.[0]?.name ||\n undefined,\n method,\n connection: method === 'connection' ? connection : undefined,\n }),\n );\n } catch {\n // storage full / disabled — recognition is a convenience, never a gate\n }\n\n setUser({\n id: account.id,\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n emailVerified: account.emailVerified ?? false,\n mfaEnabled: account.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n };\n\n const login = async (email: string, password: string) => {\n setLoading(true);\n try {\n // Call real login API\n const res = await fetch(`${getApiUrl()}/accounts/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n credentials: 'include', // Required to receive and send cookies\n });\n\n if (!res.ok) {\n const error = await res\n .json()\n .catch(() => ({ message: 'Login failed' }));\n throw new Error(error.message || 'Invalid email or password');\n }\n\n const data = await res.json();\n await establishSession(data);\n } finally {\n setLoading(false);\n }\n };\n\n // The Google door. The callback page already exchanged the OAuth code for a\n // session payload; here we just hydrate from it, same as email login.\n const loginWithGoogle = async (payload: AuthSessionPayload) => {\n setLoading(true);\n try {\n await establishSession(payload, 'google');\n } finally {\n setLoading(false);\n }\n };\n\n // The passwordless door. Verify already exchanged the emailed code for a\n // session payload; hydrate from it identically.\n const loginWithCode = async (\n payload: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n setLoading(true);\n try {\n // The door is passed through so a passkey sign-in is REMEMBERED as one.\n // Without it the recognition card would greet a passkey user and then\n // email them a code — the exact mismatch the Google branch exists to\n // avoid, just with a different door.\n await establishSession(payload, method, connection);\n } finally {\n setLoading(false);\n }\n };\n\n const logout = async () => {\n resetPostHogIdentity();\n // The key list moved to `lib/end-session` so the invite screen can end a\n // session too, without a second copy of it drifting from this one.\n await endSession();\n setUser(null);\n };\n\n const switchWorkspace = useCallback(\n async (tenantSlug: string) => {\n if (!user) return;\n\n const workspace = user.workspaces.find(\n (w) => w.tenant.slug === tenantSlug,\n );\n if (workspace) {\n persistActiveWorkspace(workspace);\n try {\n const prev = JSON.parse(\n localStorage.getItem('pb_last_account') || '{}',\n );\n if (prev.email) {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n ...prev,\n businessName: workspace.tenant.businessName,\n }),\n );\n }\n } catch {\n // recognition hint is a convenience, never a gate\n }\n setUser((prev) =>\n prev ? { ...prev, currentWorkspace: workspace } : null,\n );\n }\n },\n [user],\n );\n\n const createWorkspace = useCallback(\n async (input: CreateWorkspaceInput): Promise<Tenant> => {\n const {\n businessName,\n templateId,\n currency,\n intents,\n isSolo,\n category,\n salesChannel,\n address,\n phone,\n country,\n logoFile,\n } = input;\n\n const slug =\n input.slug ||\n businessName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/(^-|-$)/g, '');\n\n // organizationId is optional — server resolves it from account membership\n // when omitted; cache it for later calls.\n let organizationId = user?.organizationId || '';\n if (!organizationId) {\n const token = localStorage.getItem('pb_token') || '';\n const orgs = await accountsApi.getMyOrganizations(token);\n if (!orgs || orgs.length === 0) {\n throw new Error('No organization found. Please sign up first.');\n }\n organizationId = orgs[0].id;\n localStorage.setItem('organizationId', organizationId);\n setUser((prev) => (prev ? { ...prev, organizationId } : null));\n }\n\n // 1. Provision — the single business-creation call in the app.\n const result = await apiFetch<{ tenant: Tenant }>(\n '/onboarding/provision',\n {\n method: 'POST',\n body: JSON.stringify({\n organizationId,\n businessName,\n slug,\n // Only sent when the merchant actually chose a look. Omitted, the\n // API derives it from `intents` against the templates table it\n // owns; named, an unknown id is rejected rather than replaced.\n ...(templateId && { templateId }),\n ...(category && { category }),\n ...(intents?.length && { intents }),\n ...(isSolo != null && { isSolo }),\n ...(currency && { currency }),\n // phone is part of the provision contract — the usecase stores it\n // canonically on tenant.phone AND settings.phone, so send it here\n // rather than patching settings after (which would land it in the\n // wrong place and nothing would read it).\n ...(phone?.trim() && { phone: phone.trim() }),\n }),\n },\n );\n\n let tenant = result.tenant;\n\n // Make the new tenant the active one BEFORE the logo upload: the media\n // proxy tags the upload with `x-tenant-slug` read from stored tenant, so\n // it must point at this fresh tenant for the file to land in its namespace.\n persistActiveWorkspace({ tenant, role: 'owner' });\n\n // 2a. Upload the logo, if one was picked. The media endpoint needs the\n // tenant to exist, so this only runs post-provision. Non-fatal: a\n // failed upload must never fail business creation.\n let logoUrl: string | undefined;\n if (logoFile) {\n try {\n const uploaded = await uploadImage(logoFile);\n logoUrl = uploaded.url;\n } catch {\n // non-fatal — the merchant can add a logo later from the editor\n }\n }\n\n // 2b. Persist fields NOT in the provision contract onto settings (server\n // deep-merges). These are set here on a fresh tenant, so a shallow set\n // is safe. `logo` mirrors the editor's brand shape ({ url }), from which\n // businessLogo is synthesized. Refinements, never gates — a failure\n const settingsPatch: Record<string, unknown> = {};\n if (salesChannel) settingsPatch.salesChannel = salesChannel;\n if (intents?.length) settingsPatch.selectedIntents = intents;\n if (country) settingsPatch.country = country;\n if (address?.trim()) settingsPatch.contact = { address: address.trim() };\n if (logoUrl) settingsPatch.logo = { url: logoUrl };\n if (Object.keys(settingsPatch).length > 0) {\n try {\n await tenantApi.updateSettings(slug, settingsPatch);\n // Reflect what we just wrote onto the in-memory tenant so a soft-nav\n // dashboard reads fresh settings without a round-trip. (We merge our\n // known patch rather than the API's return, whose Tenant shape differs\n // from auth-context's.)\n tenant = {\n ...tenant,\n businessLogo: logoUrl ?? tenant.businessLogo,\n settings: { ...(tenant.settings ?? {}), ...settingsPatch },\n };\n } catch {\n // non-fatal — these refine, they don't gate\n }\n }\n\n // 3. Register in context + make active, so the dashboard renders it\n // immediately instead of bouncing back to onboarding.\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n\n return tenant;\n },\n [user],\n );\n\n const duplicateWorkspace = useCallback(\n async (tenantId: string, businessName?: string): Promise<Tenant> => {\n const result = await apiFetch<{ tenant: Tenant }>(\n `/onboarding/${tenantId}/duplicate`,\n {\n method: 'POST',\n body: JSON.stringify({ businessName }),\n },\n );\n\n const tenant = result.tenant;\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n return tenant;\n },\n [user],\n );\n\n const deleteWorkspace = useCallback(\n (tenantSlug: string) => {\n // Immediately remove the deleted workspace from in-memory state\n // so no component re-renders with a stale tenant reference\n const remaining = (user?.workspaces || []).filter(\n (w) => w.tenant.slug !== tenantSlug,\n );\n const next = remaining[0] || null;\n\n // Update localStorage to reflect the new workspace selection\n persistActiveWorkspace(next);\n if (next) {\n persistWorkspaceList(remaining);\n } else {\n localStorage.removeItem('workspaces');\n localStorage.removeItem('pb_workspaces_cache');\n }\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: remaining,\n currentWorkspace: next,\n }\n : null,\n );\n },\n [user?.workspaces],\n );\n\n const refreshUser = useCallback(async () => {\n if (!user) return;\n\n try {\n // Re-fetch account from API to get latest data (e.g. emailVerified)\n const token = localStorage.getItem('pb_token');\n let userData: Record<string, unknown> | null = null;\n if (token) {\n try {\n const freshAccount = await apiFetch<Record<string, unknown>>(\n '/accounts/me',\n { token },\n );\n if (freshAccount) {\n userData = freshAccount;\n // Update localStorage with fresh data\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch (err) {\n /**\n * Two very different failures were being treated as one.\n *\n * The server being unreachable is temporary, and falling back to the\n * cached account is right: the merchant keeps working.\n *\n * The server answering \"this account does not exist\" is not\n * temporary. Swallowing that left a dead session rendering as a\n * signed-in one, reading a cached copy of a user the API has never\n * heard of. Nothing surfaced until a write went deep enough to check\n * — provisioning a business then failed with \"You do not have access\n * to this organization\", which is true, unactionable, and describes\n * the wrong problem.\n */\n const status = (err as { status?: number })?.status;\n if (status === 401 || status === 403 || status === 404) {\n clearStoredAuth();\n if (typeof window !== 'undefined') window.location.href = '/login';\n return;\n }\n // Unreachable, not refused: keep going on what we have.\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) userData = JSON.parse(pbUser);\n }\n } else {\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) userData = JSON.parse(pbUser);\n }\n\n if (!userData) return;\n\n // Reload workspaces from API\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n const currentWorkspace = resolveActiveWorkspace(\n workspaces,\n result.fromApi,\n );\n\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId from workspace if not stored\n const storedOrgId = localStorage.getItem('organizationId');\n const orgId =\n storedOrgId ||\n currentWorkspace?.tenant?.organizationId ||\n user.organizationId;\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n name: (userData?.name as string) || prev.name,\n email: (userData?.email as string) || prev.email,\n avatarUrl: (userData?.avatarUrl as string) || prev.avatarUrl,\n emailVerified:\n (userData?.emailVerified as boolean) ?? prev.emailVerified,\n mfaEnabled: (userData?.mfaEnabled as boolean) ?? prev.mfaEnabled,\n workspaces,\n currentWorkspace,\n organizationId: orgId,\n }\n : null,\n );\n } catch {\n // Ignore errors\n }\n }, [user, loadWorkspaces]);\n\n const refreshTenant = useCallback(async () => {\n const currentSlug = user?.currentWorkspace?.tenant?.slug;\n const currentWorkspace = user?.currentWorkspace;\n const workspaces = user?.workspaces;\n\n if (!currentSlug || !currentWorkspace || !workspaces) return;\n\n const freshTenant = await fetchTenant(currentSlug);\n if (freshTenant) {\n const updatedWorkspace = { ...currentWorkspace, tenant: freshTenant };\n const updatedWorkspaces = workspaces.map((w) =>\n w.tenant.slug === freshTenant.slug ? updatedWorkspace : w,\n );\n persistActiveWorkspace(updatedWorkspace);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: updatedWorkspace,\n }\n : null,\n );\n }\n }, [user?.currentWorkspace, user?.workspaces, fetchTenant]);\n\n const retryConnection = useCallback(async () => {\n setLoading(true);\n setConnectionError(false);\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n\n if (result.fromApi && workspaces.length > 0) {\n const currentWorkspace = resolveActiveWorkspace(workspaces, true);\n persistActiveWorkspace(currentWorkspace);\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces,\n currentWorkspace,\n }\n : null,\n );\n } else if (!result.fromApi) {\n setConnectionError(true);\n }\n setLoading(false);\n }, [loadWorkspaces]);\n\n return (\n <AuthContext.Provider\n value={{\n user,\n tenant: user?.currentWorkspace?.tenant ?? null,\n workspaces: user?.workspaces ?? [],\n loading,\n connectionError,\n retryConnection,\n login,\n loginWithGoogle,\n loginWithCode,\n logout,\n refreshTenant,\n refreshUser,\n switchWorkspace,\n createWorkspace,\n duplicateWorkspace,\n deleteWorkspace,\n }}\n >\n {children}\n </AuthContext.Provider>\n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (context === undefined) {\n throw new Error('useAuth must be used within an AuthProvider');\n }\n return context;\n}\n\n/**\n * The current business, or null when there is no provider above.\n *\n * For shared primitives, which cannot assume they are inside an AuthProvider:\n * the root layout mounts none, each section mounts its own, and `useAuth`\n * throws. A page frame that threw would take the whole screen white rather\n * than degrade, which is the failure /auth already shipped once.\n */\nexport function useOptionalTenant(): Tenant | null {\n return useContext(AuthContext)?.tenant ?? null;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/auth-context.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n ReactNode,\n useCallback,\n} from 'react';\nimport {\n getApiUrl,\n apiFetch,\n accountsApi,\n tenantApi,\n type AuthSessionPayload,\n type Tenant,\n} from './api';\nimport { removeLegacyToken } from './stored-token';\nimport { resetPostHogIdentity } from '@withpotter/components/analytics';\nimport { uploadImage } from '@withpotter/lib/media-service';\nimport { endSession } from '@withpotter/lib/end-session';\nimport {\n pickWorkspace,\n readJoinedWorkspace,\n clearJoinedWorkspace,\n} from './active-workspace';\n\n// Tenant is defined once, canonically, in ./api. Re-exported here so the many\n// modules that historically imported it from auth-context keep working — there\n// is now a single Tenant shape (with the correct string id) across the app.\nexport type { Tenant };\n\nexport interface Workspace {\n tenant: Tenant;\n role: 'owner' | 'manager' | 'staff' | 'viewer';\n permissions?: Record<string, boolean>;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n emailVerified: boolean;\n /** Two-factor authentication is active on this account. */\n mfaEnabled?: boolean;\n organizationId: string; // Created during signup\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n}\n\ninterface AuthContextType {\n user: User | null;\n tenant: Tenant | null;\n workspaces: Workspace[];\n loading: boolean;\n connectionError: boolean;\n login: (email: string, password: string) => Promise<void>;\n loginWithGoogle: (payload: AuthSessionPayload) => Promise<void>;\n loginWithCode: (\n payload: AuthSessionPayload,\n method?: SignInMethod,\n connection?: string,\n ) => Promise<void>;\n logout: () => Promise<void>;\n refreshTenant: () => Promise<void>;\n refreshUser: () => Promise<void>;\n switchWorkspace: (tenantSlug: string) => Promise<void>;\n createWorkspace: (input: CreateWorkspaceInput) => Promise<Tenant>;\n duplicateWorkspace: (\n tenantId: string,\n businessName?: string,\n ) => Promise<Tenant>;\n deleteWorkspace: (tenantSlug: string) => void;\n retryConnection: () => Promise<void>;\n}\n\n/**\n * The single input for creating a business. One shape for both entry paths\n * (first-run /onboarding and in-dashboard /workspaces/new) so every business is\n * provisioned identically. Fields not in the provision contract (salesChannel,\n * address) are persisted onto tenant.settings right after.\n */\nexport interface CreateWorkspaceInput {\n businessName: string;\n /**\n * Only for a deliberate choice of storefront look, which today means the\n * Website hub. Business creation leaves it unset: the API derives the\n * starting template from `intents`, so no client keeps a list of template ids\n * it cannot validate. Three copies of that list had already drifted.\n */\n templateId?: string;\n slug?: string;\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n category?: string;\n salesChannel?: string;\n address?: string;\n phone?: string;\n /** ISO-3166 alpha-2. Not in the provision contract, so it rides the same\n * settings patch as salesChannel. Onboarding used to collect this, derive a\n * currency from it and then throw it away — which is why Settings › General\n * showed an empty Country on every workspace ever created. */\n country?: string;\n /** Optional business logo, uploaded after provision (the media endpoint needs\n * the tenant to exist). Non-fatal — a failed upload never fails creation. */\n logoFile?: File | null;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\n/**\n * Local-dev open access: when running `next dev` with NEXT_PUBLIC_DEV_OPEN_ACCESS=1,\n * skip auth entirely so any dashboard page is directly viewable without a\n * login/OTP (backend data just shows empty states if the API isn't running).\n * NEVER active in staging/prod — those build with NODE_ENV=production, so this\n * is false regardless of the flag.\n */\nconst DEV_OPEN_ACCESS =\n process.env.NODE_ENV === 'development' &&\n process.env.NEXT_PUBLIC_DEV_OPEN_ACCESS === '1';\n\n/**\n * The door an account came through. `connection` is a sign-in the host app provides, such as a payment or bank\n * provider's login; which one is kept beside it (see `LastAccount.connection`).\n */\nexport type SignInMethod = 'google' | 'email' | 'passkey' | 'connection';\n\n/**\n * THE PRECEDENCE RULE FOR \"WHICH WORKSPACE AM I IN\".\n *\n * `user.currentWorkspace` is the only authoritative answer. Four storage keys\n * cache it — `currentWorkspace`, `pb_current_tenant`, `tenant` and\n * `pb_last_workspace` — and they exist because consumers outside React need the\n * answer synchronously: the media proxy resolves `x-tenant-slug` straight out\n * of them (lib/media-service.ts), and the login door names the business it is\n * about to resume into before any provider has mounted.\n *\n * They are a projection of the resolved workspace, never an input to it, so\n * every path that resolves one must write all four together through here.\n * Written piecemeal they drift apart permanently: resolution falls back to\n * `workspaces[0]` whenever the stored slug is no longer in the account's tenant\n * list, and the paths that resolved by fallback used to write nothing back — so\n * storage kept naming a business the app was not in, uploads were tagged with\n * it, and the slug-keyed arrival flag never matched.\n *\n * Passing null clears the active-workspace cache without touching\n * `pb_last_workspace`, which deliberately outlives a session so a returning\n * owner resumes where they left off.\n */\nfunction persistActiveWorkspace(workspace: Workspace | null): void {\n if (!workspace) {\n localStorage.removeItem('currentWorkspace');\n localStorage.removeItem('pb_current_tenant');\n localStorage.removeItem('tenant');\n return;\n }\n const { slug } = workspace.tenant;\n localStorage.setItem('currentWorkspace', slug);\n localStorage.setItem('pb_current_tenant', slug);\n localStorage.setItem('tenant', JSON.stringify(workspace.tenant));\n localStorage.setItem('pb_last_workspace', slug);\n}\n\n/**\n * The slug to prefer when resolving the active workspace, in one order for\n * every entry path. Reload and login used to disagree on it, which let the two\n * doors into the same account land in two different businesses.\n *\n * It is a preference, not an answer: a slug that is not in the account's tenant\n * list loses to `workspaces[0]`, and the winner is written back immediately.\n */\nfunction readPreferredSlug(): string | null {\n return (\n localStorage.getItem('currentWorkspace') ||\n localStorage.getItem('pb_last_workspace') ||\n localStorage.getItem('pb_current_tenant')\n );\n}\n\n/**\n * The workspace this load lands in, for every entry path.\n *\n * Reload, login and refresh each wrote `find(slug) || workspaces[0]` inline,\n * which is why the accept-invite screen could not say \"land me in the business\n * I just joined\" — there was nowhere to say it. `pass authoritative` when the\n * list came from the API: a just-joined tenant missing from a real list is a\n * membership that no longer exists, and the handoff is dropped rather than\n * retried against every later load.\n */\nfunction resolveActiveWorkspace(\n workspaces: Workspace[],\n authoritative: boolean,\n): Workspace | null {\n const joinedTenantId = readJoinedWorkspace();\n const picked = pickWorkspace(workspaces, {\n joinedTenantId,\n preferredSlug: readPreferredSlug(),\n });\n if (\n joinedTenantId &&\n (authoritative || picked?.tenant.id === joinedTenantId)\n ) {\n clearJoinedWorkspace();\n }\n return picked;\n}\n\n/**\n * The workspace list cache, written as one unit. `workspaces` (slugs) is the\n * legacy shape, `pb_workspaces_cache` the full one; both are read as fallbacks\n * when the API is unreachable, so a business missing from either reads as a\n * business the owner does not have.\n */\nfunction persistWorkspaceList(workspaces: Workspace[]): void {\n localStorage.setItem(\n 'workspaces',\n JSON.stringify(workspaces.map((w) => w.tenant.slug)),\n );\n localStorage.setItem('pb_workspaces_cache', JSON.stringify(workspaces));\n}\n\n/**\n * Everything that makes the browser believe it is signed in.\n *\n * Hoisted out of the auth check because it was trapped in one effect's closure,\n * so any other place that discovered a dead session had no way to end it and\n * fell back to cached state instead.\n */\nfunction clearStoredAuth(): void {\n localStorage.removeItem('pb_token');\n localStorage.removeItem('pb_user');\n localStorage.removeItem('pb_account_id');\n localStorage.removeItem('userEmail');\n localStorage.removeItem('organizationId');\n persistActiveWorkspace(null);\n}\n\n/**\n * The workspace we can name WITHOUT asking the server.\n *\n * Every tenant-scoped query in the app is gated on `loading` being false, and\n * `loading` was only cleared after `/team/my-tenants` came back. So the whole\n * dashboard — twenty-odd requests on Home alone — sat waiting on one round trip\n * before it was allowed to ask for anything, on every single page load, even\n * though the answer was already sitting in localStorage from last time.\n *\n * This reads that cache so the app can start immediately. It is a HINT, not an\n * answer: `checkAuth` still calls the API and reconciles, so a workspace the\n * account has lost is corrected a moment later. Nothing is trusted for\n * authorisation either way — the API enforces access on every request, so the\n * worst case is a card that renders and then corrects itself.\n */\nfunction readCachedWorkspace(): Workspace | null {\n try {\n const raw = localStorage.getItem('pb_workspaces_cache');\n if (!raw) return null;\n const cached = JSON.parse(raw) as Workspace[];\n if (!Array.isArray(cached) || cached.length === 0) return null;\n const match =\n pickWorkspace(cached, {\n joinedTenantId: readJoinedWorkspace(),\n preferredSlug: readPreferredSlug(),\n }) ?? cached[0];\n // A slug-only legacy entry has no real id; starting on one would fire every\n // query against a tenant id that does not exist.\n return match?.tenant?.slug && match.tenant.id !== match.tenant.slug\n ? match\n : null;\n } catch {\n return null;\n }\n}\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n const [user, setUser] = useState<User | null>(null);\n const [loading, setLoading] = useState(true);\n const [connectionError, setConnectionError] = useState(false);\n\n const fetchTenant = useCallback(\n async (slug: string): Promise<Tenant | null> => {\n try {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n const res = await fetch(`${getApiUrl()}/tenants/${slug}`, {\n credentials: 'include',\n });\n if (!res.ok) return null;\n\n const data = await res.json();\n return {\n id: data.id,\n slug: data.slug,\n businessName: data.businessName,\n // Synthesize businessLogo from settings.logo.url (GET /tenants/:slug returns raw entity without businessLogo)\n businessLogo:\n data.businessLogo || data.settings?.logo?.url || undefined,\n contactEmail: data.contactEmail || data.email,\n contactPhone: data.contactPhone || data.phone,\n templateId: data.templateId || 'luxe-fashion-01',\n customDomain: data.customDomain || undefined,\n status: data.status,\n settings: data.settings,\n organizationId: data.organizationId,\n };\n } catch {\n return null;\n }\n },\n [],\n );\n\n // Load workspaces from API\n // Returns { workspaces, fromApi } to track if data came from API\n const loadWorkspaces = useCallback(async (): Promise<{\n workspaces: Workspace[];\n fromApi: boolean;\n }> => {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n try {\n const res = await fetch(`${getApiUrl()}/team/my-tenants`, {\n credentials: 'include',\n });\n\n // 401 means the session/token is invalid — force re-login\n if (res.status === 401 || res.status === 403) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n 'Auth expired (status',\n res.status,\n '), redirecting to login',\n );\n }\n // Clear stale auth data\n clearStoredAuth();\n // Redirect to login — throw to prevent checkAuth from updating\n // React state, which would race with this hard navigation\n if (typeof window !== 'undefined') {\n window.location.href = '/login';\n }\n throw new Error('AUTH_EXPIRED');\n }\n\n if (res.ok) {\n const apiTenants = (await res.json()) as Array<{\n id: string;\n tenantId: string;\n tenantSlug: string;\n tenantName: string;\n role: string;\n permissions?: Record<string, boolean>;\n templateId?: string | null;\n organizationId?: string | null;\n tenant?: {\n id: string;\n slug: string;\n businessName: string;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n industryKey?: string | null;\n settings: Record<string, unknown> | null;\n };\n }>;\n\n /**\n * A DELETED WORKSPACE IS NOT A WORKSPACE.\n *\n * The membership row outlives the tenant, so deleting a workspace left\n * it in this list and therefore in the switcher. Choosing one looked\n * completely normal and then every request 401'd with \"Tenant context\n * required\" — the API's tenant middleware resolves only ACTIVE and\n * PENDING — so the whole dashboard rendered empty: no orders, no\n * customers, no analytics, no marketing. Nothing anywhere said the\n * workspace was gone, so it read as the product being broken rather\n * than the workspace not existing.\n *\n * Measured on staging: three of six workspaces in this list were\n * deleted, and all three 401 on every endpoint.\n */\n const liveTenants = apiTenants.filter(\n (t) => (t.tenant?.status ?? 'active') !== 'deleted',\n );\n\n // Use the full tenant details from the API response (no N+1 calls needed)\n const workspaces: Workspace[] = liveTenants.map((t) => ({\n tenant: {\n id: t.tenant?.id || t.tenantId,\n slug: t.tenant?.slug || t.tenantSlug,\n businessName: t.tenant?.businessName || t.tenantName,\n businessLogo: t.tenant?.businessLogo || undefined,\n contactEmail: t.tenant?.contactEmail || undefined,\n contactPhone: t.tenant?.contactPhone || undefined,\n customDomain: t.tenant?.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.tenant?.status as\n | 'active'\n | 'suspended'\n | 'pending'\n | 'deleted') || 'active',\n // What kind of business this is. The switcher labels every row\n // with it so four workspaces are four distinguishable things, and\n // dropping it here was why they all read as a bare name.\n industryKey: t.tenant?.industryKey ?? null,\n settings: t.tenant?.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n }));\n\n // Sync localStorage with API response (cache full workspace data for resilient fallback)\n if (workspaces.length > 0) {\n persistWorkspaceList(workspaces);\n }\n\n return { workspaces, fromApi: true };\n }\n\n // API returned non-ok status (e.g., 429, 500) — fall back to cached workspaces\n if (process.env.NODE_ENV === 'development') {\n console.warn('API returned error status:', res.status);\n }\n // Try full workspace cache first (has proper UUIDs)\n try {\n const cachedRaw = localStorage.getItem('pb_workspaces_cache');\n if (cachedRaw) {\n const cachedWorkspaces = JSON.parse(cachedRaw) as Workspace[];\n if (cachedWorkspaces.length > 0) {\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n }\n } catch {\n // Corrupted cache — fall through\n }\n // Legacy fallback: slugs-only cache (no UUIDs available — use connection error state)\n const cachedSlugs = JSON.parse(\n localStorage.getItem('workspaces') || '[]',\n ) as string[];\n if (cachedSlugs.length > 0) {\n const cachedWorkspaces: Workspace[] = cachedSlugs.map((slug) => ({\n tenant: {\n id: slug,\n slug,\n businessName: slug,\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n },\n role: 'owner' as const,\n }));\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n return { workspaces: [], fromApi: false };\n } catch (error) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to fetch workspaces from API:', error);\n }\n // Network error or other failure\n return { workspaces: [], fromApi: false };\n }\n }, []);\n\n useEffect(() => {\n const checkAuth = async () => {\n // Local-dev open access: bypass auth so any page renders directly.\n if (DEV_OPEN_ACCESS) {\n const slug = process.env.NEXT_PUBLIC_DEV_TENANT || 'demo';\n const tenant = {\n id: slug,\n slug,\n businessName: 'Dev Business',\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n };\n const workspace = { tenant, role: 'owner' as const };\n setUser({\n id: 'dev-user',\n email: 'dev@local',\n name: 'Dev',\n emailVerified: true,\n mfaEnabled: false,\n organizationId: '',\n workspaces: [workspace],\n currentWorkspace: workspace,\n });\n setLoading(false);\n return;\n }\n\n // The session is the HttpOnly cookie, which this code cannot see.\n // The cached account only says this browser has signed in before; the\n // calls below are the proof, and a 401 ends the session.\n removeLegacyToken();\n const pbUser = localStorage.getItem('pb_user');\n const storedOrgId = localStorage.getItem('organizationId');\n\n // No user data = invalid session\n if (!pbUser) {\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n let userData: {\n id: string;\n email: string;\n name?: string;\n avatarUrl?: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string;\n } | null = null;\n try {\n userData = JSON.parse(pbUser);\n } catch {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Invalid user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n if (!userData?.email) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('No email in user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n // ── Optimistic start ──────────────────────────────────────────────────\n // Everything tenant-scoped is gated on `loading`, so clearing it here\n // lets the dashboard begin fetching against the workspace we already\n // know, instead of idling through a round trip first. The API call below\n // still runs and reconciles; this only decides when the app is allowed\n // to START, not what is true.\n const cachedWorkspace = readCachedWorkspace();\n if (cachedWorkspace && !cancelled) {\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId:\n storedOrgId ||\n userData?.organizationId ||\n cachedWorkspace.tenant.organizationId ||\n '',\n workspaces: [cachedWorkspace],\n currentWorkspace: cachedWorkspace,\n });\n setLoading(false);\n }\n\n try {\n const freshAccount = await apiFetch<typeof userData>('/accounts/me');\n if (freshAccount) {\n userData = freshAccount;\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch {\n // Keep the cached account when the API is temporarily unavailable.\n }\n\n // Load workspaces — may throw AUTH_EXPIRED if session is invalid\n let result;\n try {\n result = await loadWorkspaces();\n } catch (e: unknown) {\n if (e instanceof Error && e.message === 'AUTH_EXPIRED') return;\n throw e;\n }\n if (cancelled) return;\n const workspaces = result.workspaces;\n setConnectionError(!result.fromApi && workspaces.length === 0);\n\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n\n // Find current workspace or use first one\n let currentWorkspace = resolveActiveWorkspace(workspaces, result.fromApi);\n\n // If no workspaces but we have signup tenant, fetch it\n if (workspaces.length === 0 && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n persistWorkspaceList(workspaces);\n }\n }\n\n // Write the resolution back before rendering it. This is the reload path\n // that fell through with a stored slug the account no longer has: it\n // resolved `workspaces[0]` and left the cache naming the old business.\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId - must be a valid UUID\n const userOrgId =\n storedOrgId ||\n userData?.organizationId ||\n currentWorkspace?.tenant?.organizationId;\n\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n setLoading(false);\n };\n\n let cancelled = false;\n checkAuth();\n return () => {\n cancelled = true;\n };\n }, [fetchTenant, loadWorkspaces]);\n\n // Establish the client session from an auth payload (the /accounts/login\n // response, or a Google callback result of the same shape). Both the email\n // and Google doors funnel through here so they hydrate identically.\n /** Which door an account came through, remembered for the next visit. */\n // `method` is the door the account actually came through. It is stored on the\n // recognition hint so /login can offer that same door on return: greeting a\n // Google user by name and then emailing them a code is worse than not\n // recognising them at all.\n const establishSession = async (\n data: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n const { account, tenants, organizationId } = data;\n\n // Build workspaces directly from enriched tenant data in the payload\n const workspaces: Workspace[] = [];\n for (const t of tenants || []) {\n workspaces.push({\n tenant: {\n id: t.id,\n slug: t.slug,\n businessName: t.name,\n businessLogo:\n t.businessLogo ||\n (t.settings as { logo?: { url?: string } } | null | undefined)?.logo\n ?.url ||\n undefined,\n contactEmail: t.contactEmail || undefined,\n contactPhone: t.contactPhone || undefined,\n customDomain: t.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.status as 'active' | 'suspended' | 'pending' | 'deleted') ||\n 'active',\n settings: t.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n });\n }\n\n // Resume the business the owner last worked in (matches the reload path),\n // falling back to the first workspace. Without this, a multi-business owner\n // whose session expired lands in an arbitrary business, not where they left.\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n // `pb_last_workspace` deliberately survives logout (like pb_last_account),\n // so a returning owner resumes into the business they were last in rather\n // than an arbitrary first workspace — and the \"Welcome back\" card names it.\n let currentWorkspace = resolveActiveWorkspace(workspaces, true);\n if (!currentWorkspace && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n }\n }\n\n // Get organizationId from response, or from first tenant\n const userOrgId =\n organizationId ||\n tenants?.[0]?.organizationId ||\n currentWorkspace?.tenant?.organizationId ||\n null;\n\n // Save auth data\n localStorage.setItem('pb_account_id', account.id);\n localStorage.setItem('pb_user', JSON.stringify(account));\n localStorage.setItem('userEmail', account.email);\n persistWorkspaceList(workspaces);\n if (userOrgId) {\n localStorage.setItem('organizationId', userOrgId);\n }\n persistActiveWorkspace(currentWorkspace);\n\n // Recognition hint: a small, non-sensitive record of the last account to\n // sign in on this device. Unlike the session keys above it deliberately\n // survives logout, so /login can greet a returning owner by name. It holds\n // no token — the emailed code is still the only proof of identity.\n try {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n businessName:\n currentWorkspace?.tenant.businessName ||\n tenants?.[0]?.name ||\n undefined,\n method,\n connection: method === 'connection' ? connection : undefined,\n }),\n );\n } catch {\n // storage full / disabled — recognition is a convenience, never a gate\n }\n\n setUser({\n id: account.id,\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n emailVerified: account.emailVerified ?? false,\n mfaEnabled: account.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n };\n\n const login = async (email: string, password: string) => {\n setLoading(true);\n try {\n // Call real login API\n const res = await fetch(`${getApiUrl()}/accounts/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n credentials: 'include', // Required to receive and send cookies\n });\n\n if (!res.ok) {\n const error = await res\n .json()\n .catch(() => ({ message: 'Login failed' }));\n throw new Error(error.message || 'Invalid email or password');\n }\n\n const data = await res.json();\n await establishSession(data);\n } finally {\n setLoading(false);\n }\n };\n\n // The Google door. The callback page already exchanged the OAuth code for a\n // session payload; here we just hydrate from it, same as email login.\n const loginWithGoogle = async (payload: AuthSessionPayload) => {\n setLoading(true);\n try {\n await establishSession(payload, 'google');\n } finally {\n setLoading(false);\n }\n };\n\n // The passwordless door. Verify already exchanged the emailed code for a\n // session payload; hydrate from it identically.\n const loginWithCode = async (\n payload: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n setLoading(true);\n try {\n // The door is passed through so a passkey sign-in is REMEMBERED as one.\n // Without it the recognition card would greet a passkey user and then\n // email them a code — the exact mismatch the Google branch exists to\n // avoid, just with a different door.\n await establishSession(payload, method, connection);\n } finally {\n setLoading(false);\n }\n };\n\n const logout = async () => {\n resetPostHogIdentity();\n // The key list moved to `lib/end-session` so the invite screen can end a\n // session too, without a second copy of it drifting from this one.\n await endSession();\n setUser(null);\n };\n\n const switchWorkspace = useCallback(\n async (tenantSlug: string) => {\n if (!user) return;\n\n const workspace = user.workspaces.find(\n (w) => w.tenant.slug === tenantSlug,\n );\n if (workspace) {\n persistActiveWorkspace(workspace);\n try {\n const prev = JSON.parse(\n localStorage.getItem('pb_last_account') || '{}',\n );\n if (prev.email) {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n ...prev,\n businessName: workspace.tenant.businessName,\n }),\n );\n }\n } catch {\n // recognition hint is a convenience, never a gate\n }\n setUser((prev) =>\n prev ? { ...prev, currentWorkspace: workspace } : null,\n );\n }\n },\n [user],\n );\n\n const createWorkspace = useCallback(\n async (input: CreateWorkspaceInput): Promise<Tenant> => {\n const {\n businessName,\n templateId,\n currency,\n intents,\n isSolo,\n category,\n salesChannel,\n address,\n phone,\n country,\n logoFile,\n } = input;\n\n const slug =\n input.slug ||\n businessName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/(^-|-$)/g, '');\n\n // organizationId is optional — server resolves it from account membership\n // when omitted; cache it for later calls.\n let organizationId = user?.organizationId || '';\n if (!organizationId) {\n const orgs = await accountsApi.getMyOrganizations();\n if (!orgs || orgs.length === 0) {\n throw new Error('No organization found. Please sign up first.');\n }\n organizationId = orgs[0].id;\n localStorage.setItem('organizationId', organizationId);\n setUser((prev) => (prev ? { ...prev, organizationId } : null));\n }\n\n // 1. Provision — the single business-creation call in the app.\n const result = await apiFetch<{ tenant: Tenant }>(\n '/onboarding/provision',\n {\n method: 'POST',\n body: JSON.stringify({\n organizationId,\n businessName,\n slug,\n // Only sent when the merchant actually chose a look. Omitted, the\n // API derives it from `intents` against the templates table it\n // owns; named, an unknown id is rejected rather than replaced.\n ...(templateId && { templateId }),\n ...(category && { category }),\n ...(intents?.length && { intents }),\n ...(isSolo != null && { isSolo }),\n ...(currency && { currency }),\n // phone is part of the provision contract — the usecase stores it\n // canonically on tenant.phone AND settings.phone, so send it here\n // rather than patching settings after (which would land it in the\n // wrong place and nothing would read it).\n ...(phone?.trim() && { phone: phone.trim() }),\n }),\n },\n );\n\n let tenant = result.tenant;\n\n // Make the new tenant the active one BEFORE the logo upload: the media\n // proxy tags the upload with `x-tenant-slug` read from stored tenant, so\n // it must point at this fresh tenant for the file to land in its namespace.\n persistActiveWorkspace({ tenant, role: 'owner' });\n\n // 2a. Upload the logo, if one was picked. The media endpoint needs the\n // tenant to exist, so this only runs post-provision. Non-fatal: a\n // failed upload must never fail business creation.\n let logoUrl: string | undefined;\n if (logoFile) {\n try {\n const uploaded = await uploadImage(logoFile);\n logoUrl = uploaded.url;\n } catch {\n // non-fatal — the merchant can add a logo later from the editor\n }\n }\n\n // 2b. Persist fields NOT in the provision contract onto settings (server\n // deep-merges). These are set here on a fresh tenant, so a shallow set\n // is safe. `logo` mirrors the editor's brand shape ({ url }), from which\n // businessLogo is synthesized. Refinements, never gates — a failure\n const settingsPatch: Record<string, unknown> = {};\n if (salesChannel) settingsPatch.salesChannel = salesChannel;\n if (intents?.length) settingsPatch.selectedIntents = intents;\n if (country) settingsPatch.country = country;\n if (address?.trim()) settingsPatch.contact = { address: address.trim() };\n if (logoUrl) settingsPatch.logo = { url: logoUrl };\n if (Object.keys(settingsPatch).length > 0) {\n try {\n await tenantApi.updateSettings(slug, settingsPatch);\n // Reflect what we just wrote onto the in-memory tenant so a soft-nav\n // dashboard reads fresh settings without a round-trip. (We merge our\n // known patch rather than the API's return, whose Tenant shape differs\n // from auth-context's.)\n tenant = {\n ...tenant,\n businessLogo: logoUrl ?? tenant.businessLogo,\n settings: { ...(tenant.settings ?? {}), ...settingsPatch },\n };\n } catch {\n // non-fatal — these refine, they don't gate\n }\n }\n\n // 3. Register in context + make active, so the dashboard renders it\n // immediately instead of bouncing back to onboarding.\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n\n return tenant;\n },\n [user],\n );\n\n const duplicateWorkspace = useCallback(\n async (tenantId: string, businessName?: string): Promise<Tenant> => {\n const result = await apiFetch<{ tenant: Tenant }>(\n `/onboarding/${tenantId}/duplicate`,\n {\n method: 'POST',\n body: JSON.stringify({ businessName }),\n },\n );\n\n const tenant = result.tenant;\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n return tenant;\n },\n [user],\n );\n\n const deleteWorkspace = useCallback(\n (tenantSlug: string) => {\n // Immediately remove the deleted workspace from in-memory state\n // so no component re-renders with a stale tenant reference\n const remaining = (user?.workspaces || []).filter(\n (w) => w.tenant.slug !== tenantSlug,\n );\n const next = remaining[0] || null;\n\n // Update localStorage to reflect the new workspace selection\n persistActiveWorkspace(next);\n if (next) {\n persistWorkspaceList(remaining);\n } else {\n localStorage.removeItem('workspaces');\n localStorage.removeItem('pb_workspaces_cache');\n }\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: remaining,\n currentWorkspace: next,\n }\n : null,\n );\n },\n [user?.workspaces],\n );\n\n const refreshUser = useCallback(async () => {\n if (!user) return;\n\n try {\n // Re-fetch account from API to get latest data (e.g. emailVerified)\n let userData: Record<string, unknown> | null = null;\n {\n try {\n const freshAccount = await apiFetch<Record<string, unknown>>(\n '/accounts/me',\n );\n if (freshAccount) {\n userData = freshAccount;\n // Update localStorage with fresh data\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch (err) {\n /**\n * Two very different failures were being treated as one.\n *\n * The server being unreachable is temporary, and falling back to the\n * cached account is right: the merchant keeps working.\n *\n * The server answering \"this account does not exist\" is not\n * temporary. Swallowing that left a dead session rendering as a\n * signed-in one, reading a cached copy of a user the API has never\n * heard of. Nothing surfaced until a write went deep enough to check\n * — provisioning a business then failed with \"You do not have access\n * to this organization\", which is true, unactionable, and describes\n * the wrong problem.\n */\n const status = (err as { status?: number })?.status;\n if (status === 401 || status === 403 || status === 404) {\n clearStoredAuth();\n if (typeof window !== 'undefined') window.location.href = '/login';\n return;\n }\n // Unreachable, not refused: keep going on what we have.\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) userData = JSON.parse(pbUser);\n }\n }\n\n if (!userData) return;\n\n // Reload workspaces from API\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n const currentWorkspace = resolveActiveWorkspace(\n workspaces,\n result.fromApi,\n );\n\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId from workspace if not stored\n const storedOrgId = localStorage.getItem('organizationId');\n const orgId =\n storedOrgId ||\n currentWorkspace?.tenant?.organizationId ||\n user.organizationId;\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n name: (userData?.name as string) || prev.name,\n email: (userData?.email as string) || prev.email,\n avatarUrl: (userData?.avatarUrl as string) || prev.avatarUrl,\n emailVerified:\n (userData?.emailVerified as boolean) ?? prev.emailVerified,\n mfaEnabled: (userData?.mfaEnabled as boolean) ?? prev.mfaEnabled,\n workspaces,\n currentWorkspace,\n organizationId: orgId,\n }\n : null,\n );\n } catch {\n // Ignore errors\n }\n }, [user, loadWorkspaces]);\n\n const refreshTenant = useCallback(async () => {\n const currentSlug = user?.currentWorkspace?.tenant?.slug;\n const currentWorkspace = user?.currentWorkspace;\n const workspaces = user?.workspaces;\n\n if (!currentSlug || !currentWorkspace || !workspaces) return;\n\n const freshTenant = await fetchTenant(currentSlug);\n if (freshTenant) {\n const updatedWorkspace = { ...currentWorkspace, tenant: freshTenant };\n const updatedWorkspaces = workspaces.map((w) =>\n w.tenant.slug === freshTenant.slug ? updatedWorkspace : w,\n );\n persistActiveWorkspace(updatedWorkspace);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: updatedWorkspace,\n }\n : null,\n );\n }\n }, [user?.currentWorkspace, user?.workspaces, fetchTenant]);\n\n const retryConnection = useCallback(async () => {\n setLoading(true);\n setConnectionError(false);\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n\n if (result.fromApi && workspaces.length > 0) {\n const currentWorkspace = resolveActiveWorkspace(workspaces, true);\n persistActiveWorkspace(currentWorkspace);\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces,\n currentWorkspace,\n }\n : null,\n );\n } else if (!result.fromApi) {\n setConnectionError(true);\n }\n setLoading(false);\n }, [loadWorkspaces]);\n\n return (\n <AuthContext.Provider\n value={{\n user,\n tenant: user?.currentWorkspace?.tenant ?? null,\n workspaces: user?.workspaces ?? [],\n loading,\n connectionError,\n retryConnection,\n login,\n loginWithGoogle,\n loginWithCode,\n logout,\n refreshTenant,\n refreshUser,\n switchWorkspace,\n createWorkspace,\n duplicateWorkspace,\n deleteWorkspace,\n }}\n >\n {children}\n </AuthContext.Provider>\n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (context === undefined) {\n throw new Error('useAuth must be used within an AuthProvider');\n }\n return context;\n}\n\n/**\n * The current business, or null when there is no provider above.\n *\n * For shared primitives, which cannot assume they are inside an AuthProvider:\n * the root layout mounts none, each section mounts its own, and `useAuth`\n * throws. A page frame that threw would take the whole screen white rather\n * than degrade, which is the failure /auth already shipped once.\n */\nexport function useOptionalTenant(): Tenant | null {\n return useContext(AuthContext)?.tenant ?? null;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1 @@
1
+ export declare function removeLegacyToken(): void;
@@ -0,0 +1,11 @@
1
+ const LEGACY_TOKEN_KEY = "pb_token";
2
+ function removeLegacyToken() {
3
+ try {
4
+ localStorage.removeItem(LEGACY_TOKEN_KEY);
5
+ } catch {
6
+ }
7
+ }
8
+ export {
9
+ removeLegacyToken
10
+ };
11
+ //# sourceMappingURL=stored-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stored-token.ts"],"sourcesContent":["/**\n * The key older versions kept the session token under. The session is now only\n * the HttpOnly `pb_auth_token` cookie, so a copy here is a credential any script\n * on the page could read. Nothing writes it; this removes one left behind.\n */\nconst LEGACY_TOKEN_KEY = 'pb_token';\n\nexport function removeLegacyToken(): void {\n try {\n localStorage.removeItem(LEGACY_TOKEN_KEY);\n } catch {\n // Storage blocked: nothing was kept there.\n }\n}\n"],"mappings":"AAKA,MAAM,mBAAmB;AAElB,SAAS,oBAA0B;AACxC,MAAI;AACF,iBAAa,WAAW,gBAAgB;AAAA,EAC1C,QAAQ;AAAA,EAER;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/lib",
3
- "version": "0.13.0-next.64",
3
+ "version": "0.13.0-next.65",
4
4
  "description": "Box dashboard foundation: API client, contexts, types, intent config, and utilities.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,