feat: combine email verification and device linking into single flow

- Modified handleRequestDeviceLink to accept unverified/new emails
- User can now create account via email on any device
- handleLinkDevice now also verifies email when completing device link
- Updated frontend CryptID.tsx with contextual messaging
- Added needsUsername flow for new account creation via email
- One click now verifies email AND links device, saving user steps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-04 04:47:40 -08:00
parent be6f58630f
commit d672275adf
3 changed files with 241 additions and 55 deletions

View File

@ -29,6 +29,10 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
const [suggestedUsername, setSuggestedUsername] = useState<string>(''); const [suggestedUsername, setSuggestedUsername] = useState<string>('');
const [emailLinkSent, setEmailLinkSent] = useState(false); const [emailLinkSent, setEmailLinkSent] = useState(false);
const [pendingCryptId, setPendingCryptId] = useState<string | null>(null); const [pendingCryptId, setPendingCryptId] = useState<string | null>(null);
const [needsUsername, setNeedsUsername] = useState(false);
const [newAccountUsername, setNewAccountUsername] = useState('');
const [isNewAccountFlow, setIsNewAccountFlow] = useState(false);
const [needsEmailVerificationFlow, setNeedsEmailVerificationFlow] = useState(false);
const [browserSupport, setBrowserSupport] = useState<{ const [browserSupport, setBrowserSupport] = useState<{
supported: boolean; supported: boolean;
secure: boolean; secure: boolean;
@ -130,15 +134,24 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
/** /**
* Handle email link request (Device B - new device) * Handle email link request (Device B - new device)
* Combined flow: This will verify email AND link device in one step
*/ */
const handleEmailLinkRequest = async () => { const handleEmailLinkRequest = async () => {
if (!email) return; if (!email) return;
// If we need a username but don't have one, show the username form
if (needsUsername && !newAccountUsername.trim()) {
setError('Please enter a CryptID username to create your account');
return;
}
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
try { try {
const result = await requestDeviceLink(email); const result = await requestDeviceLink(email, {
cryptidUsername: needsUsername ? newAccountUsername.trim() : undefined
});
if (result.success) { if (result.success) {
if (result.alreadyLinked) { if (result.alreadyLinked) {
@ -155,13 +168,30 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
// Email sent - show waiting message // Email sent - show waiting message
setEmailLinkSent(true); setEmailLinkSent(true);
setPendingCryptId(result.cryptidUsername || null); setPendingCryptId(result.cryptidUsername || null);
addNotification('Verification email sent! Check your inbox.', 'success'); setNeedsUsername(false);
setIsNewAccountFlow(result.isNewAccount || false);
setNeedsEmailVerificationFlow(result.needsEmailVerification || false);
// Contextual message based on flow type
if (result.isNewAccount) {
addNotification('Setup email sent! Check your inbox to complete registration.', 'success');
} else if (result.needsEmailVerification) {
addNotification('Verification email sent! This will verify your email and link this device.', 'success');
} else {
addNotification('Device link email sent! Check your inbox.', 'success');
}
} else { } else {
setError('Failed to send verification email'); setError('Failed to send verification email');
} }
} else {
// Handle needsUsername response from server
if (result.needsUsername) {
setNeedsUsername(true);
setError('No account found for this email. Please choose a CryptID username to create your account.');
} else { } else {
setError(result.error || 'Failed to request device link'); setError(result.error || 'Failed to request device link');
} }
}
} catch (err) { } catch (err) {
console.error('Email link request error:', err); console.error('Email link request error:', err);
setError('An unexpected error occurred'); setError('An unexpected error occurred');
@ -252,21 +282,41 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
// Email link sent - waiting for user to click verification // Email link sent - waiting for user to click verification
if (authMode === 'email-link' && emailLinkSent) { if (authMode === 'email-link' && emailLinkSent) {
// Determine contextual messaging
const getHeadline = () => {
if (isNewAccountFlow) return 'Complete Your Setup';
if (needsEmailVerificationFlow) return 'Verify & Connect';
return 'Check Your Email';
};
const getDescription = () => {
if (isNewAccountFlow) {
return 'Click the link in the email to verify your email and set up your CryptID account on this device.';
}
if (needsEmailVerificationFlow) {
return 'Click the link to verify your email and connect this device to your account in one step.';
}
return 'Click the link in the email to complete sign in on this device.';
};
return ( return (
<div className="crypto-login-container"> <div className="crypto-login-container">
<h2>Check Your Email</h2> <h2>{getHeadline()}</h2>
<div className="email-link-pending"> <div className="email-link-pending">
<div className="email-icon">📧</div> <div className="email-icon">📧</div>
<p>We sent a verification link to:</p> <p>We sent a verification link to:</p>
<p className="email-address"><strong>{email}</strong></p> <p className="email-address"><strong>{email}</strong></p>
{pendingCryptId && ( {pendingCryptId && (
<p className="cryptid-info"> <p className="cryptid-info">
This will link this device to CryptID: <strong>{pendingCryptId}</strong> {isNewAccountFlow
? <>Your CryptID will be: <strong>{pendingCryptId}</strong></>
: <>This will link this device to CryptID: <strong>{pendingCryptId}</strong></>
}
</p> </p>
)} )}
<p className="email-instructions"> <p className="email-instructions">
Click the link in the email to complete sign in on this device. {getDescription()}
The link expires in 1 hour. {' '}The link expires in 1 hour.
</p> </p>
<button <button
onClick={() => { onClick={() => {
@ -274,6 +324,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setAuthMode('login'); setAuthMode('login');
setEmail(''); setEmail('');
setPendingCryptId(null); setPendingCryptId(null);
setIsNewAccountFlow(false);
setNeedsEmailVerificationFlow(false);
}} }}
className="toggle-button" className="toggle-button"
> >
@ -293,11 +345,12 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
if (authMode === 'email-link') { if (authMode === 'email-link') {
return ( return (
<div className="crypto-login-container"> <div className="crypto-login-container">
<h2>Sign In with Email</h2> <h2>{needsUsername ? 'Create CryptID Account' : 'Sign In with Email'}</h2>
<div className="crypto-info"> <div className="crypto-info">
<p> <p>
Enter the email address linked to your CryptID account. {needsUsername
We'll send a verification link to complete sign in on this device. ? 'No account found for this email. Choose a CryptID username to create your account.'
: 'Enter your email address to sign in or create an account. One click will verify your email and link this device.'}
</p> </p>
</div> </div>
@ -308,22 +361,48 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
type="email" type="email"
id="email" id="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
setEmail(e.target.value);
// Reset needsUsername when email changes
if (needsUsername) {
setNeedsUsername(false);
setNewAccountUsername('');
}
}}
placeholder="Enter your email..." placeholder="Enter your email..."
required required
disabled={isLoading} disabled={isLoading || needsUsername}
autoComplete="email" autoComplete="email"
/> />
</div> </div>
{needsUsername && (
<div className="form-group">
<label htmlFor="newAccountUsername">CryptID Username</label>
<input
type="text"
id="newAccountUsername"
value={newAccountUsername}
onChange={(e) => setNewAccountUsername(e.target.value)}
placeholder="Choose a username..."
required
disabled={isLoading}
autoComplete="username"
minLength={3}
maxLength={20}
/>
<p className="field-hint">This will be your unique CryptID identity</p>
</div>
)}
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<button <button
type="submit" type="submit"
disabled={isLoading || !email.trim()} disabled={isLoading || !email.trim() || (needsUsername && !newAccountUsername.trim())}
className="crypto-auth-button" className="crypto-auth-button"
> >
{isLoading ? 'Sending...' : 'Send Verification Link'} {isLoading ? 'Sending...' : needsUsername ? 'Create Account & Send Link' : 'Send Verification Link'}
</button> </button>
</form> </form>
@ -333,6 +412,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setAuthMode('login'); setAuthMode('login');
setError(null); setError(null);
setEmail(''); setEmail('');
setNeedsUsername(false);
setNewAccountUsername('');
}} }}
disabled={isLoading} disabled={isLoading}
className="toggle-button" className="toggle-button"

View File

@ -30,6 +30,13 @@ export interface DeviceLinkResult {
alreadyLinked?: boolean; alreadyLinked?: boolean;
emailSent?: boolean; emailSent?: boolean;
error?: string; error?: string;
// Combined flow fields
isNewAccount?: boolean;
needsEmailVerification?: boolean;
needsUsername?: boolean;
// Completion fields
emailVerified?: boolean;
emailWasJustVerified?: boolean;
} }
export interface LookupResult { export interface LookupResult {
@ -130,16 +137,21 @@ export async function checkEmailStatus(cryptidUsername: string): Promise<LookupR
* Request to link a new device using email * Request to link a new device using email
* Called from Device B (new device) * Called from Device B (new device)
* *
* Flow: * Combined Flow:
* 1. Generate new keypair on Device B * 1. Generate new keypair on Device B
* 2. Send email + publicKey to server * 2. Send email + publicKey (+ optional username for new accounts) to server
* 3. Server sends verification email * 3. Server sends verification email
* 4. User clicks link in email (on Device B) * 4. User clicks link in email (on Device B)
* 5. Device B's key is linked to the account * 5. Device B's key is linked AND email is verified in one step
*
* If email is not found and no username provided, server returns needsUsername: true
*/ */
export async function requestDeviceLink( export async function requestDeviceLink(
email: string, email: string,
deviceName?: string options?: {
deviceName?: string;
cryptidUsername?: string; // Required for new accounts
}
): Promise<DeviceLinkResult & { publicKey?: string }> { ): Promise<DeviceLinkResult & { publicKey?: string }> {
try { try {
// Generate a new keypair for this device // Generate a new keypair for this device
@ -168,7 +180,8 @@ export async function requestDeviceLink(
body: JSON.stringify({ body: JSON.stringify({
email, email,
publicKey, publicKey,
deviceName: deviceName || getDeviceName() deviceName: options?.deviceName || getDeviceName(),
cryptidUsername: options?.cryptidUsername
}), }),
}); });
@ -177,7 +190,8 @@ export async function requestDeviceLink(
if (!response.ok) { if (!response.ok) {
return { return {
success: false, success: false,
error: data.error || 'Failed to request device link' error: data.error || 'Failed to request device link',
needsUsername: data.needsUsername
}; };
} }
@ -189,6 +203,8 @@ export async function requestDeviceLink(
email, email,
publicKey, publicKey,
cryptidUsername: data.cryptidUsername, cryptidUsername: data.cryptidUsername,
isNewAccount: data.isNewAccount,
needsEmailVerification: data.needsEmailVerification,
timestamp: Date.now() timestamp: Date.now()
})); }));
} }
@ -408,7 +424,12 @@ export function hasPendingDeviceLink(): boolean {
/** /**
* Get pending device link info * Get pending device link info
*/ */
export function getPendingDeviceLink(): { email: string; cryptidUsername: string } | null { export function getPendingDeviceLink(): {
email: string;
cryptidUsername: string;
isNewAccount?: boolean;
needsEmailVerification?: boolean;
} | null {
const pending = sessionStorage.getItem('pendingDeviceLink'); const pending = sessionStorage.getItem('pendingDeviceLink');
if (!pending) return null; if (!pending) return null;
@ -417,7 +438,9 @@ export function getPendingDeviceLink(): { email: string; cryptidUsername: string
if (Date.now() - data.timestamp < 60 * 60 * 1000) { if (Date.now() - data.timestamp < 60 * 60 * 1000) {
return { return {
email: data.email, email: data.email,
cryptidUsername: data.cryptidUsername cryptidUsername: data.cryptidUsername,
isNewAccount: data.isNewAccount,
needsEmailVerification: data.needsEmailVerification
}; };
} }
return null; return null;

View File

@ -264,7 +264,10 @@ export async function handleVerifyEmail(
/** /**
* Request to link a new device (Device B enters email) * Request to link a new device (Device B enters email)
* POST /auth/request-device-link * POST /auth/request-device-link
* Body: { email, publicKey, deviceName } * Body: { email, publicKey, deviceName, cryptidUsername? }
*
* Combined flow: If email not verified yet, this will verify email AND link device
* in one step when user clicks the link. Saves user time vs separate flows.
*/ */
export async function handleRequestDeviceLink( export async function handleRequestDeviceLink(
request: Request, request: Request,
@ -275,9 +278,10 @@ export async function handleRequestDeviceLink(
email: string; email: string;
publicKey: string; publicKey: string;
deviceName?: string; deviceName?: string;
cryptidUsername?: string; // Optional: for new accounts being set up
}; };
const { email, publicKey, deviceName } = body; const { email, publicKey, deviceName, cryptidUsername: providedUsername } = body;
if (!email || !publicKey) { if (!email || !publicKey) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), { return new Response(JSON.stringify({ error: 'Missing required fields' }), {
@ -286,6 +290,15 @@ export async function handleRequestDeviceLink(
}); });
} }
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(JSON.stringify({ error: 'Invalid email format' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const db = env.CRYPTID_DB; const db = env.CRYPTID_DB;
if (!db) { if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), { return new Response(JSON.stringify({ error: 'Database not configured' }), {
@ -294,34 +307,56 @@ export async function handleRequestDeviceLink(
}); });
} }
// Check if email exists and is verified // Check if this public key is already registered
const user = await db.prepare( const existingKey = await db.prepare(
'SELECT * FROM users WHERE email = ? AND email_verified = 1' 'SELECT dk.*, u.cryptid_username FROM device_keys dk JOIN users u ON dk.user_id = u.id WHERE dk.public_key = ?'
).bind(publicKey).first<DeviceKey & { cryptid_username: string }>();
if (existingKey) {
return new Response(JSON.stringify({
success: true,
message: 'Device already linked',
cryptidUsername: existingKey.cryptid_username,
alreadyLinked: true
}), {
headers: { 'Content-Type': 'application/json' },
});
}
// Check if email exists in system (verified or not)
let user = await db.prepare(
'SELECT * FROM users WHERE email = ?'
).bind(email).first<User>(); ).bind(email).first<User>();
if (!user) { const isNewAccount = !user;
const needsEmailVerification = !user || !user.email_verified;
// If no user exists and no username provided, we need a username
if (!user && !providedUsername) {
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'No verified CryptID account found for this email' error: 'No account found for this email. Please provide a CryptID username to create an account.',
needsUsername: true
}), { }), {
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
// Check if this public key is already registered // Create user if doesn't exist
const existingKey = await db.prepare( if (!user && providedUsername) {
'SELECT * FROM device_keys WHERE public_key = ?' const userId = generateUUID();
).bind(publicKey).first<DeviceKey>(); await db.prepare(
'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)'
).bind(userId, email, providedUsername).run();
if (existingKey) { user = {
return new Response(JSON.stringify({ id: userId,
success: true, email,
message: 'Device already linked', cryptid_username: providedUsername,
cryptidUsername: user.cryptid_username, email_verified: 0,
alreadyLinked: true created_at: new Date().toISOString(),
}), { updated_at: new Date().toISOString()
headers: { 'Content-Type': 'application/json' }, };
});
} }
const userAgent = request.headers.get('User-Agent') || null; const userAgent = request.headers.get('User-Agent') || null;
@ -329,37 +364,65 @@ export async function handleRequestDeviceLink(
// Clean up old tokens // Clean up old tokens
await cleanupExpiredTokens(db); await cleanupExpiredTokens(db);
// Create device link token (1 hour expiry for security) // Create combined device link + email verification token
// Uses 'device_link' type but will also verify email when clicked
const token = generateToken(); const token = generateToken();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
await db.prepare( await db.prepare(
'INSERT INTO verification_tokens (id, email, token, token_type, public_key, device_name, user_agent, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO verification_tokens (id, email, token, token_type, public_key, device_name, user_agent, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).bind(generateUUID(), email, token, 'device_link', publicKey, deviceName || 'New Device', userAgent, expiresAt).run(); ).bind(generateUUID(), email, token, 'device_link', publicKey, deviceName || 'New Device', userAgent, expiresAt).run();
// Send device link email // Send combined verification email
const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`; const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`;
const emailSent = await sendEmail(
env, // Different email content based on whether this is new account or existing
email, const emailSubject = isNewAccount
'Link new device to your CryptID', ? 'Complete your CryptID setup'
: needsEmailVerification
? 'Verify email and link device to your CryptID'
: 'Link new device to your CryptID';
const emailContent = isNewAccount
? `
<h2>Complete Your CryptID Setup</h2>
<p>Click the link below to verify your email and set up your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Complete Setup</a></p>
<p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p>
<p style="color: #666; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
` `
: needsEmailVerification
? `
<h2>Verify Email & Link Device</h2>
<p>Click the link below to verify your email and link this device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify & Link Device</a></p>
<p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p>
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong></p>
`
: `
<h2>New Device Link Request</h2> <h2>New Device Link Request</h2>
<p>Someone is trying to link a new device to your CryptID: <strong>${user.cryptid_username}</strong></p> <p>Someone is trying to link a new device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p> <p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p>If this was you, click the button below to approve:</p> <p>If this was you, click the button below to approve:</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p> <p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
<p>Or copy this link: ${linkUrl}</p> <p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p> <p>This link expires in 1 hour.</p>
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong> Someone may be trying to access your account.</p> <p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong> Someone may be trying to access your account.</p>
` `;
);
const emailSent = await sendEmail(env, email, emailSubject, emailContent);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true, success: true,
message: emailSent ? 'Verification email sent to your address' : 'Failed to send email', message: emailSent ? 'Verification email sent to your address' : 'Failed to send email',
emailSent, emailSent,
cryptidUsername: user.cryptid_username cryptidUsername: user!.cryptid_username,
isNewAccount,
needsEmailVerification
}), { }), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
@ -376,6 +439,10 @@ export async function handleRequestDeviceLink(
/** /**
* Complete device link (clicked from email on Device B) * Complete device link (clicked from email on Device B)
* GET /auth/link-device/:token * GET /auth/link-device/:token
*
* Combined flow: This also verifies email if not already verified.
* User clicks link from Device B, which links the device AND verifies the email
* in one action - saving the user from needing to do two separate verifications.
*/ */
export async function handleLinkDevice( export async function handleLinkDevice(
token: string, token: string,
@ -414,6 +481,9 @@ export async function handleLinkDevice(
}); });
} }
// Track if we're also verifying email
const wasEmailUnverified = user.email_verified === 0;
// Add the new device key // Add the new device key
await db.prepare( await db.prepare(
'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)'
@ -425,6 +495,14 @@ export async function handleLinkDevice(
tokenRecord.user_agent tokenRecord.user_agent
).run(); ).run();
// COMBINED FLOW: Also verify email if not already verified
// This saves the user from needing to click a separate email verification link
if (wasEmailUnverified) {
await db.prepare(
"UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?"
).bind(user.id).run();
}
// Mark token as used // Mark token as used
await db.prepare( await db.prepare(
'UPDATE verification_tokens SET used = 1 WHERE id = ?' 'UPDATE verification_tokens SET used = 1 WHERE id = ?'
@ -432,9 +510,13 @@ export async function handleLinkDevice(
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true, success: true,
message: 'Device linked successfully', message: wasEmailUnverified
? 'Device linked and email verified successfully'
: 'Device linked successfully',
cryptidUsername: user.cryptid_username, cryptidUsername: user.cryptid_username,
email: user.email email: user.email,
emailVerified: true, // Now definitely verified
emailWasJustVerified: wasEmailUnverified
}), { }), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });