diff --git a/src/components/auth/CryptID.tsx b/src/components/auth/CryptID.tsx index 2cc38c7..16bbd51 100644 --- a/src/components/auth/CryptID.tsx +++ b/src/components/auth/CryptID.tsx @@ -29,6 +29,10 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { const [suggestedUsername, setSuggestedUsername] = useState(''); const [emailLinkSent, setEmailLinkSent] = useState(false); const [pendingCryptId, setPendingCryptId] = useState(null); + const [needsUsername, setNeedsUsername] = useState(false); + const [newAccountUsername, setNewAccountUsername] = useState(''); + const [isNewAccountFlow, setIsNewAccountFlow] = useState(false); + const [needsEmailVerificationFlow, setNeedsEmailVerificationFlow] = useState(false); const [browserSupport, setBrowserSupport] = useState<{ supported: boolean; secure: boolean; @@ -130,15 +134,24 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { /** * Handle email link request (Device B - new device) + * Combined flow: This will verify email AND link device in one step */ const handleEmailLinkRequest = async () => { 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); setIsLoading(true); try { - const result = await requestDeviceLink(email); + const result = await requestDeviceLink(email, { + cryptidUsername: needsUsername ? newAccountUsername.trim() : undefined + }); if (result.success) { if (result.alreadyLinked) { @@ -155,12 +168,29 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { // Email sent - show waiting message setEmailLinkSent(true); 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 { setError('Failed to send verification email'); } } else { - setError(result.error || 'Failed to request device link'); + // 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 { + setError(result.error || 'Failed to request device link'); + } } } catch (err) { console.error('Email link request error:', err); @@ -252,21 +282,41 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { // Email link sent - waiting for user to click verification 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 (
-

Check Your Email

+

{getHeadline()}

📧

We sent a verification link to:

{email}

{pendingCryptId && (

- This will link this device to CryptID: {pendingCryptId} + {isNewAccountFlow + ? <>Your CryptID will be: {pendingCryptId} + : <>This will link this device to CryptID: {pendingCryptId} + }

)}

- Click the link in the email to complete sign in on this device. - The link expires in 1 hour. + {getDescription()} + {' '}The link expires in 1 hour.

@@ -333,6 +412,8 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { setAuthMode('login'); setError(null); setEmail(''); + setNeedsUsername(false); + setNewAccountUsername(''); }} disabled={isLoading} className="toggle-button" diff --git a/src/lib/auth/cryptidEmailService.ts b/src/lib/auth/cryptidEmailService.ts index 00a1709..8ac6d82 100644 --- a/src/lib/auth/cryptidEmailService.ts +++ b/src/lib/auth/cryptidEmailService.ts @@ -30,6 +30,13 @@ export interface DeviceLinkResult { alreadyLinked?: boolean; emailSent?: boolean; error?: string; + // Combined flow fields + isNewAccount?: boolean; + needsEmailVerification?: boolean; + needsUsername?: boolean; + // Completion fields + emailVerified?: boolean; + emailWasJustVerified?: boolean; } export interface LookupResult { @@ -130,16 +137,21 @@ export async function checkEmailStatus(cryptidUsername: string): Promise { try { // Generate a new keypair for this device @@ -168,7 +180,8 @@ export async function requestDeviceLink( body: JSON.stringify({ email, publicKey, - deviceName: deviceName || getDeviceName() + deviceName: options?.deviceName || getDeviceName(), + cryptidUsername: options?.cryptidUsername }), }); @@ -177,7 +190,8 @@ export async function requestDeviceLink( if (!response.ok) { return { 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, publicKey, cryptidUsername: data.cryptidUsername, + isNewAccount: data.isNewAccount, + needsEmailVerification: data.needsEmailVerification, timestamp: Date.now() })); } @@ -408,7 +424,12 @@ export function hasPendingDeviceLink(): boolean { /** * 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'); if (!pending) return null; @@ -417,7 +438,9 @@ export function getPendingDeviceLink(): { email: string; cryptidUsername: string if (Date.now() - data.timestamp < 60 * 60 * 1000) { return { email: data.email, - cryptidUsername: data.cryptidUsername + cryptidUsername: data.cryptidUsername, + isNewAccount: data.isNewAccount, + needsEmailVerification: data.needsEmailVerification }; } return null; diff --git a/worker/cryptidAuth.ts b/worker/cryptidAuth.ts index e789935..2db86b0 100644 --- a/worker/cryptidAuth.ts +++ b/worker/cryptidAuth.ts @@ -264,7 +264,10 @@ export async function handleVerifyEmail( /** * Request to link a new device (Device B enters email) * 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( request: Request, @@ -275,9 +278,10 @@ export async function handleRequestDeviceLink( email: string; publicKey: 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) { 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; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { @@ -294,34 +307,56 @@ export async function handleRequestDeviceLink( }); } - // Check if email exists and is verified - const user = await db.prepare( - 'SELECT * FROM users WHERE email = ? AND email_verified = 1' + // Check if this public key is already registered + const existingKey = await db.prepare( + '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(); + + 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(); - 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({ - 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, headers: { 'Content-Type': 'application/json' }, }); } - // Check if this public key is already registered - const existingKey = await db.prepare( - 'SELECT * FROM device_keys WHERE public_key = ?' - ).bind(publicKey).first(); + // Create user if doesn't exist + if (!user && providedUsername) { + const userId = generateUUID(); + await db.prepare( + 'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)' + ).bind(userId, email, providedUsername).run(); - if (existingKey) { - return new Response(JSON.stringify({ - success: true, - message: 'Device already linked', - cryptidUsername: user.cryptid_username, - alreadyLinked: true - }), { - headers: { 'Content-Type': 'application/json' }, - }); + user = { + id: userId, + email, + cryptid_username: providedUsername, + email_verified: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; } const userAgent = request.headers.get('User-Agent') || null; @@ -329,37 +364,65 @@ export async function handleRequestDeviceLink( // Clean up old tokens 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 expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour await db.prepare( '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(); - // Send device link email + // Send combined verification email const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`; - const emailSent = await sendEmail( - env, - email, - 'Link new device to your CryptID', + + // Different email content based on whether this is new account or existing + const emailSubject = isNewAccount + ? 'Complete your CryptID setup' + : needsEmailVerification + ? 'Verify email and link device to your CryptID' + : 'Link new device to your CryptID'; + + const emailContent = isNewAccount + ? ` +

Complete Your CryptID Setup

+

Click the link below to verify your email and set up your CryptID: ${user!.cryptid_username}

+

Device: ${deviceName || 'New Device'}

+

Complete Setup

+

Or copy this link: ${linkUrl}

+

This link expires in 1 hour.

+

If you didn't request this, you can safely ignore this email.

` + : needsEmailVerification + ? ` +

Verify Email & Link Device

+

Click the link below to verify your email and link this device to your CryptID: ${user!.cryptid_username}

+

Device: ${deviceName || 'New Device'}

+

Verify & Link Device

+

Or copy this link: ${linkUrl}

+

This link expires in 1 hour.

+

If you didn't request this, do not click the link.

+ ` + : `

New Device Link Request

-

Someone is trying to link a new device to your CryptID: ${user.cryptid_username}

+

Someone is trying to link a new device to your CryptID: ${user!.cryptid_username}

Device: ${deviceName || 'New Device'}

If this was you, click the button below to approve:

Approve Device

Or copy this link: ${linkUrl}

This link expires in 1 hour.

If you didn't request this, do not click the link. Someone may be trying to access your account.

- ` - ); + `; + + const emailSent = await sendEmail(env, email, emailSubject, emailContent); return new Response(JSON.stringify({ success: true, message: emailSent ? 'Verification email sent to your address' : 'Failed to send email', emailSent, - cryptidUsername: user.cryptid_username + cryptidUsername: user!.cryptid_username, + isNewAccount, + needsEmailVerification }), { headers: { 'Content-Type': 'application/json' }, }); @@ -376,6 +439,10 @@ export async function handleRequestDeviceLink( /** * Complete device link (clicked from email on Device B) * 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( 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 await db.prepare( '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 ).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 await db.prepare( 'UPDATE verification_tokens SET used = 1 WHERE id = ?' @@ -432,9 +510,13 @@ export async function handleLinkDevice( return new Response(JSON.stringify({ success: true, - message: 'Device linked successfully', + message: wasEmailUnverified + ? 'Device linked and email verified successfully' + : 'Device linked successfully', cryptidUsername: user.cryptid_username, - email: user.email + email: user.email, + emailVerified: true, // Now definitely verified + emailWasJustVerified: wasEmailUnverified }), { headers: { 'Content-Type': 'application/json' }, });