Added email/phone verification
This commit is contained in:
parent
a2a2d9b558
commit
666427a7c9
@ -1 +1 @@
|
||||
90af14136b0b935418ae62167703d1dcbcb7b3ce-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -16,6 +16,7 @@ import rateLimit from 'express-rate-limit';
|
||||
import { readFile } from 'fs/promises'; // ← needed for /healthz
|
||||
import { requireAuth } from './shared/requireAuth.js';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { sendSMS } from './utils/smsService.js';
|
||||
|
||||
const CANARY_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS encryption_canary (
|
||||
@ -507,6 +508,28 @@ const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
|
||||
const PASSWORD_HELP =
|
||||
'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Verification helpers / rate limits
|
||||
---------------------------------------------------------------- */
|
||||
function absoluteWebBase() {
|
||||
// You already require APTIVA_API_BASE above; reuse it to build magic links.
|
||||
return String(RESET_CONFIG.BASE_URL || '').replace(/\/+$/, '');
|
||||
}
|
||||
const verifySendLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 3,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
const verifyConfirmLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 6,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
// Change password (must be logged in)
|
||||
app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => {
|
||||
try {
|
||||
@ -706,6 +729,133 @@ app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
AUTH STATUS (used by client gate)
|
||||
------------------------------------------------------------------ */
|
||||
app.get('/api/auth/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const uid = req.userId;
|
||||
const [rows] = await (pool.raw || pool).query(
|
||||
'SELECT email_verified_at, phone_verified_at FROM user_profile WHERE id = ? LIMIT 1',
|
||||
[uid]
|
||||
);
|
||||
const row = rows?.[0] || {};
|
||||
return res.status(200).json({
|
||||
is_authenticated: true,
|
||||
email_verified_at: row.email_verified_at || null,
|
||||
phone_verified_at: row.phone_verified_at || null
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[auth/status]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
EMAIL VERIFICATION (send + confirm)
|
||||
------------------------------------------------------------------ */
|
||||
app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (req, res) => {
|
||||
try {
|
||||
if (!SENDGRID_ENABLED) return res.status(503).json({ error: 'Email not configured' });
|
||||
const uid = req.userId;
|
||||
const [[row]] = await (pool.raw || pool).query('SELECT email FROM user_profile WHERE id=? LIMIT 1', [uid]);
|
||||
const enc = row?.email;
|
||||
if (!enc) return res.status(400).json({ error: 'No email on file' });
|
||||
let emailPlain = '';
|
||||
try { emailPlain = decrypt(enc); } catch { emailPlain = enc; }
|
||||
|
||||
const token = jwt.sign({ sub: String(uid), prp: 'verify_email' }, JWT_SECRET, { expiresIn: '30m' });
|
||||
const link = `${absoluteWebBase()}/verify?t=${encodeURIComponent(token)}`;
|
||||
|
||||
const text =
|
||||
`Verify your AptivaAI email by clicking the link below (expires in 30 minutes):
|
||||
+${link}`;
|
||||
|
||||
await sgMail.send({
|
||||
to: emailPlain,
|
||||
from: RESET_CONFIG.FROM,
|
||||
subject: 'Verify your email — AptivaAI',
|
||||
text,
|
||||
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
|
||||
});
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('[verify/email/send]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Failed to send verification email' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body || {};
|
||||
if (!token) return res.status(400).json({ error: 'Token required' });
|
||||
let payload;
|
||||
try { payload = jwt.verify(token, JWT_SECRET); }
|
||||
catch { return res.status(400).json({ error: 'Invalid or expired token' }); }
|
||||
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_email') {
|
||||
return res.status(400).json({ error: 'Token/user mismatch' });
|
||||
}
|
||||
const [r] = await (pool.raw || pool).query(
|
||||
'UPDATE user_profile SET email_verified_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||
[req.userId]
|
||||
);
|
||||
return res.status(200).json({ ok: !!r?.affectedRows });
|
||||
} catch (e) {
|
||||
console.error('[verify/email/confirm]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Failed to confirm email' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
PHONE VERIFICATION (send + confirm) — optional
|
||||
------------------------------------------------------------------ */
|
||||
app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => {
|
||||
try {
|
||||
const { phone_e164 } = req.body || {};
|
||||
if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) {
|
||||
return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' });
|
||||
}
|
||||
// persist/overwrite phone on file
|
||||
await (pool.raw || pool).query('UPDATE user_profile SET phone_e164=? WHERE id=?', [phone_e164, req.userId]);
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
await sendSMS({ to: phone_e164, body: `AptivaAI security code: ${code}. Expires in 10 minutes.` });
|
||||
|
||||
// store short-lived challenge in HttpOnly cookie (10 min)
|
||||
const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' });
|
||||
res.cookie('aptiva_phone_vc', tok, { ...sessionCookieOptions(), maxAge: 10 * 60 * 1000 });
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('[verify/phone/send]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Failed to send code' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body || {};
|
||||
if (!code) return res.status(400).json({ error: 'Code required' });
|
||||
const tok = req.cookies?.aptiva_phone_vc;
|
||||
if (!tok) return res.status(400).json({ error: 'No challenge issued' });
|
||||
let payload;
|
||||
try { payload = jwt.verify(tok, JWT_SECRET); }
|
||||
catch { return res.status(400).json({ error: 'Challenge expired' }); }
|
||||
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_phone') {
|
||||
return res.status(400).json({ error: 'Challenge mismatch' });
|
||||
}
|
||||
if (String(payload?.code) !== String(code)) {
|
||||
return res.status(400).json({ error: 'Invalid code' });
|
||||
}
|
||||
const [r] = await (pool.raw || pool).query(
|
||||
'UPDATE user_profile SET phone_verified_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||
[req.userId]
|
||||
);
|
||||
res.clearCookie('aptiva_phone_vc', sessionCookieOptions());
|
||||
return res.status(200).json({ ok: !!r?.affectedRows });
|
||||
} catch (e) {
|
||||
console.error('[verify/phone/confirm]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Failed to confirm phone' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
USER REGISTRATION (MySQL)
|
||||
@ -1011,7 +1161,9 @@ app.get('/api/user-profile', requireAuth, async (req, res) => {
|
||||
'career_priorities','interest_inventory_answers','riasec_scores','career_list',
|
||||
'email',
|
||||
'phone_e164',
|
||||
'sms_opt_in'
|
||||
'sms_opt_in',
|
||||
'email_verified_at',
|
||||
'phone_verified_at'
|
||||
]);
|
||||
|
||||
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
@ -50,6 +50,9 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
|
||||
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID}
|
||||
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN}
|
||||
TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID}
|
||||
SALARY_DB_PATH: /app/salary_info.db
|
||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||
volumes:
|
||||
|
@ -248,3 +248,7 @@ mysqldump \
|
||||
user_profile_db > full_schema.sql
|
||||
|
||||
|
||||
-- /home/jcoakley/sql/2025-09-11_add_verification_flags.sql
|
||||
ALTER TABLE user_profile
|
||||
ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup,
|
||||
ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164;
|
||||
|
90
src/App.js
90
src/App.js
@ -45,6 +45,8 @@ import ResetPassword from './components/ResetPassword.js';
|
||||
import { clearToken } from './auth/authMemory.js';
|
||||
import api from './auth/apiClient.js';
|
||||
import * as safeLocal from './utils/safeLocal.js';
|
||||
import VerificationGate from './components/VerificationGate.js';
|
||||
import Verify from './components/Verify.js';
|
||||
|
||||
|
||||
|
||||
@ -655,81 +657,33 @@ const cancelLogout = () => {
|
||||
|
||||
|
||||
<Route path="/paywall" element={<Paywall />} />
|
||||
<Route path="/verify" element={<Verify />} />
|
||||
|
||||
{/* Authenticated routes */}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Route path="/signin-landing" element={<SignInLanding user={user} />} />
|
||||
<Route path="/interest-inventory" element={<InterestInventory />} />
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/planning" element={<PlanningLanding />} />
|
||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />} />
|
||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
<Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} />
|
||||
<Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} />
|
||||
<Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} />
|
||||
<Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} />
|
||||
<Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
|
||||
<Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
|
||||
<Route path="/educational-programs" element={<VerificationGate><EducationalProgramsPage /></VerificationGate>} />
|
||||
<Route path="/preparing" element={<VerificationGate><PreparingLanding /></VerificationGate>} />
|
||||
<Route path="/billing" element={<BillingResult />} />
|
||||
|
||||
{/* Premium-wrapped */}
|
||||
<Route
|
||||
path="/enhancing"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<EnhancingLanding />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/retirement"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<RetirementLanding />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/career-roadmap/:careerId?"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<CareerRoadmap />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/profile/careers" element={<CareerProfileList />} />
|
||||
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
|
||||
<Route path="/profile/college" element={<CollegeProfileList />} />
|
||||
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
|
||||
<Route
|
||||
path="/financial-profile"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<FinancialProfileForm />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/retirement-planner"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<RetirementPlanner />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/premium-onboarding"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<OnboardingContainer />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resume-optimizer"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<ResumeRewrite />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/enhancing" element={<VerificationGate><PremiumRoute user={user}><EnhancingLanding /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/retirement" element={<VerificationGate><PremiumRoute user={user}><RetirementLanding /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/career-roadmap/:careerId?" element={<VerificationGate><PremiumRoute user={user}><CareerRoadmap /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/profile/careers" element={<VerificationGate><CareerProfileList /></VerificationGate>} />
|
||||
<Route path="/profile/careers/:id/edit" element={<VerificationGate><CareerProfileForm /></VerificationGate>} />
|
||||
<Route path="/profile/college" element={<VerificationGate><CollegeProfileList /></VerificationGate>} />
|
||||
<Route path="/profile/college/:careerId/:id?" element={<VerificationGate><CollegeProfileForm /></VerificationGate>} />
|
||||
<Route path="/financial-profile" element={<VerificationGate><PremiumRoute user={user}><FinancialProfileForm /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/retirement-planner" element={<VerificationGate><PremiumRoute user={user}><RetirementPlanner /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/premium-onboarding" element={<VerificationGate><PremiumRoute user={user}><OnboardingContainer /></PremiumRoute></VerificationGate>} />
|
||||
<Route path="/resume-optimizer" element={<VerificationGate><PremiumRoute user={user}><ResumeRewrite /></PremiumRoute></VerificationGate>} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
34
src/components/VerificationGate.js
Normal file
34
src/components/VerificationGate.js
Normal file
@ -0,0 +1,34 @@
|
||||
// /home/jcoakley/aptiva-dev1-app/src/components/VerificationGate.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../auth/apiClient.js';
|
||||
import { useLocation, Navigate } from 'react-router-dom';
|
||||
|
||||
export default function VerificationGate({ children }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const loc = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Ask user-profile for extra fields; backend safely ignores unknown "fields"
|
||||
const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium,email_verified_at,phone_verified_at');
|
||||
if (cancelled) return;
|
||||
const v = Boolean(data?.email_verified_at || data?.phone_verified_at);
|
||||
setVerified(v);
|
||||
} catch {
|
||||
setVerified(false);
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [loc.pathname]);
|
||||
|
||||
if (!ready) return null; // keep splash minimal
|
||||
if (!verified && !/^\/verify(?:$|\?)/.test(loc.pathname)) {
|
||||
return <Navigate to="/verify" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
171
src/components/Verify.js
Normal file
171
src/components/Verify.js
Normal file
@ -0,0 +1,171 @@
|
||||
// /home/jcoakley/aptiva-dev1-app/src/components/Verify.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
import api from '../auth/apiClient.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Verify() {
|
||||
const navigate = useNavigate();
|
||||
const [msg, setMsg] = useState('');
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const [token, setToken] = useState(() => qs.get('t') || '');
|
||||
const next = qs.get('next') || '/signin-landing';
|
||||
const [phone, setPhone] = useState('1');
|
||||
const [code, setCode] = useState('');
|
||||
const [sendingEmail, setSendingEmail] = useState(false);
|
||||
const [sendingSms, setSendingSms] = useState(false);
|
||||
const [confirmingSms, setConfirmingSms] = useState(false);
|
||||
const [smsConsent, setSmsConsent] = useState(false); // explicit consent for SMS
|
||||
|
||||
const sendEmail = async () => {
|
||||
if (sendingEmail) return;
|
||||
setSendingEmail(true);
|
||||
try {
|
||||
await api.post('/api/auth/verify/email/send', {});
|
||||
setMsg('Verification email sent.');
|
||||
} catch {
|
||||
setMsg('Could not send verification email.');
|
||||
} finally {
|
||||
// auto re-enable after 30s to avoid lockout
|
||||
setTimeout(() => setSendingEmail(false), 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEmail = async () => {
|
||||
try {
|
||||
await api.post('/api/auth/verify/email/confirm', { token });
|
||||
setMsg('Email verified. Redirecting…');
|
||||
// give backend a heartbeat to persist, then navigate
|
||||
setTimeout(() => navigate(next, { replace: true }), 350);
|
||||
}
|
||||
catch { setMsg('Invalid or expired email token.'); }
|
||||
};
|
||||
|
||||
const sendSms = async () => {
|
||||
if (sendingSms) return;
|
||||
setSendingSms(true);
|
||||
try {
|
||||
await api.post('/api/auth/verify/phone/send', { phone_e164: phone });
|
||||
setMsg('SMS code sent.');
|
||||
} catch {
|
||||
setMsg('Could not send SMS code.');
|
||||
} finally {
|
||||
// re-enable after 30s; avoids hammering the endpoint
|
||||
setTimeout(() => setSendingSms(false), 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSms = async () => {
|
||||
if (confirmingSms) return;
|
||||
setConfirmingSms(true);
|
||||
try {
|
||||
await api.post('/api/auth/verify/phone/confirm', { code });
|
||||
setMsg('Phone verified. Redirecting…');
|
||||
setTimeout(() => navigate(next, { replace: true }), 350);
|
||||
} catch {
|
||||
setMsg('Invalid or expired code.');
|
||||
} finally {
|
||||
setConfirmingSms(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) { confirmEmail(); } // magic-link auto confirm → then redirect
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold">Verify your account</h1>
|
||||
<p className="text-sm text-gray-600">You must verify before using AptivaAI.</p>
|
||||
|
||||
{/* EMAIL CARD */}
|
||||
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
|
||||
<h2 className="text-sm font-medium text-gray-800">Email verification</h2>
|
||||
<div className="grid grid-cols-[8rem,1fr,6.5rem] items-stretch gap-2">
|
||||
<Button
|
||||
className="w-full text-sm"
|
||||
onClick={sendEmail}
|
||||
disabled={sendingEmail}
|
||||
>
|
||||
{sendingEmail ? 'Sending…' : 'Send email'}
|
||||
</Button>
|
||||
<input
|
||||
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Paste token"
|
||||
value={token}
|
||||
onChange={e=>setToken(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="w-full text-sm"
|
||||
variant="secondary"
|
||||
onClick={confirmEmail}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PHONE CARD */}
|
||||
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
|
||||
<h2 className="text-sm font-medium text-gray-800">Phone verification (optional)</h2>
|
||||
|
||||
|
||||
{/* Explicit SMS consent (required for A2P accuracy) */}
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="sms-consent"
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border"
|
||||
checked={smsConsent}
|
||||
onChange={e => setSmsConsent(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5">
|
||||
By requesting a code, you agree to receive one-time security texts from AptivaAI to verify your account.
|
||||
Reply STOP to opt out. Msg & data rates may apply.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-[1fr,7.5rem] items-stretch gap-2">
|
||||
<input
|
||||
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={phone}
|
||||
placeholder="+1XXXXXXXXXX"
|
||||
onChange={e => {
|
||||
let d = e.target.value.replace(/\D/g,'');
|
||||
if (!d.startsWith('1')) d = '1' + d;
|
||||
const v = '+' + d.slice(0,11);
|
||||
setPhone(v);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="px-3 whitespace-nowrap w-full"
|
||||
onClick={sendSms}
|
||||
disabled={sendingSms || !smsConsent}
|
||||
>
|
||||
{sendingSms ? 'Sending…' : 'Send code'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr,6.5rem] items-stretch gap-2">
|
||||
<input
|
||||
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={e=>setCode(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="px-3 whitespace-nowrap w-full"
|
||||
variant="secondary"
|
||||
onClick={confirmSms}
|
||||
disabled={confirmingSms || !code}
|
||||
>
|
||||
{confirmingSms ? 'Checking…' : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!msg && <div className="text-sm text-gray-800">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user