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:
parent
be6f58630f
commit
d672275adf
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue