keystone-design-bootstrap 1.0.103 → 1.0.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/blog-post-vWzW8yFb.d.ts +50 -0
- package/dist/contexts/index.d.ts +13 -0
- package/dist/contexts/index.js +173 -0
- package/dist/contexts/index.js.map +1 -0
- package/dist/design_system/components/DynamicFormFields.d.ts +15 -0
- package/dist/design_system/components/DynamicFormFields.js +5176 -0
- package/dist/design_system/components/DynamicFormFields.js.map +1 -0
- package/dist/design_system/elements/index.d.ts +383 -0
- package/dist/design_system/elements/index.js +4007 -0
- package/dist/design_system/elements/index.js.map +1 -0
- package/dist/design_system/logo/keystone-logo.d.ts +6 -0
- package/dist/design_system/logo/keystone-logo.js +145 -0
- package/dist/design_system/logo/keystone-logo.js.map +1 -0
- package/dist/design_system/sections/index.d.ts +233 -0
- package/dist/design_system/sections/index.js +20048 -0
- package/dist/design_system/sections/index.js.map +1 -0
- package/dist/form-C94A_PX_.d.ts +36 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +20608 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/component-registry.d.ts +13 -0
- package/dist/lib/component-registry.js +36 -0
- package/dist/lib/component-registry.js.map +1 -0
- package/dist/lib/hooks/index.d.ts +83 -0
- package/dist/lib/hooks/index.js +182 -0
- package/dist/lib/hooks/index.js.map +1 -0
- package/dist/lib/server-api.d.ts +67 -0
- package/dist/lib/server-api.js +195 -0
- package/dist/lib/server-api.js.map +1 -0
- package/dist/package-DeHKpQp7.d.ts +121 -0
- package/dist/photos-CmBdWiuZ.d.ts +27 -0
- package/dist/themes/index.d.ts +17 -0
- package/dist/themes/index.js +29 -0
- package/dist/themes/index.js.map +1 -0
- package/dist/tracking/index.d.ts +254 -0
- package/dist/tracking/index.js +587 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/types/index.d.ts +313 -0
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cx.d.ts +15 -0
- package/dist/utils/cx.js +18 -0
- package/dist/utils/cx.js.map +1 -0
- package/dist/utils/gradient-placeholder.d.ts +7 -0
- package/dist/utils/gradient-placeholder.js +59 -0
- package/dist/utils/gradient-placeholder.js.map +1 -0
- package/dist/utils/is-react-component.d.ts +21 -0
- package/dist/utils/is-react-component.js +20 -0
- package/dist/utils/is-react-component.js.map +1 -0
- package/dist/utils/markdown-toc.d.ts +14 -0
- package/dist/utils/markdown-toc.js +29 -0
- package/dist/utils/markdown-toc.js.map +1 -0
- package/dist/utils/phone-helpers.d.ts +24 -0
- package/dist/utils/phone-helpers.js +26 -0
- package/dist/utils/phone-helpers.js.map +1 -0
- package/dist/utils/photo-helpers.d.ts +38 -0
- package/dist/utils/photo-helpers.js +41 -0
- package/dist/utils/photo-helpers.js.map +1 -0
- package/dist/website-photos-Cl1YqAno.d.ts +21 -0
- package/package.json +1 -1
- package/src/design_system/portal/LoginForm.tsx +1 -24
- package/src/lib/consumer-session.ts +3 -1
- package/src/next/routes/consumer-auth.ts +49 -124
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/markdown-toc.ts"],"sourcesContent":["/**\n * Utility functions for extracting table of contents from markdown\n */\n\nexport interface TableOfContentsItem {\n id: string;\n title: string;\n level: number;\n}\n\n/**\n * Generate a URL-friendly ID from a heading text\n */\nfunction slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '') // Remove special characters\n .replace(/[\\s_-]+/g, '-') // Replace spaces and underscores with hyphens\n .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens\n}\n\n/**\n * Extract headings from markdown content and generate table of contents\n */\nexport function extractTableOfContents(markdown: string): TableOfContentsItem[] {\n if (!markdown) return [];\n\n const toc: TableOfContentsItem[] = [];\n const lines = markdown.split('\\n');\n\n for (const line of lines) {\n // Match markdown headings (##, ###, etc.)\n const headingMatch = line.match(/^(#{1,6})\\s+(.+)$/);\n if (headingMatch) {\n const level = headingMatch[1].length;\n const title = headingMatch[2].trim();\n \n // Only include h2 and h3 headings in TOC\n if (level >= 2 && level <= 3) {\n const id = slugify(title);\n toc.push({\n id,\n title,\n level,\n });\n }\n }\n }\n\n return toc;\n}\n\n\n"],"mappings":";AAaA,SAAS,QAAQ,MAAsB;AACrC,SAAO,KACJ,YAAY,EACZ,KAAK,EACL,QAAQ,aAAa,EAAE,EACvB,QAAQ,YAAY,GAAG,EACvB,QAAQ,YAAY,EAAE;AAC3B;AAKO,SAAS,uBAAuB,UAAyC;AAC9E,MAAI,CAAC,SAAU,QAAO,CAAC;AAEvB,QAAM,MAA6B,CAAC;AACpC,QAAM,QAAQ,SAAS,MAAM,IAAI;AAEjC,aAAW,QAAQ,OAAO;AAExB,UAAM,eAAe,KAAK,MAAM,mBAAmB;AACnD,QAAI,cAAc;AAChB,YAAM,QAAQ,aAAa,CAAC,EAAE;AAC9B,YAAM,QAAQ,aAAa,CAAC,EAAE,KAAK;AAGnC,UAAI,SAAS,KAAK,SAAS,GAAG;AAC5B,cAAM,KAAK,QAAQ,KAAK;AACxB,YAAI,KAAK;AAAA,UACP;AAAA,UACA;AAAA,UACA;AAAA,QACF,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List of countries with their respective country code, flag, phone code, and phone mask.
|
|
3
|
+
*/
|
|
4
|
+
declare const countries: ({
|
|
5
|
+
name: string;
|
|
6
|
+
code: string;
|
|
7
|
+
flag: string;
|
|
8
|
+
phoneCode: string;
|
|
9
|
+
phoneMask: string;
|
|
10
|
+
} | {
|
|
11
|
+
name: string;
|
|
12
|
+
code: string;
|
|
13
|
+
flag: string;
|
|
14
|
+
phoneCode: string;
|
|
15
|
+
phoneMask?: undefined;
|
|
16
|
+
})[];
|
|
17
|
+
|
|
18
|
+
type Country = (typeof countries)[0];
|
|
19
|
+
/** Get national-format mask from country by stripping the country code prefix (e.g. "+1 (###) ###-####" → "(###) ###-####"). */
|
|
20
|
+
declare function getNationalMask(country: Country | undefined): string;
|
|
21
|
+
/** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */
|
|
22
|
+
declare function formatDigitsToMask(digits: string, mask: string): string;
|
|
23
|
+
|
|
24
|
+
export { formatDigitsToMask, getNationalMask };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/utils/phone-helpers.ts
|
|
2
|
+
function getNationalMask(country) {
|
|
3
|
+
if (!(country == null ? void 0 : country.phoneMask)) return "";
|
|
4
|
+
const code = country.phoneCode.startsWith("+") ? country.phoneCode : `+${country.phoneCode}`;
|
|
5
|
+
const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6
|
+
return country.phoneMask.replace(new RegExp(`^\\s*${escaped}[\\s-]*`), "").trim();
|
|
7
|
+
}
|
|
8
|
+
function formatDigitsToMask(digits, mask) {
|
|
9
|
+
if (digits.length === 0) return "";
|
|
10
|
+
let i = 0;
|
|
11
|
+
let out = "";
|
|
12
|
+
for (const c of mask) {
|
|
13
|
+
if (c === "#") {
|
|
14
|
+
if (i < digits.length) out += digits[i++];
|
|
15
|
+
else break;
|
|
16
|
+
} else if (i < digits.length) {
|
|
17
|
+
out += c;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
formatDigitsToMask,
|
|
24
|
+
getNationalMask
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=phone-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/phone-helpers.ts"],"sourcesContent":["import type countries from './countries';\n\ntype Country = (typeof countries)[0];\n\n/** Get national-format mask from country by stripping the country code prefix (e.g. \"+1 (###) ###-####\" → \"(###) ###-####\"). */\nexport function getNationalMask(country: Country | undefined): string {\n if (!country?.phoneMask) return '';\n const code = country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`;\n const escaped = code.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return country.phoneMask.replace(new RegExp(`^\\\\s*${escaped}[\\\\s-]*`), '').trim();\n}\n\n/** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */\nexport function formatDigitsToMask(digits: string, mask: string): string {\n if (digits.length === 0) return '';\n let i = 0;\n let out = '';\n for (const c of mask) {\n if (c === '#') {\n if (i < digits.length) out += digits[i++];\n else break;\n } else if (i < digits.length) {\n out += c;\n }\n }\n return out;\n}\n"],"mappings":";AAKO,SAAS,gBAAgB,SAAsC;AACpE,MAAI,EAAC,mCAAS,WAAW,QAAO;AAChC,QAAM,OAAO,QAAQ,UAAU,WAAW,GAAG,IAAI,QAAQ,YAAY,IAAI,QAAQ,SAAS;AAC1F,QAAM,UAAU,KAAK,QAAQ,uBAAuB,MAAM;AAC1D,SAAO,QAAQ,UAAU,QAAQ,IAAI,OAAO,QAAQ,OAAO,SAAS,GAAG,EAAE,EAAE,KAAK;AAClF;AAGO,SAAS,mBAAmB,QAAgB,MAAsB;AACvE,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,MAAI,IAAI;AACR,MAAI,MAAM;AACV,aAAW,KAAK,MAAM;AACpB,QAAI,MAAM,KAAK;AACb,UAAI,IAAI,OAAO,OAAQ,QAAO,OAAO,GAAG;AAAA,UACnC;AAAA,IACP,WAAW,IAAI,OAAO,QAAQ;AAC5B,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { P as PhotoAttachment } from '../photos-CmBdWiuZ.js';
|
|
2
|
+
import { W as WebsitePhotos } from '../website-photos-Cl1YqAno.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper functions for extracting photo URLs from photo associations
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.
|
|
10
|
+
*/
|
|
11
|
+
declare function isVideoUrl(url: string | null | undefined): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Get the best available photo URL from a photos array
|
|
14
|
+
* Priority: featured photo > first photo > fallback
|
|
15
|
+
*/
|
|
16
|
+
declare function getPhotoUrl(photos?: PhotoAttachment[]): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Get avatar URL for team members or authors.
|
|
19
|
+
* Returns a URL only when there is a real photo in attachments; otherwise null.
|
|
20
|
+
* Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.
|
|
21
|
+
*/
|
|
22
|
+
/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */
|
|
23
|
+
declare function getAvatarUrl(photos?: PhotoAttachment[], _fallbackId?: number | string, _name?: string): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Get featured image URL for blog posts
|
|
26
|
+
*/
|
|
27
|
+
declare function getFeaturedImageUrl(photos?: PhotoAttachment[]): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Get logo URL from website_photos API (which aggregates from account_photos)
|
|
30
|
+
*
|
|
31
|
+
* The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',
|
|
32
|
+
* with industry fallback. This is the primary and only source for logos.
|
|
33
|
+
*
|
|
34
|
+
* Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)
|
|
35
|
+
*/
|
|
36
|
+
declare function getLogoUrl(websitePhotos?: WebsitePhotos | null): string | undefined;
|
|
37
|
+
|
|
38
|
+
export { PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl, isVideoUrl };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/utils/photo-helpers.ts
|
|
2
|
+
var VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv", ".flv", ".wmv"];
|
|
3
|
+
function isVideoUrl(url) {
|
|
4
|
+
var _a;
|
|
5
|
+
if (!url || typeof url !== "string") return false;
|
|
6
|
+
const path = (_a = url.split("?")[0]) != null ? _a : "";
|
|
7
|
+
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
8
|
+
return VIDEO_EXTENSIONS.includes(ext);
|
|
9
|
+
}
|
|
10
|
+
function getPhotoUrl(photos) {
|
|
11
|
+
if (photos && photos.length > 0) {
|
|
12
|
+
const featuredPhoto = photos.find((pa) => pa.featured);
|
|
13
|
+
const photoToUse = featuredPhoto || photos[0];
|
|
14
|
+
const photo = photoToUse.photo;
|
|
15
|
+
if (photo) {
|
|
16
|
+
return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function getAvatarUrl(photos, _fallbackId, _name) {
|
|
22
|
+
return getPhotoUrl(photos);
|
|
23
|
+
}
|
|
24
|
+
function getFeaturedImageUrl(photos) {
|
|
25
|
+
return getPhotoUrl(photos);
|
|
26
|
+
}
|
|
27
|
+
function getLogoUrl(websitePhotos) {
|
|
28
|
+
var _a;
|
|
29
|
+
if ((_a = websitePhotos == null ? void 0 : websitePhotos.logo) == null ? void 0 : _a.url) {
|
|
30
|
+
return websitePhotos.logo.url;
|
|
31
|
+
}
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
getAvatarUrl,
|
|
36
|
+
getFeaturedImageUrl,
|
|
37
|
+
getLogoUrl,
|
|
38
|
+
getPhotoUrl,
|
|
39
|
+
isVideoUrl
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=photo-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport type { PhotoAttachment } from '../types/api/photos';\n\n/** Video file extensions treated as video (match backend ExternalPhotoService.video_url?) */\nconst VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.m4v', '.avi', '.mkv', '.flv', '.wmv'];\n\n/**\n * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.\n */\nexport function isVideoUrl(url: string | null | undefined): boolean {\n if (!url || typeof url !== 'string') return false;\n const path = url.split('?')[0] ?? '';\n const ext = path.slice(path.lastIndexOf('.')).toLowerCase();\n return VIDEO_EXTENSIONS.includes(ext);\n}\n\n/**\n * Get the best available photo URL from a photos array\n * Priority: featured photo > first photo > fallback\n */\nexport function getPhotoUrl(\n photos?: PhotoAttachment[]\n): string | null {\n // Priority 1: Featured photo from photos array\n if (photos && photos.length > 0) {\n const featuredPhoto = photos.find(pa => pa.featured);\n const photoToUse = featuredPhoto || photos[0];\n const photo = photoToUse.photo;\n \n if (photo) {\n return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;\n }\n }\n \n // Fallback (gradient or null)\n return null;\n}\n\n/**\n * Get avatar URL for team members or authors.\n * Returns a URL only when there is a real photo in attachments; otherwise null.\n * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.\n */\n/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */\n/* eslint-disable @typescript-eslint/no-unused-vars -- _fallbackId, _name reserved for API */\nexport function getAvatarUrl(\n photos?: PhotoAttachment[],\n _fallbackId?: number | string,\n _name?: string\n): string | null {\n return getPhotoUrl(photos);\n}\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\n/**\n * Get featured image URL for blog posts\n */\nexport function getFeaturedImageUrl(\n photos?: PhotoAttachment[]\n): string | null {\n return getPhotoUrl(photos);\n}\n\n/**\n * Get logo URL from website_photos API (which aggregates from account_photos)\n * \n * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',\n * with industry fallback. This is the primary and only source for logos.\n * \n * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)\n */\nexport function getLogoUrl(\n websitePhotos?: WebsitePhotos | null\n): string | undefined {\n if (websitePhotos?.logo?.url) {\n return websitePhotos.logo.url;\n }\n \n return undefined;\n}\n\n"],"mappings":";AAUA,IAAM,mBAAmB,CAAC,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ,MAAM;AAKlF,SAAS,WAAW,KAAyC;AAfpE;AAgBE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,QAAO,SAAI,MAAM,GAAG,EAAE,CAAC,MAAhB,YAAqB;AAClC,QAAM,MAAM,KAAK,MAAM,KAAK,YAAY,GAAG,CAAC,EAAE,YAAY;AAC1D,SAAO,iBAAiB,SAAS,GAAG;AACtC;AAMO,SAAS,YACd,QACe;AAEf,MAAI,UAAU,OAAO,SAAS,GAAG;AAC/B,UAAM,gBAAgB,OAAO,KAAK,QAAM,GAAG,QAAQ;AACnD,UAAM,aAAa,iBAAiB,OAAO,CAAC;AAC5C,UAAM,QAAQ,WAAW;AAEzB,QAAI,OAAO;AACT,aAAO,MAAM,aAAa,MAAM,cAAc,MAAM,iBAAiB,MAAM,gBAAgB;AAAA,IAC7F;AAAA,EACF;AAGA,SAAO;AACT;AASO,SAAS,aACd,QACA,aACA,OACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAMO,SAAS,oBACd,QACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAUO,SAAS,WACd,eACoB;AA/EtB;AAgFE,OAAI,oDAAe,SAAf,mBAAqB,KAAK;AAC5B,WAAO,cAAc,KAAK;AAAA,EAC5B;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface WebsitePhoto {
|
|
2
|
+
id: number;
|
|
3
|
+
url: string;
|
|
4
|
+
thumbnail_url?: string;
|
|
5
|
+
medium_url?: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
source: 'account' | 'industry';
|
|
8
|
+
}
|
|
9
|
+
interface WebsitePhotos {
|
|
10
|
+
logo?: WebsitePhoto | null;
|
|
11
|
+
favicon?: WebsitePhoto | null;
|
|
12
|
+
hero?: WebsitePhoto | null;
|
|
13
|
+
contact?: WebsitePhoto | null;
|
|
14
|
+
about?: WebsitePhoto | null;
|
|
15
|
+
careers?: WebsitePhoto | null;
|
|
16
|
+
preview_image?: WebsitePhoto | null;
|
|
17
|
+
stock_photos?: WebsitePhoto[];
|
|
18
|
+
}
|
|
19
|
+
type WebsitePhotosResponse = WebsitePhotos;
|
|
20
|
+
|
|
21
|
+
export type { WebsitePhotos as W, WebsitePhoto as a, WebsitePhotosResponse as b };
|
package/package.json
CHANGED
|
@@ -204,30 +204,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
204
204
|
body: JSON.stringify({ phone: fullPhone, code }),
|
|
205
205
|
});
|
|
206
206
|
const result = await res.json().catch(() => ({}));
|
|
207
|
-
if (!res.ok) {
|
|
208
|
-
setError(result.error || 'That code is invalid or expired.');
|
|
209
|
-
captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'verification_failed' });
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Compatibility path: verify_code may directly authenticate and set the
|
|
214
|
-
// session cookie via the route handler (no passwordless_auth needed).
|
|
215
|
-
if (result.authenticated) {
|
|
216
|
-
await setPixelUserData({ email: result.email || undefined, phone: fullPhone });
|
|
217
|
-
firePixelEvent('Lead', undefined, result?.eventId);
|
|
218
|
-
captureEvent('portal_login_step_advanced', {
|
|
219
|
-
from_step: 'signin',
|
|
220
|
-
to_step: 'authenticated',
|
|
221
|
-
reason: 'verification_direct_auth',
|
|
222
|
-
});
|
|
223
|
-
captureEvent('portal_login_completed', { flow: 'signin' });
|
|
224
|
-
setResendAvailableAt(null);
|
|
225
|
-
setSecondsUntilResend(0);
|
|
226
|
-
if (onSuccess) onSuccess(); else router.refresh();
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (!result.verification_token) {
|
|
207
|
+
if (!res.ok || !result.verification_token) {
|
|
231
208
|
setError(result.error || 'That code is invalid or expired.');
|
|
232
209
|
captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'verification_failed' });
|
|
233
210
|
return;
|
|
@@ -8,8 +8,10 @@ export const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
|
|
|
8
8
|
import type { Consumer, ConversationSummary, Message, ContactSummary } from '../types/api/consumer';
|
|
9
9
|
export type { Consumer, ConversationSummary, Message, ContactSummary };
|
|
10
10
|
|
|
11
|
+
// AUTH_API_URL (when set) points consumer auth/session endpoints at a dedicated
|
|
12
|
+
// auth service (e.g. Heimdal); everything else keeps using API_URL.
|
|
11
13
|
function getApiUrl(): string {
|
|
12
|
-
return process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
14
|
+
return process.env.AUTH_API_URL || process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
async function consumerFetch<T>(path: string, token: string): Promise<T | null> {
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Env (server-side only):
|
|
13
13
|
* - API_URL (default: http://localhost:3000/api/v1)
|
|
14
|
+
* - AUTH_API_URL (optional) — overrides API_URL for consumer auth/session endpoints.
|
|
15
|
+
* Set this when auth lives on a separate service (e.g. Heimdal) from the data API.
|
|
14
16
|
* - API_KEY
|
|
15
|
-
* - CONSUMER_AUTH_URL (optional; overrides passwordless routes base, e.g.
|
|
16
|
-
* https://www.keystone.app/api/consumer)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
|
|
@@ -25,19 +25,13 @@ type NextResponseLike = { json: (body: unknown, init?: ResponseInit) => any };
|
|
|
25
25
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
|
26
26
|
|
|
27
27
|
function getApiUrl(): string {
|
|
28
|
-
return process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
28
|
+
return process.env.AUTH_API_URL || process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function getApiKey(): string {
|
|
32
32
|
return process.env.API_KEY || '';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function getPasswordlessBaseUrl(): string {
|
|
36
|
-
const override = (process.env.CONSUMER_AUTH_URL || '').trim();
|
|
37
|
-
if (override) return override.replace(/\/+$/, '');
|
|
38
|
-
return `${getApiUrl().replace(/\/+$/, '')}/consumer/auth`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
function apiHeaders(request?: Request): Record<string, string> {
|
|
42
36
|
const key = getApiKey();
|
|
43
37
|
return {
|
|
@@ -47,69 +41,22 @@ function apiHeaders(request?: Request): Record<string, string> {
|
|
|
47
41
|
};
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
function
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function readStringField(obj: Record<string, unknown>, key: string): string | null {
|
|
55
|
-
const value = obj[key];
|
|
56
|
-
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function readNestedStringField(
|
|
60
|
-
obj: Record<string, unknown>,
|
|
61
|
-
parentKey: string,
|
|
62
|
-
key: string
|
|
63
|
-
): string | null {
|
|
64
|
-
const parent = obj[parentKey];
|
|
65
|
-
if (!parent || typeof parent !== 'object') return null;
|
|
66
|
-
const value = (parent as Record<string, unknown>)[key];
|
|
67
|
-
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
44
|
+
function asString(value: unknown): string | undefined {
|
|
45
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
68
46
|
}
|
|
69
47
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Walk a few levels and accept any non-empty string on keys containing "token"/"jwt".
|
|
83
|
-
const queue: Array<{ value: unknown; depth: number }> = [{ value: data, depth: 0 }];
|
|
84
|
-
const visited = new Set<object>();
|
|
85
|
-
while (queue.length > 0) {
|
|
86
|
-
const next = queue.shift();
|
|
87
|
-
if (!next) break;
|
|
88
|
-
const { value, depth } = next;
|
|
89
|
-
if (!value || typeof value !== 'object') continue;
|
|
90
|
-
if (visited.has(value as object)) continue;
|
|
91
|
-
visited.add(value as object);
|
|
92
|
-
|
|
93
|
-
const entries = Object.entries(value as Record<string, unknown>);
|
|
94
|
-
for (const [key, candidate] of entries) {
|
|
95
|
-
// Never treat verification tokens as auth/session JWTs.
|
|
96
|
-
if (/verification[_-]?token/i.test(key)) {
|
|
97
|
-
if (depth < 4 && candidate && typeof candidate === 'object') {
|
|
98
|
-
queue.push({ value: candidate, depth: depth + 1 });
|
|
99
|
-
}
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
const isTokenLikeKey = /token|jwt/i.test(key);
|
|
103
|
-
if (isTokenLikeKey && typeof candidate === 'string' && candidate.length > 0) {
|
|
104
|
-
return candidate;
|
|
105
|
-
}
|
|
106
|
-
if (depth < 4 && candidate && typeof candidate === 'object') {
|
|
107
|
-
queue.push({ value: candidate, depth: depth + 1 });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return null;
|
|
48
|
+
// Rails returns errors at the top level ({ error, code }); FastAPI backends
|
|
49
|
+
// (Heimdal) nest them under `detail`, where `error` is either a string or a
|
|
50
|
+
// { code, message } object for validation failures. Read all shapes, but only
|
|
51
|
+
// ever surface strings.
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
function upstreamError(json: any): { error?: string; code?: string; retry_in_seconds?: number } {
|
|
54
|
+
const err = json.error ?? json.detail?.error;
|
|
55
|
+
return {
|
|
56
|
+
error: asString(err) || asString(err?.message),
|
|
57
|
+
code: asString(json.code) || asString(json.detail?.code) || asString(err?.code),
|
|
58
|
+
retry_in_seconds: json.retry_in_seconds ?? json.detail?.retry_in_seconds,
|
|
59
|
+
};
|
|
113
60
|
}
|
|
114
61
|
|
|
115
62
|
// POST /api/consumer/initiate
|
|
@@ -133,8 +80,8 @@ async function handleInitiate(request: Request, NR: NextResponseLike): Promise<R
|
|
|
133
80
|
if (res.status === 404) return NR.json({ exists: false });
|
|
134
81
|
if (!res.ok) return NR.json({ exists: null });
|
|
135
82
|
|
|
136
|
-
const json = await res.json().catch(() => ({}))
|
|
137
|
-
const data =
|
|
83
|
+
const json = await res.json().catch(() => ({}));
|
|
84
|
+
const data = json.data as Record<string, unknown> | undefined;
|
|
138
85
|
return NR.json({
|
|
139
86
|
exists: true,
|
|
140
87
|
firstName: data?.first_name ?? undefined,
|
|
@@ -163,14 +110,14 @@ async function handleLogin(request: Request, NR: NextResponseLike): Promise<Resp
|
|
|
163
110
|
|
|
164
111
|
const json = await res.json().catch(() => ({}));
|
|
165
112
|
if (!res.ok) {
|
|
113
|
+
const err = upstreamError(json);
|
|
166
114
|
return NR.json(
|
|
167
|
-
{ error:
|
|
115
|
+
{ error: err.error || 'Login failed. Please try again.', code: err.code },
|
|
168
116
|
{ status: res.status }
|
|
169
117
|
);
|
|
170
118
|
}
|
|
171
119
|
|
|
172
|
-
const
|
|
173
|
-
const token = extractAuthToken(data);
|
|
120
|
+
const token = json.data?.token;
|
|
174
121
|
if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
|
|
175
122
|
|
|
176
123
|
const response = NR.json({});
|
|
@@ -206,16 +153,18 @@ async function handleSignup(request: Request, NR: NextResponseLike): Promise<Res
|
|
|
206
153
|
}),
|
|
207
154
|
});
|
|
208
155
|
|
|
209
|
-
const json = await res.json().catch(() => ({}))
|
|
156
|
+
const json = await res.json().catch(() => ({}));
|
|
210
157
|
if (!res.ok) {
|
|
211
|
-
return NR.json(
|
|
158
|
+
return NR.json(
|
|
159
|
+
{ error: upstreamError(json).error || 'Signup failed. Please try again.' },
|
|
160
|
+
{ status: res.status }
|
|
161
|
+
);
|
|
212
162
|
}
|
|
213
163
|
|
|
214
|
-
const
|
|
215
|
-
const token = extractAuthToken(data);
|
|
164
|
+
const token = json.data?.token;
|
|
216
165
|
if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
|
|
217
166
|
|
|
218
|
-
const response = NR.json({ claimed: data?.claimed ?? false });
|
|
167
|
+
const response = NR.json({ claimed: json.data?.claimed ?? false });
|
|
219
168
|
response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
|
|
220
169
|
httpOnly: true,
|
|
221
170
|
secure: process.env.NODE_ENV === 'production',
|
|
@@ -233,27 +182,27 @@ async function handleSendCode(request: Request, NR: NextResponseLike): Promise<R
|
|
|
233
182
|
const { phone } = body;
|
|
234
183
|
if (!phone) return NR.json({ error: 'Phone is required.' }, { status: 422 });
|
|
235
184
|
|
|
236
|
-
const res = await fetch(`${
|
|
185
|
+
const res = await fetch(`${getApiUrl()}/consumer/auth/send_code`, {
|
|
237
186
|
method: 'POST',
|
|
238
187
|
headers: apiHeaders(request),
|
|
239
188
|
body: JSON.stringify({ phone }),
|
|
240
189
|
});
|
|
241
190
|
|
|
242
|
-
const json = await res.json().catch(() => ({}))
|
|
243
|
-
const data = responseData(json);
|
|
191
|
+
const json = await res.json().catch(() => ({}));
|
|
244
192
|
if (!res.ok) {
|
|
193
|
+
const err = upstreamError(json);
|
|
245
194
|
return NR.json(
|
|
246
195
|
{
|
|
247
|
-
error:
|
|
248
|
-
code:
|
|
249
|
-
retry_in_seconds:
|
|
196
|
+
error: err.error || 'Failed to send verification code. Please try again.',
|
|
197
|
+
code: err.code,
|
|
198
|
+
retry_in_seconds: err.retry_in_seconds,
|
|
250
199
|
},
|
|
251
200
|
{ status: res.status }
|
|
252
201
|
);
|
|
253
202
|
}
|
|
254
203
|
|
|
255
204
|
return NR.json({
|
|
256
|
-
resend_available_at: data?.resend_available_at ?? null,
|
|
205
|
+
resend_available_at: json.data?.resend_available_at ?? null,
|
|
257
206
|
});
|
|
258
207
|
}
|
|
259
208
|
|
|
@@ -264,49 +213,26 @@ async function handleVerifyCode(request: Request, NR: NextResponseLike): Promise
|
|
|
264
213
|
const { phone, code } = body;
|
|
265
214
|
if (!phone || !code) return NR.json({ error: 'Phone and code are required.' }, { status: 422 });
|
|
266
215
|
|
|
267
|
-
const res = await fetch(`${
|
|
216
|
+
const res = await fetch(`${getApiUrl()}/consumer/auth/verify_code`, {
|
|
268
217
|
method: 'POST',
|
|
269
218
|
headers: apiHeaders(request),
|
|
270
219
|
body: JSON.stringify({ phone, code }),
|
|
271
220
|
});
|
|
272
221
|
|
|
273
|
-
const json = await res.json().catch(() => ({}))
|
|
274
|
-
const data = responseData(json);
|
|
222
|
+
const json = await res.json().catch(() => ({}));
|
|
275
223
|
if (!res.ok) {
|
|
224
|
+
const err = upstreamError(json);
|
|
276
225
|
return NR.json(
|
|
277
|
-
{
|
|
278
|
-
error: (json as Record<string, unknown>).error || 'Verification failed. Please try again.',
|
|
279
|
-
code: (json as Record<string, unknown>).code,
|
|
280
|
-
},
|
|
226
|
+
{ error: err.error || 'Verification failed. Please try again.', code: err.code },
|
|
281
227
|
{ status: res.status }
|
|
282
228
|
);
|
|
283
229
|
}
|
|
284
230
|
|
|
285
|
-
// Some surfaces return a direct session token on verify.
|
|
286
|
-
const directToken = extractAuthToken(data);
|
|
287
|
-
if (directToken) {
|
|
288
|
-
const response = NR.json({
|
|
289
|
-
authenticated: true,
|
|
290
|
-
first_name: data?.first_name ?? '',
|
|
291
|
-
last_name: data?.last_name ?? '',
|
|
292
|
-
email: data?.email ?? '',
|
|
293
|
-
eventId: data?.event_id ?? undefined,
|
|
294
|
-
});
|
|
295
|
-
response.cookies.set(CONSUMER_TOKEN_COOKIE, directToken, {
|
|
296
|
-
httpOnly: true,
|
|
297
|
-
secure: process.env.NODE_ENV === 'production',
|
|
298
|
-
sameSite: 'lax',
|
|
299
|
-
path: '/',
|
|
300
|
-
maxAge: COOKIE_MAX_AGE,
|
|
301
|
-
});
|
|
302
|
-
return response;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
231
|
return NR.json({
|
|
306
|
-
verification_token: data?.verification_token,
|
|
307
|
-
first_name: data?.first_name ?? '',
|
|
308
|
-
last_name: data?.last_name ?? '',
|
|
309
|
-
email: data?.email ?? '',
|
|
232
|
+
verification_token: json.data?.verification_token,
|
|
233
|
+
first_name: json.data?.first_name ?? '',
|
|
234
|
+
last_name: json.data?.last_name ?? '',
|
|
235
|
+
email: json.data?.email ?? '',
|
|
310
236
|
});
|
|
311
237
|
}
|
|
312
238
|
|
|
@@ -322,7 +248,7 @@ async function handlePasswordlessAuth(request: Request, NR: NextResponseLike): P
|
|
|
322
248
|
return NR.json({ error: 'First name, last name, and email are required.' }, { status: 422 });
|
|
323
249
|
}
|
|
324
250
|
|
|
325
|
-
const res = await fetch(`${
|
|
251
|
+
const res = await fetch(`${getApiUrl()}/consumer/auth/passwordless_auth`, {
|
|
326
252
|
method: 'POST',
|
|
327
253
|
headers: apiHeaders(request),
|
|
328
254
|
body: JSON.stringify({
|
|
@@ -334,21 +260,20 @@ async function handlePasswordlessAuth(request: Request, NR: NextResponseLike): P
|
|
|
334
260
|
}),
|
|
335
261
|
});
|
|
336
262
|
|
|
337
|
-
const json = await res.json().catch(() => ({}))
|
|
338
|
-
const data = responseData(json);
|
|
263
|
+
const json = await res.json().catch(() => ({}));
|
|
339
264
|
if (!res.ok) {
|
|
340
265
|
return NR.json(
|
|
341
|
-
{ error: (json
|
|
266
|
+
{ error: upstreamError(json).error || 'Authentication failed. Please try again.' },
|
|
342
267
|
{ status: res.status }
|
|
343
268
|
);
|
|
344
269
|
}
|
|
345
270
|
|
|
346
|
-
const token =
|
|
271
|
+
const token = json.data?.token;
|
|
347
272
|
if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
|
|
348
273
|
|
|
349
274
|
// Surface the server-side Lead event_id so the browser can fire a single deduped
|
|
350
275
|
// Lead (fbq track with { eventID }) that Meta matches against the CAPI Lead event.
|
|
351
|
-
const response = NR.json({ eventId: data?.event_id ?? undefined });
|
|
276
|
+
const response = NR.json({ eventId: json.data?.event_id ?? undefined });
|
|
352
277
|
response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
|
|
353
278
|
httpOnly: true,
|
|
354
279
|
secure: process.env.NODE_ENV === 'production',
|