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 [emailLinkSent, setEmailLinkSent] = useState(false);
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<{
supported: boolean;
secure: boolean;
@ -130,15 +134,24 @@ const CryptID: React.FC<CryptIDProps> = ({ 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,13 +168,30 @@ const CryptID: React.FC<CryptIDProps> = ({ 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 {
// 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);
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
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 (
<div className="crypto-login-container">
<h2>Check Your Email</h2>
<h2>{getHeadline()}</h2>
<div className="email-link-pending">
<div className="email-icon">📧</div>
<p>We sent a verification link to:</p>
<p className="email-address"><strong>{email}</strong></p>
{pendingCryptId && (
<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 className="email-instructions">
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.
</p>
<button
onClick={() => {
@ -274,6 +324,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setAuthMode('login');
setEmail('');
setPendingCryptId(null);
setIsNewAccountFlow(false);
setNeedsEmailVerificationFlow(false);
}}
className="toggle-button"
>
@ -293,11 +345,12 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
if (authMode === 'email-link') {
return (
<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">
<p>
Enter the email address linked to your CryptID account.
We'll send a verification link to complete sign in on this device.
{needsUsername
? '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>
</div>
@ -308,22 +361,48 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
type="email"
id="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..."
required
disabled={isLoading}
disabled={isLoading || needsUsername}
autoComplete="email"
/>
</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>}
<button
type="submit"
disabled={isLoading || !email.trim()}
disabled={isLoading || !email.trim() || (needsUsername && !newAccountUsername.trim())}
className="crypto-auth-button"
>
{isLoading ? 'Sending...' : 'Send Verification Link'}
{isLoading ? 'Sending...' : needsUsername ? 'Create Account & Send Link' : 'Send Verification Link'}
</button>
</form>
@ -333,6 +412,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setAuthMode('login');
setError(null);
setEmail('');
setNeedsUsername(false);
setNewAccountUsername('');
}}
disabled={isLoading}
className="toggle-button"

View File

@ -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<LookupR
* Request to link a new device using email
* Called from Device B (new device)
*
* Flow:
* Combined Flow:
* 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
* 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(
email: string,
deviceName?: string
options?: {
deviceName?: string;
cryptidUsername?: string; // Required for new accounts
}
): Promise<DeviceLinkResult & { publicKey?: string }> {
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;

View File

@ -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<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>();
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<DeviceKey>();
// 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
? `
<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>
<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>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>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> Someone may be trying to access your account.</p>
`
);
`;
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' },
});