/** * CryptID Email Service * Handles communication with the backend for email linking and device verification */ import * as crypto from './crypto'; // Get the worker API URL based on environment function getApiUrl(): string { const hostname = window.location.hostname; // In development (localhost, local IPs, Tailscale IPs), use the local worker on same host const isLocalDev = hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('100.'); // Tailscale if (isLocalDev) { return `http://${hostname}:5172`; } // In production, use the deployed worker return 'https://jeffemmett-canvas.jeffemmett.workers.dev'; } export interface LinkEmailResult { success: boolean; message?: string; emailVerified?: boolean; emailSent?: boolean; error?: string; } export interface DeviceLinkResult { success: boolean; message?: string; cryptidUsername?: string; alreadyLinked?: boolean; emailSent?: boolean; error?: string; } export interface LookupResult { found: boolean; cryptidUsername?: string; email?: string; emailVerified?: boolean; deviceName?: string; } export interface Device { id: string; deviceName: string; userAgent: string | null; createdAt: string; lastUsed: string | null; isCurrentDevice: boolean; } /** * Link an email to the current CryptID account * Called from Device A (existing device with account) */ export async function linkEmailToAccount( email: string, cryptidUsername: string, deviceName?: string ): Promise { try { // Get the public key for this user const publicKey = crypto.getPublicKey(cryptidUsername); if (!publicKey) { return { success: false, error: 'No public key found for this account' }; } const response = await fetch(`${getApiUrl()}/auth/link-email`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, cryptidUsername, publicKey, deviceName: deviceName || getDeviceName() }), }); const data = await response.json() as LinkEmailResult & { error?: string }; if (!response.ok) { return { success: false, error: data.error || 'Failed to link email' }; } return data; } catch (error) { console.error('Link email error:', error); return { success: false, error: String(error) }; } } /** * Check the status of email verification */ export async function checkEmailStatus(cryptidUsername: string): Promise { try { const publicKey = crypto.getPublicKey(cryptidUsername); if (!publicKey) { return { found: false }; } const response = await fetch(`${getApiUrl()}/auth/lookup`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ publicKey }), }); const data = await response.json() as LookupResult; return data; } catch (error) { console.error('Check email status error:', error); return { found: false }; } } /** * Request to link a new device using email * Called from Device B (new device) * * Flow: * 1. Generate new keypair on Device B * 2. Send email + publicKey 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 */ export async function requestDeviceLink( email: string, deviceName?: string ): Promise { try { // Generate a new keypair for this device const keyPair = await crypto.generateKeyPair(); if (!keyPair) { return { success: false, error: 'Failed to generate cryptographic keys' }; } // Export the public key const publicKey = await crypto.exportPublicKey(keyPair.publicKey); if (!publicKey) { return { success: false, error: 'Failed to export public key' }; } const response = await fetch(`${getApiUrl()}/auth/request-device-link`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, publicKey, deviceName: deviceName || getDeviceName() }), }); const data = await response.json() as DeviceLinkResult & { error?: string }; if (!response.ok) { return { success: false, error: data.error || 'Failed to request device link' }; } // If successful, temporarily store the keypair for later // The user will need to click the email link to complete the process if (data.success && !data.alreadyLinked) { // Store pending link data sessionStorage.setItem('pendingDeviceLink', JSON.stringify({ email, publicKey, cryptidUsername: data.cryptidUsername, timestamp: Date.now() })); } return { ...data, publicKey }; } catch (error) { console.error('Request device link error:', error); return { success: false, error: String(error) }; } } /** * Complete the device link after email verification * Called when user clicks the verification link and lands back on the app */ export async function completeDeviceLink(token: string): Promise { try { const response = await fetch(`${getApiUrl()}/auth/link-device/${token}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); const data = await response.json() as DeviceLinkResult & { email?: string; error?: string }; if (!response.ok) { return { success: false, error: data.error || 'Failed to complete device link' }; } // Use the typed data const result = data; // If successful, the pending device link data should match const pendingLink = sessionStorage.getItem('pendingDeviceLink'); if (pendingLink && result.success) { const pending = JSON.parse(pendingLink); // Register this device locally with the CryptID username from the server if (result.cryptidUsername) { // Store the public key locally for this username crypto.storePublicKey(result.cryptidUsername, pending.publicKey); crypto.addRegisteredUser(result.cryptidUsername); // Store auth data to match the existing flow localStorage.setItem(`${result.cryptidUsername}_authData`, JSON.stringify({ challenge: `device-linked:${Date.now()}`, signature: 'device-link-verified', timestamp: Date.now(), email: result.email })); } // Clear pending link data sessionStorage.removeItem('pendingDeviceLink'); } return result; } catch (error) { console.error('Complete device link error:', error); return { success: false, error: String(error) }; } } /** * Verify email via token (for initial email verification) */ export async function verifyEmail(token: string): Promise<{ success: boolean; email?: string; error?: string }> { try { const response = await fetch(`${getApiUrl()}/auth/verify-email/${token}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); const data = await response.json() as { success: boolean; email?: string; error?: string }; if (!response.ok) { return { success: false, error: data.error || 'Failed to verify email' }; } return data; } catch (error) { console.error('Verify email error:', error); return { success: false, error: String(error) }; } } /** * Get all devices linked to this account */ export async function getLinkedDevices(cryptidUsername: string): Promise { try { const publicKey = crypto.getPublicKey(cryptidUsername); if (!publicKey) { return []; } const response = await fetch(`${getApiUrl()}/auth/devices`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ publicKey }), }); const data = await response.json() as { devices?: Device[] }; return data.devices || []; } catch (error) { console.error('Get linked devices error:', error); return []; } } /** * Revoke a device from the account */ export async function revokeDevice( cryptidUsername: string, deviceId: string ): Promise<{ success: boolean; error?: string }> { try { const publicKey = crypto.getPublicKey(cryptidUsername); if (!publicKey) { return { success: false, error: 'No public key found' }; } const response = await fetch(`${getApiUrl()}/auth/devices/${deviceId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ publicKey }), }); const data = await response.json() as { success: boolean; error?: string }; if (!response.ok) { return { success: false, error: data.error || 'Failed to revoke device' }; } return data; } catch (error) { console.error('Revoke device error:', error); return { success: false, error: String(error) }; } } /** * Get a friendly device name based on user agent */ function getDeviceName(): string { const ua = navigator.userAgent; // Detect OS let os = 'Unknown'; if (ua.includes('Windows')) os = 'Windows'; else if (ua.includes('Mac')) os = 'macOS'; else if (ua.includes('Linux')) os = 'Linux'; else if (ua.includes('Android')) os = 'Android'; else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS'; // Detect browser let browser = 'Browser'; if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome'; else if (ua.includes('Firefox')) browser = 'Firefox'; else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari'; else if (ua.includes('Edg')) browser = 'Edge'; return `${browser} on ${os}`; } /** * Check if there's a pending device link to complete */ export function hasPendingDeviceLink(): boolean { const pending = sessionStorage.getItem('pendingDeviceLink'); if (!pending) return false; try { const data = JSON.parse(pending); // Check if it's less than 1 hour old return Date.now() - data.timestamp < 60 * 60 * 1000; } catch { return false; } } /** * Get pending device link info */ export function getPendingDeviceLink(): { email: string; cryptidUsername: string } | null { const pending = sessionStorage.getItem('pendingDeviceLink'); if (!pending) return null; try { const data = JSON.parse(pending); if (Date.now() - data.timestamp < 60 * 60 * 1000) { return { email: data.email, cryptidUsername: data.cryptidUsername }; } return null; } catch { return null; } }