@guettonenrique/passwordless-auth 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +387 -2
- package/dist/functions/index.cjs +391 -0
- package/dist/functions/index.cjs.map +1 -0
- package/dist/functions/index.d.cts +70 -0
- package/dist/functions/index.d.ts +70 -0
- package/dist/functions/index.js +348 -0
- package/dist/functions/index.js.map +1 -0
- package/dist/react-native/index.cjs +617 -0
- package/dist/react-native/index.cjs.map +1 -0
- package/dist/react-native/index.d.cts +64 -0
- package/dist/react-native/index.d.ts +64 -0
- package/dist/react-native/index.js +606 -0
- package/dist/react-native/index.js.map +1 -0
- package/dist/shared/index.cjs +151 -0
- package/dist/shared/index.cjs.map +1 -0
- package/dist/shared/index.d.cts +50 -0
- package/dist/shared/index.d.ts +50 -0
- package/dist/shared/index.js +111 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/types-BUi8ZPam.d.cts +110 -0
- package/dist/types-BUi8ZPam.d.ts +110 -0
- package/package.json +91 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react-native/api.ts","../../src/react-native/OtpInput.tsx","../../src/shared/constants.ts","../../src/shared/utils.ts","../../src/react-native/EmailOtpModal.tsx"],"sourcesContent":["import { getFunctions, httpsCallable } from '@react-native-firebase/functions';\nimport { getAuth, signInWithCustomToken } from '@react-native-firebase/auth';\nimport type {\n RequestOtpPayload,\n RequestOtpResponse,\n VerifyOtpPayload,\n VerifyOtpResponse,\n} from '../shared';\n\n/**\n * Dispatches a 6-digit OTP code to the provided email.\n */\nexport async function requestEmailOtp(email: string): Promise<RequestOtpResponse> {\n const functions = getFunctions();\n const callable = httpsCallable<RequestOtpPayload, RequestOtpResponse>(\n functions,\n 'requestEmailOtp'\n );\n const result = await callable({ email: email.trim().toLowerCase() });\n return result.data;\n}\n\n/**\n * Verifies the code and returns the Firebase custom auth token.\n */\nexport async function verifyEmailOtp(email: string, code: string): Promise<string> {\n const functions = getFunctions();\n const callable = httpsCallable<VerifyOtpPayload, VerifyOtpResponse>(\n functions,\n 'verifyEmailOtp'\n );\n const result = await callable({\n email: email.trim().toLowerCase(),\n code: code.trim(),\n });\n return result.data.customToken;\n}\n\n/**\n * Verifies the code and signs in with the minted custom token.\n * After this resolves, the standard Firebase Auth session is active\n * (token persistence, refresh, onAuthStateChanged).\n */\nexport async function signInWithEmailOtp(email: string, code: string): Promise<void> {\n const customToken = await verifyEmailOtp(email, code);\n await signInWithCustomToken(getAuth(), customToken);\n}\n","import React, { useEffect, useRef, useState } from 'react';\nimport {\n Platform,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type TextInputInstance,\n} from 'react-native';\nimport { CODE_LENGTH } from '../shared';\n\n// expo-haptics is an optional peer: degrade silently when absent.\nlet Haptics: {\n impactAsync?: (style: string) => Promise<void>;\n notificationAsync?: (type: string) => Promise<void>;\n} | null = null;\ntry {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n Haptics = require('expo-haptics');\n} catch {\n Haptics = null;\n}\n\nconst impact = (style: 'light' | 'success') => {\n try {\n if (style === 'light') {\n Haptics?.impactAsync?.('light').catch(() => null);\n } else {\n Haptics?.notificationAsync?.('success').catch(() => null);\n }\n } catch {\n // haptics are best-effort\n }\n};\n\nexport interface OtpInputProps {\n /** Number of digits. Default 6 (matches the backend). */\n length?: number;\n value: string;\n onChange: (value: string) => void;\n /** Fired when the last digit arrives. */\n onComplete?: (code: string) => void;\n disabled?: boolean;\n /** Red error styling for the whole row. */\n error?: boolean;\n autoFocus?: boolean;\n}\n\n/**\n * Six-slot code input backed by ONE hidden native TextInput.\n *\n * A single input is what makes paste and native one-tap auto-fill work\n * reliably: `textContentType=\"oneTimeCode\"` (iOS) and\n * `autoComplete=\"one-time-code\"` (Android) let the OS inject the code\n * from the email in one tap.\n */\nexport function OtpInput({\n length = CODE_LENGTH,\n value,\n onChange,\n onComplete,\n disabled = false,\n error = false,\n autoFocus = true,\n}: OtpInputProps) {\n const inputRef = useRef<TextInputInstance>(null);\n const [isFocused, setIsFocused] = useState(false);\n const [cursorVisible, setCursorVisible] = useState(true);\n\n // Blinking cursor for the active slot\n useEffect(() => {\n if (!isFocused) return;\n const interval = setInterval(() => {\n setCursorVisible((prev) => !prev);\n }, 550);\n return () => clearInterval(interval);\n }, [isFocused]);\n\n const handleChangeText = (text: string) => {\n const cleaned = text.replace(/[^0-9]/g, '').slice(0, length);\n onChange(cleaned);\n\n if (cleaned.length > value.length) impact('light');\n\n if (cleaned.length === length) {\n impact('success');\n onComplete?.(cleaned);\n }\n };\n\n const slots = Array.from({ length }, (_, i) => i);\n\n return (\n <TouchableOpacity\n style={{ width: '100%' }}\n activeOpacity={1}\n onPress={() => {\n if (!disabled) inputRef.current?.focus();\n }}>\n <View\n style={{\n flexDirection: 'row',\n width: '100%',\n justifyContent: 'space-between',\n alignItems: 'center',\n marginVertical: 12,\n }}>\n {slots.map((index) => {\n const char = value[index] ?? '';\n const isSlotActive = isFocused && index === value.length && !disabled;\n const isFilled = char !== '';\n\n let borderColor = '#e5e7eb';\n let backgroundColor = '#f8fafc';\n if (error) {\n borderColor = '#f43f5e';\n backgroundColor = '#fff1f2';\n } else if (isSlotActive) {\n borderColor = '#3b82f6';\n backgroundColor = '#ffffff';\n } else if (isFilled) {\n borderColor = '#94a3b8';\n backgroundColor = '#ffffff';\n }\n\n return (\n <View\n key={index}\n style={{\n flex: 1,\n maxWidth: 48,\n height: 56,\n borderRadius: 8,\n alignItems: 'center',\n justifyContent: 'center',\n marginHorizontal: 4,\n borderWidth: isSlotActive ? 2 : 1,\n borderColor,\n backgroundColor,\n }}>\n {char ? (\n <Text\n style={{\n fontSize: 24,\n fontWeight: '700',\n color: error ? '#e11d48' : '#1e293b',\n }}>\n {char}\n </Text>\n ) : isSlotActive && cursorVisible ? (\n <View\n style={{\n width: 2,\n height: 24,\n backgroundColor: '#3b82f6',\n borderRadius: 2,\n }}\n />\n ) : null}\n </View>\n );\n })}\n </View>\n\n <TextInput\n ref={inputRef}\n value={value}\n onChangeText={handleChangeText}\n keyboardType=\"number-pad\"\n textContentType=\"oneTimeCode\"\n autoComplete={Platform.OS === 'android' ? 'one-time-code' : undefined}\n maxLength={length}\n editable={!disabled}\n autoFocus={autoFocus}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n caretHidden\n style={{\n position: 'absolute',\n top: 0,\n bottom: 0,\n left: 0,\n right: 0,\n opacity: 0,\n }}\n accessibilityLabel={`${length}-digit verification code`}\n />\n </TouchableOpacity>\n );\n}\n","/**\n * Single source of truth for the OTP contract.\n *\n * Backend factories and mobile components both read these as their defaults,\n * so the UI cooldown and the backend rate limit cannot drift apart silently.\n * A project may override them via factory options — if it does, override the\n * SAME value on both sides (e.g. a custom cooldownMs in the backend factory\n * AND as the `cooldownMs` prop on the mobile modal).\n */\n\n/** Number of digits in an OTP code. */\nexport const CODE_LENGTH = 6;\n\n/** Minimum time between two code requests for the same email. */\nexport const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds\n\n/** Lifetime of an issued code. */\nexport const OTP_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes\n\n/** Failed verifications allowed per code before it is invalidated. */\nexport const MAX_ATTEMPTS = 5;\n\n/** Firestore collection that stores pending OTPs (doc id = email). */\nexport const DEFAULT_COLLECTION = 'auth_otps';\n\n/** Default schedule for the cleanup function that reaps expired docs. */\nexport const DEFAULT_CLEANUP_SCHEDULE = 'every 15 minutes';\n\n/** Writes per batch in the cleanup drain loop (Firestore limit is 500). */\nexport const CLEANUP_BATCH_SIZE = 400;\n","import { CODE_LENGTH } from './constants';\n\n/** Deliberately simple RFC-ish check — mirrors the client-side pre-check. */\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/** Trim + lowercase an email so doc keys and hash inputs always match. */\nexport function normalizeEmail(raw: string): string {\n return raw.trim().toLowerCase();\n}\n\nexport function isValidEmail(raw: string): boolean {\n return EMAIL_REGEX.test(normalizeEmail(raw));\n}\n\nexport function isOtpCode(value: string, length: number = CODE_LENGTH): boolean {\n return new RegExp(`^\\\\d{${length}}$`).test(value);\n}\n\n/**\n * Formats the resend-cooldown error message exactly as the backend emits it.\n * Exported so mobile copy (if ever shown manually) cannot drift from it.\n */\nexport function cooldownMessage(remainingMs: number): string {\n const seconds = Math.ceil(remainingMs / 1000);\n return `Please wait ${seconds} second${seconds === 1 ? '' : 's'} before requesting a new code.`;\n}\n\n/** Formats the remaining-attempts message for a wrong-code rejection. */\nexport function attemptsMessage(remaining: number): string {\n return `Invalid verification code. You have ${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`;\n}\n","import React, { useEffect, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Modal,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type TextInputInstance,\n type ViewStyle,\n} from 'react-native';\nimport { KeyboardAwareScrollView } from 'react-native-keyboard-controller';\nimport { AlertCircle, ArrowLeft, Mail, X } from 'lucide-react-native';\nimport { CODE_LENGTH, RESEND_COOLDOWN_MS, isValidEmail } from '../shared';\nimport { OtpInput } from './OtpInput';\nimport { requestEmailOtp, signInWithEmailOtp } from './api';\n\nexport interface EmailOtpModalProps {\n visible: boolean;\n onClose: () => void;\n /** Pre-fill the email step (e.g. from the email already typed on the auth screen). */\n initialEmail?: string;\n /** Brand name shown in the header. Default: 'Sign in with email code'. */\n appName?: string;\n /**\n * Resend cooldown in seconds shown in the UI. Keep in sync with the\n * backend's `cooldownMs` option. Default 60.\n */\n cooldownSeconds?: number;\n}\n\n/**\n * Two-step email → code modal. Handles the full OTP flow:\n * email validation, send, 60s resend cooldown, auto-submit on the last\n * digit, backend error surfacing, and sign-in via custom token.\n *\n * On success the modal closes and the app's global `onAuthStateChanged`\n * listener takes over — no navigation code needed.\n */\nexport function EmailOtpModal({\n visible,\n onClose,\n initialEmail = '',\n appName,\n cooldownSeconds = Math.round(RESEND_COOLDOWN_MS / 1000),\n}: EmailOtpModalProps) {\n const [step, setStep] = useState<'email' | 'otp'>('email');\n const [email, setEmail] = useState(initialEmail);\n const [otp, setOtp] = useState('');\n const [loading, setLoading] = useState(false);\n const [resending, setResending] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [cooldown, setCooldown] = useState(0);\n\n const [prevVisible, setPrevVisible] = useState(false);\n const isVerifyingRef = useRef(false);\n const emailRef = useRef<TextInputInstance>(null);\n\n // Reset state each time the modal opens\n if (visible !== prevVisible) {\n setPrevVisible(visible);\n if (visible) {\n setStep('email');\n setEmail(initialEmail);\n setOtp('');\n setError(null);\n setLoading(false);\n setResending(false);\n setCooldown(0);\n isVerifyingRef.current = false;\n }\n }\n\n // Resend cooldown timer\n useEffect(() => {\n if (cooldown <= 0) return;\n const timer = setInterval(() => {\n setCooldown((prev) => (prev > 0 ? prev - 1 : 0));\n }, 1000);\n return () => clearInterval(timer);\n }, [cooldown]);\n\n // Strip Firebase's \"[functions/xyz] \" prefix from backend error messages\n const cleanErrorMessage = (err: unknown, fallback: string): string => {\n const raw =\n err instanceof Error ? err.message : typeof err === 'string' ? err : '';\n if (raw.length > 0) {\n return raw.replace(/^\\[.*?\\]\\s*/, '').trim();\n }\n return fallback;\n };\n\n const handleSendCode = async () => {\n const trimmedEmail = email.trim().toLowerCase();\n if (!trimmedEmail) {\n setError('Please enter your email address.');\n return;\n }\n if (!isValidEmail(trimmedEmail)) {\n setError('Please enter a valid email address.');\n return;\n }\n\n setLoading(true);\n setError(null);\n emailRef.current?.blur();\n\n try {\n await requestEmailOtp(trimmedEmail);\n setStep('otp');\n setCooldown(cooldownSeconds);\n setOtp('');\n } catch (err) {\n console.error('[passwordless-auth] requestEmailOtp failed:', err);\n setError(cleanErrorMessage(err, 'Failed to send verification code. Please try again.'));\n } finally {\n setLoading(false);\n }\n };\n\n const handleResendCode = async () => {\n if (cooldown > 0 || resending || loading) return;\n\n setResending(true);\n setError(null);\n\n try {\n await requestEmailOtp(email.trim().toLowerCase());\n setCooldown(cooldownSeconds);\n setOtp('');\n } catch (err) {\n console.error('[passwordless-auth] resend failed:', err);\n setError(cleanErrorMessage(err, 'Failed to resend verification code.'));\n } finally {\n setResending(false);\n }\n };\n\n const handleClose = () => {\n setError(null);\n setLoading(false);\n setResending(false);\n onClose();\n };\n\n const handleVerifyOtp = async (codeToVerify?: string) => {\n const code = (codeToVerify ?? otp).trim();\n if (code.length < CODE_LENGTH || isVerifyingRef.current) return;\n\n isVerifyingRef.current = true;\n setLoading(true);\n setError(null);\n\n try {\n await signInWithEmailOtp(email.trim().toLowerCase(), code);\n handleClose();\n } catch (err) {\n console.error('[passwordless-auth] verifyEmailOtp failed:', err);\n setError(\n cleanErrorMessage(err, 'Invalid or expired verification code. Please try again.')\n );\n setOtp('');\n } finally {\n setLoading(false);\n isVerifyingRef.current = false;\n }\n };\n\n const handleBackToEmail = () => {\n if (loading) return;\n setStep('email');\n setOtp('');\n setError(null);\n };\n\n const buttonBase: ViewStyle = {\n flex: 1,\n paddingVertical: 14,\n borderRadius: 12,\n alignItems: 'center',\n flexDirection: 'row',\n justifyContent: 'center',\n gap: 8,\n };\n\n return (\n <Modal visible={visible} transparent animationType=\"fade\" onRequestClose={handleClose}>\n <KeyboardAwareScrollView\n contentContainerStyle={{ flexGrow: 1 }}\n keyboardShouldPersistTaps=\"handled\"\n bottomOffset={24}\n showsVerticalScrollIndicator={false}>\n <TouchableOpacity\n style={{\n flex: 1,\n backgroundColor: 'rgba(0,0,0,0.5)',\n justifyContent: 'center',\n alignItems: 'center',\n padding: 16,\n }}\n activeOpacity={1}\n onPress={handleClose}>\n <TouchableOpacity\n activeOpacity={1}\n style={{\n backgroundColor: '#fff',\n width: '100%',\n maxWidth: 420,\n borderRadius: 16,\n overflow: 'hidden',\n shadowColor: '#000',\n shadowOpacity: 0.2,\n shadowRadius: 12,\n elevation: 10,\n }}>\n {/* Header */}\n <View\n style={{\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingHorizontal: 24,\n paddingVertical: 16,\n borderBottomWidth: 1,\n borderBottomColor: '#f3f4f6',\n }}>\n <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>\n {step === 'otp' && (\n <TouchableOpacity onPress={handleBackToEmail} disabled={loading} activeOpacity={0.7}>\n <ArrowLeft size={20} color=\"#64748b\" />\n </TouchableOpacity>\n )}\n <View>\n <Text style={{ fontSize: 12, color: '#94a3b8', marginBottom: 2 }}>\n {step === 'email' ? 'Instant Sign In' : 'Verification'}\n </Text>\n <Text style={{ fontSize: 18, fontWeight: '700', color: '#1e293b' }}>\n {step === 'email'\n ? appName\n ? `${appName} — email code`\n : 'Sign in with email code'\n : 'Enter verification code'}\n </Text>\n </View>\n </View>\n <TouchableOpacity onPress={handleClose} activeOpacity={0.7}>\n <X size={20} color=\"#94a3b8\" />\n </TouchableOpacity>\n </View>\n\n {/* Content */}\n <View style={{ padding: 24 }}>\n {error ? (\n <View\n style={{\n flexDirection: 'row',\n alignItems: 'center',\n gap: 8,\n padding: 12,\n marginBottom: 16,\n borderRadius: 12,\n backgroundColor: '#fef2f2',\n borderWidth: 1,\n borderColor: '#fecaca',\n }}>\n <AlertCircle size={16} color=\"#dc2626\" />\n <Text style={{ fontSize: 12, color: '#dc2626', flex: 1, fontWeight: '500' }}>\n {error}\n </Text>\n </View>\n ) : null}\n\n {step === 'email' ? (\n /* ---------- Step 1: Email ---------- */\n <View>\n <Text style={{ fontSize: 13, color: '#64748b', marginBottom: 16, lineHeight: 18 }}>\n We'll send a 6-digit verification code to your email. No password needed.\n </Text>\n\n <View style={{ marginBottom: 24 }}>\n <Text style={{ fontSize: 12, fontWeight: '600', color: '#334155', marginBottom: 8 }}>\n Email\n </Text>\n <View\n style={{\n flexDirection: 'row',\n alignItems: 'center',\n paddingHorizontal: 16,\n backgroundColor: '#f8fafc',\n borderRadius: 12,\n borderWidth: 1,\n borderColor: '#e5e7eb',\n height: 56,\n }}>\n <TextInput\n ref={emailRef}\n style={{ flex: 1, fontSize: 14, fontWeight: '600', color: '#1e293b' }}\n placeholder=\"name@email.com\"\n placeholderTextColor=\"#94a3b8\"\n value={email}\n onChangeText={(val) => {\n setEmail(val);\n if (error) setError(null);\n }}\n keyboardType=\"email-address\"\n autoCapitalize=\"none\"\n autoCorrect={false}\n returnKeyType=\"send\"\n onSubmitEditing={handleSendCode}\n autoFocus\n editable={!loading}\n />\n <Mail size={20} color=\"#cbd5e1\" />\n </View>\n </View>\n\n <View style={{ flexDirection: 'row', gap: 12 }}>\n <TouchableOpacity\n onPress={handleClose}\n style={{ ...buttonBase, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: 'transparent' }}\n activeOpacity={0.7}>\n <Text style={{ color: '#64748b', fontWeight: '600', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }}>\n Cancel\n </Text>\n </TouchableOpacity>\n\n <TouchableOpacity\n onPress={handleSendCode}\n disabled={loading}\n style={{ ...buttonBase, backgroundColor: loading ? '#93c5fd' : '#3b82f6' }}\n activeOpacity={0.7}>\n {loading ? <ActivityIndicator size=\"small\" color=\"#fff\" /> : null}\n <Text style={{ color: '#fff', fontWeight: '600', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }}>\n {loading ? 'Sending…' : 'Send Code'}\n </Text>\n </TouchableOpacity>\n </View>\n </View>\n ) : (\n /* ---------- Step 2: Code ---------- */\n <View>\n <Text style={{ fontSize: 13, color: '#64748b', marginBottom: 16, lineHeight: 18 }}>\n We sent a 6-digit code to{' '}\n <Text style={{ fontWeight: '600', color: '#1e293b' }}>{email.trim()}</Text>\n {' '}\n . Enter it below to sign in.\n </Text>\n\n <View style={{ alignItems: 'center', marginBottom: 16 }}>\n <OtpInput\n value={otp}\n onChange={(val) => {\n setOtp(val);\n if (error) setError(null);\n }}\n onComplete={(code) => handleVerifyOtp(code)}\n disabled={loading}\n error={!!error}\n autoFocus\n />\n </View>\n\n <View\n style={{\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n marginBottom: 24,\n paddingHorizontal: 4,\n }}>\n <TouchableOpacity onPress={handleBackToEmail} disabled={loading} activeOpacity={0.7}>\n <Text style={{ fontSize: 12, fontWeight: '600', color: '#64748b' }}>\n Change email\n </Text>\n </TouchableOpacity>\n\n {cooldown > 0 ? (\n <Text style={{ fontSize: 12, color: '#94a3b8', fontWeight: '500' }}>\n Resend in {cooldown}s\n </Text>\n ) : (\n <TouchableOpacity\n onPress={handleResendCode}\n disabled={resending || loading}\n style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}\n activeOpacity={0.7}>\n {resending ? <ActivityIndicator size=\"small\" color=\"#2563eb\" /> : null}\n <Text style={{ fontSize: 12, fontWeight: '600', color: '#2563eb' }}>\n Resend Code\n </Text>\n </TouchableOpacity>\n )}\n </View>\n\n <View style={{ flexDirection: 'row', gap: 12 }}>\n <TouchableOpacity\n onPress={handleClose}\n style={{ ...buttonBase, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: 'transparent' }}\n activeOpacity={0.7}>\n <Text style={{ color: '#64748b', fontWeight: '600', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }}>\n Cancel\n </Text>\n </TouchableOpacity>\n\n <TouchableOpacity\n onPress={() => handleVerifyOtp()}\n disabled={loading || otp.length < CODE_LENGTH}\n style={{\n ...buttonBase,\n backgroundColor: loading || otp.length < CODE_LENGTH ? '#93c5fd' : '#3b82f6',\n }}\n activeOpacity={0.7}>\n {loading ? <ActivityIndicator size=\"small\" color=\"#fff\" /> : null}\n <Text style={{ color: '#fff', fontWeight: '600', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }}>\n {loading ? 'Verifying…' : 'Verify & Sign In'}\n </Text>\n </TouchableOpacity>\n </View>\n </View>\n )}\n </View>\n </TouchableOpacity>\n </TouchableOpacity>\n </KeyboardAwareScrollView>\n </Modal>\n );\n}\n"],"mappings":"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","names":["useEffect","useRef","useState","Text","TextInput","TouchableOpacity","View","jsx","jsxs","useState","useRef","useEffect","TouchableOpacity","View","Text","TextInput"]}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/shared/index.ts
|
|
21
|
+
var shared_exports = {};
|
|
22
|
+
__export(shared_exports, {
|
|
23
|
+
CLEANUP_BATCH_SIZE: () => CLEANUP_BATCH_SIZE,
|
|
24
|
+
CODE_LENGTH: () => CODE_LENGTH,
|
|
25
|
+
DEFAULT_CLEANUP_SCHEDULE: () => DEFAULT_CLEANUP_SCHEDULE,
|
|
26
|
+
DEFAULT_COLLECTION: () => DEFAULT_COLLECTION,
|
|
27
|
+
MAX_ATTEMPTS: () => MAX_ATTEMPTS,
|
|
28
|
+
OTP_ERROR_CODES: () => OTP_ERROR_CODES,
|
|
29
|
+
OTP_EXPIRY_MS: () => OTP_EXPIRY_MS,
|
|
30
|
+
RESEND_COOLDOWN_MS: () => RESEND_COOLDOWN_MS,
|
|
31
|
+
attemptsMessage: () => attemptsMessage,
|
|
32
|
+
buildOtpEmail: () => buildOtpEmail,
|
|
33
|
+
cooldownMessage: () => cooldownMessage,
|
|
34
|
+
isOtpCode: () => isOtpCode,
|
|
35
|
+
isValidEmail: () => isValidEmail,
|
|
36
|
+
normalizeEmail: () => normalizeEmail
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(shared_exports);
|
|
39
|
+
|
|
40
|
+
// src/shared/constants.ts
|
|
41
|
+
var CODE_LENGTH = 6;
|
|
42
|
+
var RESEND_COOLDOWN_MS = 60 * 1e3;
|
|
43
|
+
var OTP_EXPIRY_MS = 10 * 60 * 1e3;
|
|
44
|
+
var MAX_ATTEMPTS = 5;
|
|
45
|
+
var DEFAULT_COLLECTION = "auth_otps";
|
|
46
|
+
var DEFAULT_CLEANUP_SCHEDULE = "every 15 minutes";
|
|
47
|
+
var CLEANUP_BATCH_SIZE = 400;
|
|
48
|
+
|
|
49
|
+
// src/shared/types.ts
|
|
50
|
+
var OTP_ERROR_CODES = {
|
|
51
|
+
InvalidArgument: "invalid-argument",
|
|
52
|
+
NotFound: "not-found",
|
|
53
|
+
DeadlineExceeded: "deadline-exceeded",
|
|
54
|
+
ResourceExhausted: "resource-exhausted",
|
|
55
|
+
Internal: "internal"
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/shared/utils.ts
|
|
59
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
60
|
+
function normalizeEmail(raw) {
|
|
61
|
+
return raw.trim().toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
function isValidEmail(raw) {
|
|
64
|
+
return EMAIL_REGEX.test(normalizeEmail(raw));
|
|
65
|
+
}
|
|
66
|
+
function isOtpCode(value, length = CODE_LENGTH) {
|
|
67
|
+
return new RegExp(`^\\d{${length}}$`).test(value);
|
|
68
|
+
}
|
|
69
|
+
function cooldownMessage(remainingMs) {
|
|
70
|
+
const seconds = Math.ceil(remainingMs / 1e3);
|
|
71
|
+
return `Please wait ${seconds} second${seconds === 1 ? "" : "s"} before requesting a new code.`;
|
|
72
|
+
}
|
|
73
|
+
function attemptsMessage(remaining) {
|
|
74
|
+
return `Invalid verification code. You have ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/shared/email-template.ts
|
|
78
|
+
function buildOtpEmail(input) {
|
|
79
|
+
const { to, appName, code, expiresMinutes } = input;
|
|
80
|
+
const subject = `${appName} - Your verification code is ${code}`;
|
|
81
|
+
const text = `Your verification code for ${appName} is: ${code}
|
|
82
|
+
|
|
83
|
+
This code is valid for ${expiresMinutes} minutes.
|
|
84
|
+
If you did not request this, please ignore this email.`;
|
|
85
|
+
const html = `
|
|
86
|
+
<!DOCTYPE html>
|
|
87
|
+
<html>
|
|
88
|
+
<head>
|
|
89
|
+
<meta charset="utf-8">
|
|
90
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
91
|
+
<title>${escapeHtml(subject)}</title>
|
|
92
|
+
</head>
|
|
93
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
|
94
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8fafc; padding: 40px 16px;">
|
|
95
|
+
<tr>
|
|
96
|
+
<td align="center">
|
|
97
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
|
98
|
+
<tr>
|
|
99
|
+
<td style="padding: 32px 32px 16px 32px; text-align: center;">
|
|
100
|
+
<h1 style="margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: #1e293b;">${escapeHtml(appName)}</h1>
|
|
101
|
+
<p style="margin: 0; font-size: 15px; color: #64748b;">Sign in to your account</p>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
<tr>
|
|
105
|
+
<td style="padding: 16px 32px 24px 32px; text-align: center;">
|
|
106
|
+
<p style="margin: 0; margin-bottom: 16px; font-size: 14px; color: #475569;">Enter this 6-digit code in the app to continue:</p>
|
|
107
|
+
<div style="display: inline-block; background-color: #f1f5f9; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 16px 32px; font-size: 34px; font-weight: 800; letter-spacing: 8px; color: #0f172a; font-family: monospace;">
|
|
108
|
+
${escapeHtml(code)}
|
|
109
|
+
</div>
|
|
110
|
+
<p style="margin: 16px 0 0 0; font-size: 13px; color: #94a3b8;">
|
|
111
|
+
This code will expire in <strong>${expiresMinutes} minutes</strong>.
|
|
112
|
+
</p>
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
<tr>
|
|
116
|
+
<td style="padding: 20px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0; text-align: center;">
|
|
117
|
+
<p style="margin: 0; font-size: 12px; color: #94a3b8;">
|
|
118
|
+
If you did not request this verification code, you can safely ignore this email.
|
|
119
|
+
</p>
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
</table>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
</table>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
`;
|
|
129
|
+
return { to, subject, text, html };
|
|
130
|
+
}
|
|
131
|
+
function escapeHtml(value) {
|
|
132
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
133
|
+
}
|
|
134
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
135
|
+
0 && (module.exports = {
|
|
136
|
+
CLEANUP_BATCH_SIZE,
|
|
137
|
+
CODE_LENGTH,
|
|
138
|
+
DEFAULT_CLEANUP_SCHEDULE,
|
|
139
|
+
DEFAULT_COLLECTION,
|
|
140
|
+
MAX_ATTEMPTS,
|
|
141
|
+
OTP_ERROR_CODES,
|
|
142
|
+
OTP_EXPIRY_MS,
|
|
143
|
+
RESEND_COOLDOWN_MS,
|
|
144
|
+
attemptsMessage,
|
|
145
|
+
buildOtpEmail,
|
|
146
|
+
cooldownMessage,
|
|
147
|
+
isOtpCode,
|
|
148
|
+
isValidEmail,
|
|
149
|
+
normalizeEmail
|
|
150
|
+
});
|
|
151
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shared/index.ts","../../src/shared/constants.ts","../../src/shared/types.ts","../../src/shared/utils.ts","../../src/shared/email-template.ts"],"sourcesContent":["export * from './constants';\nexport * from './types';\nexport * from './utils';\nexport { buildOtpEmail } from './email-template';\n","/**\n * Single source of truth for the OTP contract.\n *\n * Backend factories and mobile components both read these as their defaults,\n * so the UI cooldown and the backend rate limit cannot drift apart silently.\n * A project may override them via factory options — if it does, override the\n * SAME value on both sides (e.g. a custom cooldownMs in the backend factory\n * AND as the `cooldownMs` prop on the mobile modal).\n */\n\n/** Number of digits in an OTP code. */\nexport const CODE_LENGTH = 6;\n\n/** Minimum time between two code requests for the same email. */\nexport const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds\n\n/** Lifetime of an issued code. */\nexport const OTP_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes\n\n/** Failed verifications allowed per code before it is invalidated. */\nexport const MAX_ATTEMPTS = 5;\n\n/** Firestore collection that stores pending OTPs (doc id = email). */\nexport const DEFAULT_COLLECTION = 'auth_otps';\n\n/** Default schedule for the cleanup function that reaps expired docs. */\nexport const DEFAULT_CLEANUP_SCHEDULE = 'every 15 minutes';\n\n/** Writes per batch in the cleanup drain loop (Firestore limit is 500). */\nexport const CLEANUP_BATCH_SIZE = 400;\n","// ============================================================================\n// Payloads (App -> Backend)\n// ============================================================================\n\nexport interface RequestOtpPayload {\n email: string;\n}\n\nexport interface VerifyOtpPayload {\n email: string;\n code: string;\n}\n\n// ============================================================================\n// Responses (Backend -> App)\n// ============================================================================\n\nexport interface RequestOtpResponse {\n status: 'success';\n message: string;\n}\n\nexport interface VerifyOtpResponse {\n status: 'success';\n /** Firebase custom auth token — exchange with signInWithCustomToken. */\n customToken: string;\n}\n\n// ============================================================================\n// Backend option types\n// ============================================================================\n\n/** A fully composed OTP email, ready to dispatch. */\nexport interface OtpEmail {\n to: string;\n subject: string;\n text: string;\n html: string;\n}\n\n/**\n * Delivers the OTP email. The default implementation sends via SendGrid;\n * projects can replace it entirely (custom provider, or a console-logging\n * sender during development).\n */\nexport type OtpEmailSender = (email: OtpEmail) => Promise<void>;\n\n/** Inputs for {@link buildOtpEmail}. */\nexport interface OtpEmailInput {\n to: string;\n appName: string;\n /** The plaintext 6-digit code — only ever passed to the sender, never persisted. */\n code: string;\n /** Code lifetime in minutes, for the email copy. */\n expiresMinutes: number;\n}\n\nexport interface SendgridSenderOptions {\n /**\n * The API key, resolved per call. Accepts a plain string, a getter\n * function (`defineSecret('X').value`), or a Firebase params Secret\n * object (`defineSecret('X')`) directly.\n */\n apiKey: string | (() => string) | { value: () => string };\n fromEmail: string;\n fromName: string;\n}\n\n/** Base options shared by the request/verify factories. */\nexport interface BaseOtpOptions {\n /** Firestore collection holding pending OTPs. Default: `auth_otps`. */\n collection?: string;\n /** Resend cooldown in ms. Default: 60_000. */\n cooldownMs?: number;\n /** Code lifetime in ms. Default: 600_000 (10 minutes). */\n expiryMs?: number;\n /** Failed verifications before the code is invalidated. Default: 5. */\n maxAttempts?: number;\n /** Enforce App Check on the callable. Default: false (flip true in production). */\n enforceAppCheck?: boolean;\n}\n\nexport interface RequestOtpOptions extends BaseOtpOptions {\n /** Brand name used in the email subject and body. Required. */\n appName: string;\n /** Replace the default SendGrid delivery entirely. */\n sendEmail?: OtpEmailSender;\n /** SendGrid sender config; required unless `sendEmail` is provided. */\n sendgrid?: SendgridSenderOptions;\n}\n\n/**\n * Resolves (and, if needed, creates) the auth user for an email, returning\n * the uid the custom token will be minted for. This is the one required\n * project-specific hook — it owns the app's user schema.\n */\nexport type OtpProvisioner = (email: string) => Promise<string>;\n\nexport interface VerifyOtpOptions extends BaseOtpOptions {\n /** Required. Creates or resolves the user and returns their uid. */\n provision: OtpProvisioner;\n}\n\nexport interface CleanupOtpOptions {\n /** Cloud Scheduler cron. Default: `every 15 minutes`. */\n schedule?: string;\n /** Collection to clean. Default: `auth_otps`. */\n collection?: string;\n /** Writes per batch. Default: 400 (Firestore limit is 500). */\n batchSize?: number;\n}\n\n// ============================================================================\n// Stored document shape (Firestore `auth_otps/{email}`)\n// ============================================================================\n\nexport interface OtpDocumentData {\n /** SHA-256 hex of `email:code`. The plaintext code is never stored. */\n hash: string;\n /** Failed guess counter. */\n attempts: number;\n /** Expiry as epoch milliseconds. */\n expiresAt: number;\n /** Last dispatch as epoch milliseconds (drives the resend cooldown). */\n lastSentAt: number;\n createdAt?: unknown;\n}\n\n// ============================================================================\n// Error codes (HttpsError status codes used by the factories)\n// ============================================================================\n\nexport const OTP_ERROR_CODES = {\n InvalidArgument: 'invalid-argument',\n NotFound: 'not-found',\n DeadlineExceeded: 'deadline-exceeded',\n ResourceExhausted: 'resource-exhausted',\n Internal: 'internal',\n} as const;\n\nexport type OtpErrorCode = (typeof OTP_ERROR_CODES)[keyof typeof OTP_ERROR_CODES];\n","import { CODE_LENGTH } from './constants';\n\n/** Deliberately simple RFC-ish check — mirrors the client-side pre-check. */\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/** Trim + lowercase an email so doc keys and hash inputs always match. */\nexport function normalizeEmail(raw: string): string {\n return raw.trim().toLowerCase();\n}\n\nexport function isValidEmail(raw: string): boolean {\n return EMAIL_REGEX.test(normalizeEmail(raw));\n}\n\nexport function isOtpCode(value: string, length: number = CODE_LENGTH): boolean {\n return new RegExp(`^\\\\d{${length}}$`).test(value);\n}\n\n/**\n * Formats the resend-cooldown error message exactly as the backend emits it.\n * Exported so mobile copy (if ever shown manually) cannot drift from it.\n */\nexport function cooldownMessage(remainingMs: number): string {\n const seconds = Math.ceil(remainingMs / 1000);\n return `Please wait ${seconds} second${seconds === 1 ? '' : 's'} before requesting a new code.`;\n}\n\n/** Formats the remaining-attempts message for a wrong-code rejection. */\nexport function attemptsMessage(remaining: number): string {\n return `Invalid verification code. You have ${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`;\n}\n","import type { OtpEmail, OtpEmailInput } from './types';\n\n/**\n * Builds a fully composed OTP email { to, subject, text, html }.\n *\n * Inline HTML (no SendGrid dynamic template) on purpose: zero template\n * management per project, and the look is good enough for a code-delivery\n * email. Projects with a branded template should pass their own `sendEmail`\n * instead of customizing this builder.\n */\nexport function buildOtpEmail(input: OtpEmailInput): OtpEmail {\n const { to, appName, code, expiresMinutes } = input;\n\n const subject = `${appName} - Your verification code is ${code}`;\n\n const text =\n `Your verification code for ${appName} is: ${code}\\n\\n` +\n `This code is valid for ${expiresMinutes} minutes.\\n` +\n `If you did not request this, please ignore this email.`;\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(subject)}</title>\n </head>\n <body style=\"margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color: #f8fafc; padding: 40px 16px;\">\n <tr>\n <td align=\"center\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width: 480px; background-color: #ffffff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);\">\n <tr>\n <td style=\"padding: 32px 32px 16px 32px; text-align: center;\">\n <h1 style=\"margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: #1e293b;\">${escapeHtml(appName)}</h1>\n <p style=\"margin: 0; font-size: 15px; color: #64748b;\">Sign in to your account</p>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 16px 32px 24px 32px; text-align: center;\">\n <p style=\"margin: 0; margin-bottom: 16px; font-size: 14px; color: #475569;\">Enter this 6-digit code in the app to continue:</p>\n <div style=\"display: inline-block; background-color: #f1f5f9; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 16px 32px; font-size: 34px; font-weight: 800; letter-spacing: 8px; color: #0f172a; font-family: monospace;\">\n ${escapeHtml(code)}\n </div>\n <p style=\"margin: 16px 0 0 0; font-size: 13px; color: #94a3b8;\">\n This code will expire in <strong>${expiresMinutes} minutes</strong>.\n </p>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 20px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0; text-align: center;\">\n <p style=\"margin: 0; font-size: 12px; color: #94a3b8;\">\n If you did not request this verification code, you can safely ignore this email.\n </p>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n </html>\n `;\n\n return { to, subject, text, html };\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,IAAM,cAAc;AAGpB,IAAM,qBAAqB,KAAK;AAGhC,IAAM,gBAAgB,KAAK,KAAK;AAGhC,IAAM,eAAe;AAGrB,IAAM,qBAAqB;AAG3B,IAAM,2BAA2B;AAGjC,IAAM,qBAAqB;;;ACuG3B,IAAM,kBAAkB;AAAA,EAC7B,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,mBAAmB;AAAA,EACnB,UAAU;AACZ;;;ACvIA,IAAM,cAAc;AAGb,SAAS,eAAe,KAAqB;AAClD,SAAO,IAAI,KAAK,EAAE,YAAY;AAChC;AAEO,SAAS,aAAa,KAAsB;AACjD,SAAO,YAAY,KAAK,eAAe,GAAG,CAAC;AAC7C;AAEO,SAAS,UAAU,OAAe,SAAiB,aAAsB;AAC9E,SAAO,IAAI,OAAO,QAAQ,MAAM,IAAI,EAAE,KAAK,KAAK;AAClD;AAMO,SAAS,gBAAgB,aAA6B;AAC3D,QAAM,UAAU,KAAK,KAAK,cAAc,GAAI;AAC5C,SAAO,eAAe,OAAO,UAAU,YAAY,IAAI,KAAK,GAAG;AACjE;AAGO,SAAS,gBAAgB,WAA2B;AACzD,SAAO,uCAAuC,SAAS,WAAW,cAAc,IAAI,KAAK,GAAG;AAC9F;;;ACpBO,SAAS,cAAc,OAAgC;AAC5D,QAAM,EAAE,IAAI,SAAS,MAAM,eAAe,IAAI;AAE9C,QAAM,UAAU,GAAG,OAAO,gCAAgC,IAAI;AAE9D,QAAM,OACJ,8BAA8B,OAAO,QAAQ,IAAI;AAAA;AAAA,yBACvB,cAAc;AAAA;AAG1C,QAAM,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAMI,WAAW,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0GASoE,WAAW,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAQnG,WAAW,IAAI,CAAC;AAAA;AAAA;AAAA,2DAGiB,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBvE,SAAO,EAAE,IAAI,SAAS,MAAM,KAAK;AACnC;AAEA,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { e as OtpEmailInput, f as OtpEmail } from '../types-BUi8ZPam.cjs';
|
|
2
|
+
export { B as BaseOtpOptions, C as CleanupOtpOptions, g as OTP_ERROR_CODES, h as OtpDocumentData, O as OtpEmailSender, i as OtpErrorCode, j as OtpProvisioner, R as RequestOtpOptions, a as RequestOtpPayload, b as RequestOtpResponse, S as SendgridSenderOptions, V as VerifyOtpOptions, c as VerifyOtpPayload, d as VerifyOtpResponse } from '../types-BUi8ZPam.cjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single source of truth for the OTP contract.
|
|
6
|
+
*
|
|
7
|
+
* Backend factories and mobile components both read these as their defaults,
|
|
8
|
+
* so the UI cooldown and the backend rate limit cannot drift apart silently.
|
|
9
|
+
* A project may override them via factory options — if it does, override the
|
|
10
|
+
* SAME value on both sides (e.g. a custom cooldownMs in the backend factory
|
|
11
|
+
* AND as the `cooldownMs` prop on the mobile modal).
|
|
12
|
+
*/
|
|
13
|
+
/** Number of digits in an OTP code. */
|
|
14
|
+
declare const CODE_LENGTH = 6;
|
|
15
|
+
/** Minimum time between two code requests for the same email. */
|
|
16
|
+
declare const RESEND_COOLDOWN_MS: number;
|
|
17
|
+
/** Lifetime of an issued code. */
|
|
18
|
+
declare const OTP_EXPIRY_MS: number;
|
|
19
|
+
/** Failed verifications allowed per code before it is invalidated. */
|
|
20
|
+
declare const MAX_ATTEMPTS = 5;
|
|
21
|
+
/** Firestore collection that stores pending OTPs (doc id = email). */
|
|
22
|
+
declare const DEFAULT_COLLECTION = "auth_otps";
|
|
23
|
+
/** Default schedule for the cleanup function that reaps expired docs. */
|
|
24
|
+
declare const DEFAULT_CLEANUP_SCHEDULE = "every 15 minutes";
|
|
25
|
+
/** Writes per batch in the cleanup drain loop (Firestore limit is 500). */
|
|
26
|
+
declare const CLEANUP_BATCH_SIZE = 400;
|
|
27
|
+
|
|
28
|
+
/** Trim + lowercase an email so doc keys and hash inputs always match. */
|
|
29
|
+
declare function normalizeEmail(raw: string): string;
|
|
30
|
+
declare function isValidEmail(raw: string): boolean;
|
|
31
|
+
declare function isOtpCode(value: string, length?: number): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Formats the resend-cooldown error message exactly as the backend emits it.
|
|
34
|
+
* Exported so mobile copy (if ever shown manually) cannot drift from it.
|
|
35
|
+
*/
|
|
36
|
+
declare function cooldownMessage(remainingMs: number): string;
|
|
37
|
+
/** Formats the remaining-attempts message for a wrong-code rejection. */
|
|
38
|
+
declare function attemptsMessage(remaining: number): string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds a fully composed OTP email { to, subject, text, html }.
|
|
42
|
+
*
|
|
43
|
+
* Inline HTML (no SendGrid dynamic template) on purpose: zero template
|
|
44
|
+
* management per project, and the look is good enough for a code-delivery
|
|
45
|
+
* email. Projects with a branded template should pass their own `sendEmail`
|
|
46
|
+
* instead of customizing this builder.
|
|
47
|
+
*/
|
|
48
|
+
declare function buildOtpEmail(input: OtpEmailInput): OtpEmail;
|
|
49
|
+
|
|
50
|
+
export { CLEANUP_BATCH_SIZE, CODE_LENGTH, DEFAULT_CLEANUP_SCHEDULE, DEFAULT_COLLECTION, MAX_ATTEMPTS, OTP_EXPIRY_MS, OtpEmail, OtpEmailInput, RESEND_COOLDOWN_MS, attemptsMessage, buildOtpEmail, cooldownMessage, isOtpCode, isValidEmail, normalizeEmail };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { e as OtpEmailInput, f as OtpEmail } from '../types-BUi8ZPam.js';
|
|
2
|
+
export { B as BaseOtpOptions, C as CleanupOtpOptions, g as OTP_ERROR_CODES, h as OtpDocumentData, O as OtpEmailSender, i as OtpErrorCode, j as OtpProvisioner, R as RequestOtpOptions, a as RequestOtpPayload, b as RequestOtpResponse, S as SendgridSenderOptions, V as VerifyOtpOptions, c as VerifyOtpPayload, d as VerifyOtpResponse } from '../types-BUi8ZPam.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single source of truth for the OTP contract.
|
|
6
|
+
*
|
|
7
|
+
* Backend factories and mobile components both read these as their defaults,
|
|
8
|
+
* so the UI cooldown and the backend rate limit cannot drift apart silently.
|
|
9
|
+
* A project may override them via factory options — if it does, override the
|
|
10
|
+
* SAME value on both sides (e.g. a custom cooldownMs in the backend factory
|
|
11
|
+
* AND as the `cooldownMs` prop on the mobile modal).
|
|
12
|
+
*/
|
|
13
|
+
/** Number of digits in an OTP code. */
|
|
14
|
+
declare const CODE_LENGTH = 6;
|
|
15
|
+
/** Minimum time between two code requests for the same email. */
|
|
16
|
+
declare const RESEND_COOLDOWN_MS: number;
|
|
17
|
+
/** Lifetime of an issued code. */
|
|
18
|
+
declare const OTP_EXPIRY_MS: number;
|
|
19
|
+
/** Failed verifications allowed per code before it is invalidated. */
|
|
20
|
+
declare const MAX_ATTEMPTS = 5;
|
|
21
|
+
/** Firestore collection that stores pending OTPs (doc id = email). */
|
|
22
|
+
declare const DEFAULT_COLLECTION = "auth_otps";
|
|
23
|
+
/** Default schedule for the cleanup function that reaps expired docs. */
|
|
24
|
+
declare const DEFAULT_CLEANUP_SCHEDULE = "every 15 minutes";
|
|
25
|
+
/** Writes per batch in the cleanup drain loop (Firestore limit is 500). */
|
|
26
|
+
declare const CLEANUP_BATCH_SIZE = 400;
|
|
27
|
+
|
|
28
|
+
/** Trim + lowercase an email so doc keys and hash inputs always match. */
|
|
29
|
+
declare function normalizeEmail(raw: string): string;
|
|
30
|
+
declare function isValidEmail(raw: string): boolean;
|
|
31
|
+
declare function isOtpCode(value: string, length?: number): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Formats the resend-cooldown error message exactly as the backend emits it.
|
|
34
|
+
* Exported so mobile copy (if ever shown manually) cannot drift from it.
|
|
35
|
+
*/
|
|
36
|
+
declare function cooldownMessage(remainingMs: number): string;
|
|
37
|
+
/** Formats the remaining-attempts message for a wrong-code rejection. */
|
|
38
|
+
declare function attemptsMessage(remaining: number): string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds a fully composed OTP email { to, subject, text, html }.
|
|
42
|
+
*
|
|
43
|
+
* Inline HTML (no SendGrid dynamic template) on purpose: zero template
|
|
44
|
+
* management per project, and the look is good enough for a code-delivery
|
|
45
|
+
* email. Projects with a branded template should pass their own `sendEmail`
|
|
46
|
+
* instead of customizing this builder.
|
|
47
|
+
*/
|
|
48
|
+
declare function buildOtpEmail(input: OtpEmailInput): OtpEmail;
|
|
49
|
+
|
|
50
|
+
export { CLEANUP_BATCH_SIZE, CODE_LENGTH, DEFAULT_CLEANUP_SCHEDULE, DEFAULT_COLLECTION, MAX_ATTEMPTS, OTP_EXPIRY_MS, OtpEmail, OtpEmailInput, RESEND_COOLDOWN_MS, attemptsMessage, buildOtpEmail, cooldownMessage, isOtpCode, isValidEmail, normalizeEmail };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/shared/constants.ts
|
|
2
|
+
var CODE_LENGTH = 6;
|
|
3
|
+
var RESEND_COOLDOWN_MS = 60 * 1e3;
|
|
4
|
+
var OTP_EXPIRY_MS = 10 * 60 * 1e3;
|
|
5
|
+
var MAX_ATTEMPTS = 5;
|
|
6
|
+
var DEFAULT_COLLECTION = "auth_otps";
|
|
7
|
+
var DEFAULT_CLEANUP_SCHEDULE = "every 15 minutes";
|
|
8
|
+
var CLEANUP_BATCH_SIZE = 400;
|
|
9
|
+
|
|
10
|
+
// src/shared/types.ts
|
|
11
|
+
var OTP_ERROR_CODES = {
|
|
12
|
+
InvalidArgument: "invalid-argument",
|
|
13
|
+
NotFound: "not-found",
|
|
14
|
+
DeadlineExceeded: "deadline-exceeded",
|
|
15
|
+
ResourceExhausted: "resource-exhausted",
|
|
16
|
+
Internal: "internal"
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/shared/utils.ts
|
|
20
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
21
|
+
function normalizeEmail(raw) {
|
|
22
|
+
return raw.trim().toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
function isValidEmail(raw) {
|
|
25
|
+
return EMAIL_REGEX.test(normalizeEmail(raw));
|
|
26
|
+
}
|
|
27
|
+
function isOtpCode(value, length = CODE_LENGTH) {
|
|
28
|
+
return new RegExp(`^\\d{${length}}$`).test(value);
|
|
29
|
+
}
|
|
30
|
+
function cooldownMessage(remainingMs) {
|
|
31
|
+
const seconds = Math.ceil(remainingMs / 1e3);
|
|
32
|
+
return `Please wait ${seconds} second${seconds === 1 ? "" : "s"} before requesting a new code.`;
|
|
33
|
+
}
|
|
34
|
+
function attemptsMessage(remaining) {
|
|
35
|
+
return `Invalid verification code. You have ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/shared/email-template.ts
|
|
39
|
+
function buildOtpEmail(input) {
|
|
40
|
+
const { to, appName, code, expiresMinutes } = input;
|
|
41
|
+
const subject = `${appName} - Your verification code is ${code}`;
|
|
42
|
+
const text = `Your verification code for ${appName} is: ${code}
|
|
43
|
+
|
|
44
|
+
This code is valid for ${expiresMinutes} minutes.
|
|
45
|
+
If you did not request this, please ignore this email.`;
|
|
46
|
+
const html = `
|
|
47
|
+
<!DOCTYPE html>
|
|
48
|
+
<html>
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8">
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
52
|
+
<title>${escapeHtml(subject)}</title>
|
|
53
|
+
</head>
|
|
54
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
|
55
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8fafc; padding: 40px 16px;">
|
|
56
|
+
<tr>
|
|
57
|
+
<td align="center">
|
|
58
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
|
59
|
+
<tr>
|
|
60
|
+
<td style="padding: 32px 32px 16px 32px; text-align: center;">
|
|
61
|
+
<h1 style="margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: #1e293b;">${escapeHtml(appName)}</h1>
|
|
62
|
+
<p style="margin: 0; font-size: 15px; color: #64748b;">Sign in to your account</p>
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
<tr>
|
|
66
|
+
<td style="padding: 16px 32px 24px 32px; text-align: center;">
|
|
67
|
+
<p style="margin: 0; margin-bottom: 16px; font-size: 14px; color: #475569;">Enter this 6-digit code in the app to continue:</p>
|
|
68
|
+
<div style="display: inline-block; background-color: #f1f5f9; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 16px 32px; font-size: 34px; font-weight: 800; letter-spacing: 8px; color: #0f172a; font-family: monospace;">
|
|
69
|
+
${escapeHtml(code)}
|
|
70
|
+
</div>
|
|
71
|
+
<p style="margin: 16px 0 0 0; font-size: 13px; color: #94a3b8;">
|
|
72
|
+
This code will expire in <strong>${expiresMinutes} minutes</strong>.
|
|
73
|
+
</p>
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
<tr>
|
|
77
|
+
<td style="padding: 20px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0; text-align: center;">
|
|
78
|
+
<p style="margin: 0; font-size: 12px; color: #94a3b8;">
|
|
79
|
+
If you did not request this verification code, you can safely ignore this email.
|
|
80
|
+
</p>
|
|
81
|
+
</td>
|
|
82
|
+
</tr>
|
|
83
|
+
</table>
|
|
84
|
+
</td>
|
|
85
|
+
</tr>
|
|
86
|
+
</table>
|
|
87
|
+
</body>
|
|
88
|
+
</html>
|
|
89
|
+
`;
|
|
90
|
+
return { to, subject, text, html };
|
|
91
|
+
}
|
|
92
|
+
function escapeHtml(value) {
|
|
93
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
94
|
+
}
|
|
95
|
+
export {
|
|
96
|
+
CLEANUP_BATCH_SIZE,
|
|
97
|
+
CODE_LENGTH,
|
|
98
|
+
DEFAULT_CLEANUP_SCHEDULE,
|
|
99
|
+
DEFAULT_COLLECTION,
|
|
100
|
+
MAX_ATTEMPTS,
|
|
101
|
+
OTP_ERROR_CODES,
|
|
102
|
+
OTP_EXPIRY_MS,
|
|
103
|
+
RESEND_COOLDOWN_MS,
|
|
104
|
+
attemptsMessage,
|
|
105
|
+
buildOtpEmail,
|
|
106
|
+
cooldownMessage,
|
|
107
|
+
isOtpCode,
|
|
108
|
+
isValidEmail,
|
|
109
|
+
normalizeEmail
|
|
110
|
+
};
|
|
111
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shared/constants.ts","../../src/shared/types.ts","../../src/shared/utils.ts","../../src/shared/email-template.ts"],"sourcesContent":["/**\n * Single source of truth for the OTP contract.\n *\n * Backend factories and mobile components both read these as their defaults,\n * so the UI cooldown and the backend rate limit cannot drift apart silently.\n * A project may override them via factory options — if it does, override the\n * SAME value on both sides (e.g. a custom cooldownMs in the backend factory\n * AND as the `cooldownMs` prop on the mobile modal).\n */\n\n/** Number of digits in an OTP code. */\nexport const CODE_LENGTH = 6;\n\n/** Minimum time between two code requests for the same email. */\nexport const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds\n\n/** Lifetime of an issued code. */\nexport const OTP_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes\n\n/** Failed verifications allowed per code before it is invalidated. */\nexport const MAX_ATTEMPTS = 5;\n\n/** Firestore collection that stores pending OTPs (doc id = email). */\nexport const DEFAULT_COLLECTION = 'auth_otps';\n\n/** Default schedule for the cleanup function that reaps expired docs. */\nexport const DEFAULT_CLEANUP_SCHEDULE = 'every 15 minutes';\n\n/** Writes per batch in the cleanup drain loop (Firestore limit is 500). */\nexport const CLEANUP_BATCH_SIZE = 400;\n","// ============================================================================\n// Payloads (App -> Backend)\n// ============================================================================\n\nexport interface RequestOtpPayload {\n email: string;\n}\n\nexport interface VerifyOtpPayload {\n email: string;\n code: string;\n}\n\n// ============================================================================\n// Responses (Backend -> App)\n// ============================================================================\n\nexport interface RequestOtpResponse {\n status: 'success';\n message: string;\n}\n\nexport interface VerifyOtpResponse {\n status: 'success';\n /** Firebase custom auth token — exchange with signInWithCustomToken. */\n customToken: string;\n}\n\n// ============================================================================\n// Backend option types\n// ============================================================================\n\n/** A fully composed OTP email, ready to dispatch. */\nexport interface OtpEmail {\n to: string;\n subject: string;\n text: string;\n html: string;\n}\n\n/**\n * Delivers the OTP email. The default implementation sends via SendGrid;\n * projects can replace it entirely (custom provider, or a console-logging\n * sender during development).\n */\nexport type OtpEmailSender = (email: OtpEmail) => Promise<void>;\n\n/** Inputs for {@link buildOtpEmail}. */\nexport interface OtpEmailInput {\n to: string;\n appName: string;\n /** The plaintext 6-digit code — only ever passed to the sender, never persisted. */\n code: string;\n /** Code lifetime in minutes, for the email copy. */\n expiresMinutes: number;\n}\n\nexport interface SendgridSenderOptions {\n /**\n * The API key, resolved per call. Accepts a plain string, a getter\n * function (`defineSecret('X').value`), or a Firebase params Secret\n * object (`defineSecret('X')`) directly.\n */\n apiKey: string | (() => string) | { value: () => string };\n fromEmail: string;\n fromName: string;\n}\n\n/** Base options shared by the request/verify factories. */\nexport interface BaseOtpOptions {\n /** Firestore collection holding pending OTPs. Default: `auth_otps`. */\n collection?: string;\n /** Resend cooldown in ms. Default: 60_000. */\n cooldownMs?: number;\n /** Code lifetime in ms. Default: 600_000 (10 minutes). */\n expiryMs?: number;\n /** Failed verifications before the code is invalidated. Default: 5. */\n maxAttempts?: number;\n /** Enforce App Check on the callable. Default: false (flip true in production). */\n enforceAppCheck?: boolean;\n}\n\nexport interface RequestOtpOptions extends BaseOtpOptions {\n /** Brand name used in the email subject and body. Required. */\n appName: string;\n /** Replace the default SendGrid delivery entirely. */\n sendEmail?: OtpEmailSender;\n /** SendGrid sender config; required unless `sendEmail` is provided. */\n sendgrid?: SendgridSenderOptions;\n}\n\n/**\n * Resolves (and, if needed, creates) the auth user for an email, returning\n * the uid the custom token will be minted for. This is the one required\n * project-specific hook — it owns the app's user schema.\n */\nexport type OtpProvisioner = (email: string) => Promise<string>;\n\nexport interface VerifyOtpOptions extends BaseOtpOptions {\n /** Required. Creates or resolves the user and returns their uid. */\n provision: OtpProvisioner;\n}\n\nexport interface CleanupOtpOptions {\n /** Cloud Scheduler cron. Default: `every 15 minutes`. */\n schedule?: string;\n /** Collection to clean. Default: `auth_otps`. */\n collection?: string;\n /** Writes per batch. Default: 400 (Firestore limit is 500). */\n batchSize?: number;\n}\n\n// ============================================================================\n// Stored document shape (Firestore `auth_otps/{email}`)\n// ============================================================================\n\nexport interface OtpDocumentData {\n /** SHA-256 hex of `email:code`. The plaintext code is never stored. */\n hash: string;\n /** Failed guess counter. */\n attempts: number;\n /** Expiry as epoch milliseconds. */\n expiresAt: number;\n /** Last dispatch as epoch milliseconds (drives the resend cooldown). */\n lastSentAt: number;\n createdAt?: unknown;\n}\n\n// ============================================================================\n// Error codes (HttpsError status codes used by the factories)\n// ============================================================================\n\nexport const OTP_ERROR_CODES = {\n InvalidArgument: 'invalid-argument',\n NotFound: 'not-found',\n DeadlineExceeded: 'deadline-exceeded',\n ResourceExhausted: 'resource-exhausted',\n Internal: 'internal',\n} as const;\n\nexport type OtpErrorCode = (typeof OTP_ERROR_CODES)[keyof typeof OTP_ERROR_CODES];\n","import { CODE_LENGTH } from './constants';\n\n/** Deliberately simple RFC-ish check — mirrors the client-side pre-check. */\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/** Trim + lowercase an email so doc keys and hash inputs always match. */\nexport function normalizeEmail(raw: string): string {\n return raw.trim().toLowerCase();\n}\n\nexport function isValidEmail(raw: string): boolean {\n return EMAIL_REGEX.test(normalizeEmail(raw));\n}\n\nexport function isOtpCode(value: string, length: number = CODE_LENGTH): boolean {\n return new RegExp(`^\\\\d{${length}}$`).test(value);\n}\n\n/**\n * Formats the resend-cooldown error message exactly as the backend emits it.\n * Exported so mobile copy (if ever shown manually) cannot drift from it.\n */\nexport function cooldownMessage(remainingMs: number): string {\n const seconds = Math.ceil(remainingMs / 1000);\n return `Please wait ${seconds} second${seconds === 1 ? '' : 's'} before requesting a new code.`;\n}\n\n/** Formats the remaining-attempts message for a wrong-code rejection. */\nexport function attemptsMessage(remaining: number): string {\n return `Invalid verification code. You have ${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`;\n}\n","import type { OtpEmail, OtpEmailInput } from './types';\n\n/**\n * Builds a fully composed OTP email { to, subject, text, html }.\n *\n * Inline HTML (no SendGrid dynamic template) on purpose: zero template\n * management per project, and the look is good enough for a code-delivery\n * email. Projects with a branded template should pass their own `sendEmail`\n * instead of customizing this builder.\n */\nexport function buildOtpEmail(input: OtpEmailInput): OtpEmail {\n const { to, appName, code, expiresMinutes } = input;\n\n const subject = `${appName} - Your verification code is ${code}`;\n\n const text =\n `Your verification code for ${appName} is: ${code}\\n\\n` +\n `This code is valid for ${expiresMinutes} minutes.\\n` +\n `If you did not request this, please ignore this email.`;\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(subject)}</title>\n </head>\n <body style=\"margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color: #f8fafc; padding: 40px 16px;\">\n <tr>\n <td align=\"center\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width: 480px; background-color: #ffffff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);\">\n <tr>\n <td style=\"padding: 32px 32px 16px 32px; text-align: center;\">\n <h1 style=\"margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: #1e293b;\">${escapeHtml(appName)}</h1>\n <p style=\"margin: 0; font-size: 15px; color: #64748b;\">Sign in to your account</p>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 16px 32px 24px 32px; text-align: center;\">\n <p style=\"margin: 0; margin-bottom: 16px; font-size: 14px; color: #475569;\">Enter this 6-digit code in the app to continue:</p>\n <div style=\"display: inline-block; background-color: #f1f5f9; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 16px 32px; font-size: 34px; font-weight: 800; letter-spacing: 8px; color: #0f172a; font-family: monospace;\">\n ${escapeHtml(code)}\n </div>\n <p style=\"margin: 16px 0 0 0; font-size: 13px; color: #94a3b8;\">\n This code will expire in <strong>${expiresMinutes} minutes</strong>.\n </p>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 20px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0; text-align: center;\">\n <p style=\"margin: 0; font-size: 12px; color: #94a3b8;\">\n If you did not request this verification code, you can safely ignore this email.\n </p>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n </html>\n `;\n\n return { to, subject, text, html };\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n"],"mappings":";AAWO,IAAM,cAAc;AAGpB,IAAM,qBAAqB,KAAK;AAGhC,IAAM,gBAAgB,KAAK,KAAK;AAGhC,IAAM,eAAe;AAGrB,IAAM,qBAAqB;AAG3B,IAAM,2BAA2B;AAGjC,IAAM,qBAAqB;;;ACuG3B,IAAM,kBAAkB;AAAA,EAC7B,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,mBAAmB;AAAA,EACnB,UAAU;AACZ;;;ACvIA,IAAM,cAAc;AAGb,SAAS,eAAe,KAAqB;AAClD,SAAO,IAAI,KAAK,EAAE,YAAY;AAChC;AAEO,SAAS,aAAa,KAAsB;AACjD,SAAO,YAAY,KAAK,eAAe,GAAG,CAAC;AAC7C;AAEO,SAAS,UAAU,OAAe,SAAiB,aAAsB;AAC9E,SAAO,IAAI,OAAO,QAAQ,MAAM,IAAI,EAAE,KAAK,KAAK;AAClD;AAMO,SAAS,gBAAgB,aAA6B;AAC3D,QAAM,UAAU,KAAK,KAAK,cAAc,GAAI;AAC5C,SAAO,eAAe,OAAO,UAAU,YAAY,IAAI,KAAK,GAAG;AACjE;AAGO,SAAS,gBAAgB,WAA2B;AACzD,SAAO,uCAAuC,SAAS,WAAW,cAAc,IAAI,KAAK,GAAG;AAC9F;;;ACpBO,SAAS,cAAc,OAAgC;AAC5D,QAAM,EAAE,IAAI,SAAS,MAAM,eAAe,IAAI;AAE9C,QAAM,UAAU,GAAG,OAAO,gCAAgC,IAAI;AAE9D,QAAM,OACJ,8BAA8B,OAAO,QAAQ,IAAI;AAAA;AAAA,yBACvB,cAAc;AAAA;AAG1C,QAAM,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAMI,WAAW,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0GASoE,WAAW,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAQnG,WAAW,IAAI,CAAC;AAAA;AAAA;AAAA,2DAGiB,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBvE,SAAO,EAAE,IAAI,SAAS,MAAM,KAAK;AACnC;AAEA,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;","names":[]}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
interface RequestOtpPayload {
|
|
2
|
+
email: string;
|
|
3
|
+
}
|
|
4
|
+
interface VerifyOtpPayload {
|
|
5
|
+
email: string;
|
|
6
|
+
code: string;
|
|
7
|
+
}
|
|
8
|
+
interface RequestOtpResponse {
|
|
9
|
+
status: 'success';
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
interface VerifyOtpResponse {
|
|
13
|
+
status: 'success';
|
|
14
|
+
/** Firebase custom auth token — exchange with signInWithCustomToken. */
|
|
15
|
+
customToken: string;
|
|
16
|
+
}
|
|
17
|
+
/** A fully composed OTP email, ready to dispatch. */
|
|
18
|
+
interface OtpEmail {
|
|
19
|
+
to: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
text: string;
|
|
22
|
+
html: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Delivers the OTP email. The default implementation sends via SendGrid;
|
|
26
|
+
* projects can replace it entirely (custom provider, or a console-logging
|
|
27
|
+
* sender during development).
|
|
28
|
+
*/
|
|
29
|
+
type OtpEmailSender = (email: OtpEmail) => Promise<void>;
|
|
30
|
+
/** Inputs for {@link buildOtpEmail}. */
|
|
31
|
+
interface OtpEmailInput {
|
|
32
|
+
to: string;
|
|
33
|
+
appName: string;
|
|
34
|
+
/** The plaintext 6-digit code — only ever passed to the sender, never persisted. */
|
|
35
|
+
code: string;
|
|
36
|
+
/** Code lifetime in minutes, for the email copy. */
|
|
37
|
+
expiresMinutes: number;
|
|
38
|
+
}
|
|
39
|
+
interface SendgridSenderOptions {
|
|
40
|
+
/**
|
|
41
|
+
* The API key, resolved per call. Accepts a plain string, a getter
|
|
42
|
+
* function (`defineSecret('X').value`), or a Firebase params Secret
|
|
43
|
+
* object (`defineSecret('X')`) directly.
|
|
44
|
+
*/
|
|
45
|
+
apiKey: string | (() => string) | {
|
|
46
|
+
value: () => string;
|
|
47
|
+
};
|
|
48
|
+
fromEmail: string;
|
|
49
|
+
fromName: string;
|
|
50
|
+
}
|
|
51
|
+
/** Base options shared by the request/verify factories. */
|
|
52
|
+
interface BaseOtpOptions {
|
|
53
|
+
/** Firestore collection holding pending OTPs. Default: `auth_otps`. */
|
|
54
|
+
collection?: string;
|
|
55
|
+
/** Resend cooldown in ms. Default: 60_000. */
|
|
56
|
+
cooldownMs?: number;
|
|
57
|
+
/** Code lifetime in ms. Default: 600_000 (10 minutes). */
|
|
58
|
+
expiryMs?: number;
|
|
59
|
+
/** Failed verifications before the code is invalidated. Default: 5. */
|
|
60
|
+
maxAttempts?: number;
|
|
61
|
+
/** Enforce App Check on the callable. Default: false (flip true in production). */
|
|
62
|
+
enforceAppCheck?: boolean;
|
|
63
|
+
}
|
|
64
|
+
interface RequestOtpOptions extends BaseOtpOptions {
|
|
65
|
+
/** Brand name used in the email subject and body. Required. */
|
|
66
|
+
appName: string;
|
|
67
|
+
/** Replace the default SendGrid delivery entirely. */
|
|
68
|
+
sendEmail?: OtpEmailSender;
|
|
69
|
+
/** SendGrid sender config; required unless `sendEmail` is provided. */
|
|
70
|
+
sendgrid?: SendgridSenderOptions;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolves (and, if needed, creates) the auth user for an email, returning
|
|
74
|
+
* the uid the custom token will be minted for. This is the one required
|
|
75
|
+
* project-specific hook — it owns the app's user schema.
|
|
76
|
+
*/
|
|
77
|
+
type OtpProvisioner = (email: string) => Promise<string>;
|
|
78
|
+
interface VerifyOtpOptions extends BaseOtpOptions {
|
|
79
|
+
/** Required. Creates or resolves the user and returns their uid. */
|
|
80
|
+
provision: OtpProvisioner;
|
|
81
|
+
}
|
|
82
|
+
interface CleanupOtpOptions {
|
|
83
|
+
/** Cloud Scheduler cron. Default: `every 15 minutes`. */
|
|
84
|
+
schedule?: string;
|
|
85
|
+
/** Collection to clean. Default: `auth_otps`. */
|
|
86
|
+
collection?: string;
|
|
87
|
+
/** Writes per batch. Default: 400 (Firestore limit is 500). */
|
|
88
|
+
batchSize?: number;
|
|
89
|
+
}
|
|
90
|
+
interface OtpDocumentData {
|
|
91
|
+
/** SHA-256 hex of `email:code`. The plaintext code is never stored. */
|
|
92
|
+
hash: string;
|
|
93
|
+
/** Failed guess counter. */
|
|
94
|
+
attempts: number;
|
|
95
|
+
/** Expiry as epoch milliseconds. */
|
|
96
|
+
expiresAt: number;
|
|
97
|
+
/** Last dispatch as epoch milliseconds (drives the resend cooldown). */
|
|
98
|
+
lastSentAt: number;
|
|
99
|
+
createdAt?: unknown;
|
|
100
|
+
}
|
|
101
|
+
declare const OTP_ERROR_CODES: {
|
|
102
|
+
readonly InvalidArgument: "invalid-argument";
|
|
103
|
+
readonly NotFound: "not-found";
|
|
104
|
+
readonly DeadlineExceeded: "deadline-exceeded";
|
|
105
|
+
readonly ResourceExhausted: "resource-exhausted";
|
|
106
|
+
readonly Internal: "internal";
|
|
107
|
+
};
|
|
108
|
+
type OtpErrorCode = (typeof OTP_ERROR_CODES)[keyof typeof OTP_ERROR_CODES];
|
|
109
|
+
|
|
110
|
+
export { type BaseOtpOptions as B, type CleanupOtpOptions as C, type OtpEmailSender as O, type RequestOtpOptions as R, type SendgridSenderOptions as S, type VerifyOtpOptions as V, type RequestOtpPayload as a, type RequestOtpResponse as b, type VerifyOtpPayload as c, type VerifyOtpResponse as d, type OtpEmailInput as e, type OtpEmail as f, OTP_ERROR_CODES as g, type OtpDocumentData as h, type OtpErrorCode as i, type OtpProvisioner as j };
|