diff --git a/src/App.tsx b/src/App.tsx index 7ac10ae..1ea0be7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,8 @@ import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/starred-boards.css"; // Import starred boards styles import "@/css/user-profile.css"; // Import user profile styles import { Dashboard } from "./routes/Dashboard"; +import { VerifyEmail } from "./routes/VerifyEmail"; +import { LinkDevice } from "./routes/LinkDevice"; import { useState, useEffect } from 'react'; // Import React Context providers @@ -122,6 +124,10 @@ const AppWithProviders = () => { {/* Auth routes */} } /> + {/* Email verification routes (no trailing slash for email links) */} + } /> + } /> + {/* Optional auth routes */} diff --git a/src/components/auth/CryptID.tsx b/src/components/auth/CryptID.tsx index f2f8e76..2cc38c7 100644 --- a/src/components/auth/CryptID.tsx +++ b/src/components/auth/CryptID.tsx @@ -3,42 +3,55 @@ import { CryptoAuthService } from '../../lib/auth/cryptoAuthService'; import { useAuth } from '../../context/AuthContext'; import { useNotifications } from '../../context/NotificationContext'; import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser'; +import { + requestDeviceLink, + hasPendingDeviceLink, + getPendingDeviceLink +} from '../../lib/auth/cryptidEmailService'; interface CryptIDProps { onSuccess?: () => void; onCancel?: () => void; } +type AuthMode = 'login' | 'register' | 'email-link'; + /** * CryptID - WebCryptoAPI-based authentication component */ const CryptID: React.FC = ({ onSuccess, onCancel }) => { const [username, setUsername] = useState(''); - const [isRegistering, setIsRegistering] = useState(false); + const [email, setEmail] = useState(''); + const [authMode, setAuthMode] = useState('login'); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [existingUsers, setExistingUsers] = useState([]); const [suggestedUsername, setSuggestedUsername] = useState(''); + const [emailLinkSent, setEmailLinkSent] = useState(false); + const [pendingCryptId, setPendingCryptId] = useState(null); const [browserSupport, setBrowserSupport] = useState<{ supported: boolean; secure: boolean; webcrypto: boolean; }>({ supported: false, secure: false, webcrypto: false }); - + const { setSession } = useAuth(); const { addNotification } = useNotifications(); + // Legacy compatibility + const isRegistering = authMode === 'register'; + // Check browser support and existing users on mount useEffect(() => { const checkSupport = () => { const supported = checkBrowserSupport(); const secure = isSecureContext(); - const webcrypto = typeof window !== 'undefined' && - typeof window.crypto !== 'undefined' && + const webcrypto = typeof window !== 'undefined' && + typeof window.crypto !== 'undefined' && typeof window.crypto.subtle !== 'undefined'; - + setBrowserSupport({ supported, secure, webcrypto }); - + if (!supported) { setError('Your browser does not support the required features for cryptographic authentication.'); addNotification('Browser not supported for cryptographic authentication', 'warning'); @@ -50,22 +63,22 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { addNotification('WebCryptoAPI not available', 'warning'); } }; - + const checkExistingUsers = () => { try { // Get registered users from localStorage const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]'); - + // Filter users to only include those with valid authentication keys const validUsers = users.filter((user: string) => { // Check if public key exists const publicKey = localStorage.getItem(`${user}_publicKey`); if (!publicKey) return false; - + // Check if authentication data exists const authData = localStorage.getItem(`${user}_authData`); if (!authData) return false; - + // Verify the auth data is valid JSON and has required fields try { const parsed = JSON.parse(authData); @@ -75,18 +88,18 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { return false; } }); - + setExistingUsers(validUsers); - + // If there are valid users, suggest the first one for login if (validUsers.length > 0) { setSuggestedUsername(validUsers[0]); setUsername(validUsers[0]); // Pre-fill the username field - setIsRegistering(false); // Default to login mode if users exist + setAuthMode('login'); // Default to login mode if users exist } else { - setIsRegistering(true); // Default to registration mode if no users exist + setAuthMode('register'); // Default to registration mode if no users exist } - + // Log for debugging if (users.length !== validUsers.length) { console.log(`Found ${users.length} registered users, but only ${validUsers.length} have valid keys`); @@ -96,11 +109,67 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { setExistingUsers([]); } }; - + + const checkPendingLink = () => { + // Check if there's a pending device link from email flow + if (hasPendingDeviceLink()) { + const pending = getPendingDeviceLink(); + if (pending) { + setEmailLinkSent(true); + setPendingCryptId(pending.cryptidUsername); + setEmail(pending.email); + setAuthMode('email-link'); + } + } + }; + checkSupport(); checkExistingUsers(); + checkPendingLink(); }, [addNotification]); + /** + * Handle email link request (Device B - new device) + */ + const handleEmailLinkRequest = async () => { + if (!email) return; + + setError(null); + setIsLoading(true); + + try { + const result = await requestDeviceLink(email); + + if (result.success) { + if (result.alreadyLinked) { + // Device already linked - log them in directly + setSession({ + username: result.cryptidUsername!, + authed: true, + loading: false, + backupCreated: null + }); + addNotification('Device already linked! Signed in.', 'success'); + if (onSuccess) onSuccess(); + } else if (result.emailSent) { + // Email sent - show waiting message + setEmailLinkSent(true); + setPendingCryptId(result.cryptidUsername || null); + addNotification('Verification email sent! Check your inbox.', 'success'); + } else { + setError('Failed to send verification email'); + } + } else { + setError(result.error || 'Failed to request device link'); + } + } catch (err) { + console.error('Email link request error:', err); + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + /** * Handle form submission for both login and registration */ @@ -116,6 +185,11 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { return; } + if (authMode === 'email-link') { + await handleEmailLinkRequest(); + return; + } + if (isRegistering) { // Registration flow using CryptoAuthService const result = await CryptoAuthService.register(username); @@ -176,10 +250,110 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { ); } + // Email link sent - waiting for user to click verification + if (authMode === 'email-link' && emailLinkSent) { + return ( +
+

Check Your Email

+
+
📧
+

We sent a verification link to:

+

{email}

+ {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. +

+ +
+ {onCancel && ( + + )} +
+ ); + } + + // Email link mode - enter email to link new device + if (authMode === 'email-link') { + return ( +
+

Sign In with Email

+
+

+ Enter the email address linked to your CryptID account. + We'll send a verification link to complete sign in on this device. +

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="Enter your email..." + required + disabled={isLoading} + autoComplete="email" + /> +
+ + {error &&
{error}
} + + +
+ +
+ +
+ + {onCancel && ( + + )} +
+ ); + } + return (

{isRegistering ? 'Create CryptID Account' : 'CryptID Sign In'}

- + {/* Show existing users if available */} {existingUsers.length > 0 && !isRegistering && (
@@ -203,7 +377,7 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => {
)} - +

{isRegistering @@ -239,9 +413,9 @@ const CryptID: React.FC = ({ onSuccess, onCancel }) => { {error &&

{error}
} -
+ {/* Email sign-in option - for new devices */} + {!isRegistering && ( +
+
+ or +
+ +

+ Use this if you've linked an email to your CryptID on another device. +

+
+ )} + {onCancel && ( + + ) : ( +
+ setEmail(e.target.value)} + placeholder="Enter your email..." + className="email-input" + disabled={emailLoading} + /> +
+ + +
+
+ )} + + )} + + {emailError &&

{emailError}

} + {emailSuccess &&

{emailSuccess}

} + + + {/* Linked Devices */} + {emailStatus.linked && emailStatus.verified && ( +
+ + + {showDevices && ( +
+ {devicesLoading ? ( +

Loading devices...

+ ) : devices.length === 0 ? ( +

No devices linked yet.

+ ) : ( +
    + {devices.map((device) => ( +
  • +
    + + {device.deviceName} + {device.isCurrentDevice && (this device)} + + + Added: {new Date(device.createdAt).toLocaleDateString()} + {device.lastUsed && ` | Last used: ${new Date(device.lastUsed).toLocaleDateString()}`} + +
    + {!device.isCurrentDevice && ( + + )} +
  • + ))} +
+ )} +
+ )} +
+ )} + +
- + {!session.backupCreated && (

Remember to back up your encryption keys to prevent data loss!

diff --git a/src/css/crypto-auth.css b/src/css/crypto-auth.css index 7fa017b..485f825 100644 --- a/src/css/crypto-auth.css +++ b/src/css/crypto-auth.css @@ -642,4 +642,546 @@ html.dark .debug-input { html.dark .debug-results h3 { color: #e2e8f0; - } \ No newline at end of file + } + +/* ============================================= + Email Sign-in & Device Linking Styles + ============================================= */ + +/* Email sign-in section divider */ +.email-signin-section { + margin-top: 1.5rem; + padding-top: 1rem; +} + +.email-signin-section .divider { + display: flex; + align-items: center; + margin-bottom: 1rem; + color: #6c757d; + font-size: 0.85rem; +} + +.email-signin-section .divider::before, +.email-signin-section .divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #e9ecef; +} + +.email-signin-section .divider span { + padding: 0 0.75rem; +} + +.email-signin-button { + width: 100%; + padding: 0.75rem; + background: #6c757d; + color: white; + border: none; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.email-signin-button:hover:not(:disabled) { + background: #5a6268; +} + +.email-signin-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.email-signin-hint { + margin-top: 0.5rem; + font-size: 0.8rem; + color: #6c757d; + text-align: center; +} + +/* Email link pending state */ +.email-link-pending { + text-align: center; + padding: 1rem; +} + +.email-link-pending .email-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.email-link-pending .email-address { + font-size: 1.1rem; + margin: 0.5rem 0 1rem; +} + +.email-link-pending .cryptid-info { + background: #e3f2fd; + padding: 0.75rem; + border-radius: 6px; + margin: 1rem 0; + font-size: 0.9rem; +} + +.email-link-pending .email-instructions { + color: #6c757d; + font-size: 0.9rem; + margin-bottom: 1.5rem; +} + +/* Profile Email Settings */ +.email-settings { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e9ecef; +} + +.email-status-section { + margin-bottom: 1rem; +} + +.email-linked .email-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.email-linked .email-label { + color: #6c757d; + font-size: 0.9rem; +} + +.email-linked .email-value { + font-weight: 500; + color: #495057; +} + +.email-verified-badge { + background: #d4edda; + color: #155724; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.email-pending-badge { + background: #fff3cd; + color: #856404; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.email-hint { + font-size: 0.85rem; + color: #6c757d; + margin: 0.5rem 0; +} + +.email-description { + font-size: 0.9rem; + color: #6c757d; + margin-bottom: 1rem; +} + +.link-email-button { + padding: 0.6rem 1rem; + background: #007bff; + color: white; + border: none; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.link-email-button:hover { + background: #0056b3; +} + +/* Email form */ +.email-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.email-input { + padding: 0.6rem; + border: 2px solid #e9ecef; + border-radius: 6px; + font-size: 0.95rem; +} + +.email-input:focus { + outline: none; + border-color: #007bff; +} + +.email-form-actions { + display: flex; + gap: 0.5rem; +} + +.email-error { + color: #dc3545; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.email-success { + color: #28a745; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +/* Devices Section */ +.devices-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e9ecef; +} + +.toggle-devices-button { + background: none; + border: none; + color: #007bff; + font-size: 0.9rem; + cursor: pointer; + text-decoration: underline; + padding: 0; +} + +.toggle-devices-button:hover { + color: #0056b3; +} + +.devices-list { + margin-top: 1rem; +} + +.devices-loading, +.no-devices { + color: #6c757d; + font-size: 0.9rem; + font-style: italic; +} + +.device-list { + list-style: none; + padding: 0; + margin: 0; +} + +.device-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: #f8f9fa; + border-radius: 6px; + margin-bottom: 0.5rem; + border: 1px solid #e9ecef; +} + +.device-item.current-device { + border-color: #007bff; + background: #e7f3ff; +} + +.device-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.device-name { + font-weight: 500; + color: #495057; + font-size: 0.95rem; +} + +.device-name .current-badge { + color: #007bff; + font-weight: normal; + font-size: 0.85rem; +} + +.device-meta { + font-size: 0.8rem; + color: #6c757d; +} + +.revoke-device-button { + padding: 0.4rem 0.75rem; + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.revoke-device-button:hover { + background: #c82333; +} + +/* Verification Pages */ +.verify-email-page, +.link-device-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #f8f9fa; + padding: 1rem; +} + +.verify-email-container, +.link-device-container { + max-width: 400px; + width: 100%; + padding: 2rem; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 4px solid #e9ecef; + border-top-color: #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1.5rem; +} + +.success-icon { + width: 64px; + height: 64px; + border-radius: 50%; + background: #28a745; + color: white; + font-size: 2rem; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; +} + +.error-icon { + width: 64px; + height: 64px; + border-radius: 50%; + background: #dc3545; + color: white; + font-size: 2rem; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; +} + +.verify-email-container h2, +.link-device-container h2 { + margin: 0 0 1rem; + color: #1a1a1a; + font-size: 1.5rem; +} + +.verified-email, +.cryptid-username { + background: #e3f2fd; + padding: 0.75rem; + border-radius: 6px; + margin: 1rem 0; + font-size: 0.95rem; +} + +.redirect-notice { + color: #6c757d; + font-size: 0.9rem; + margin: 1rem 0; +} + +.error-hint { + color: #6c757d; + font-size: 0.85rem; + margin: 1rem 0; + padding: 0.75rem; + background: #f8f9fa; + border-radius: 6px; +} + +.continue-button, +.retry-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.continue-button { + background: #007bff; + color: white; +} + +.continue-button:hover { + background: #0056b3; +} + +.retry-button { + background: #6c757d; + color: white; +} + +.retry-button:hover { + background: #5a6268; +} + +/* Dark mode for email/device styles */ +html.dark .email-signin-section .divider { + color: #a0aec0; +} + +html.dark .email-signin-section .divider::before, +html.dark .email-signin-section .divider::after { + border-bottom-color: #4a5568; +} + +html.dark .email-signin-button { + background: #4a5568; +} + +html.dark .email-signin-button:hover:not(:disabled) { + background: #718096; +} + +html.dark .email-signin-hint { + color: #a0aec0; +} + +html.dark .email-link-pending .cryptid-info { + background: #2c5282; + color: #e2e8f0; +} + +html.dark .email-settings { + border-top-color: #4a5568; +} + +html.dark .email-linked .email-label { + color: #a0aec0; +} + +html.dark .email-linked .email-value { + color: #e2e8f0; +} + +html.dark .email-verified-badge { + background: #276749; + color: #9ae6b4; +} + +html.dark .email-pending-badge { + background: #744210; + color: #faf089; +} + +html.dark .email-hint, +html.dark .email-description { + color: #a0aec0; +} + +html.dark .email-input { + background: #2d3748; + border-color: #4a5568; + color: #f7fafc; +} + +html.dark .email-input:focus { + border-color: #63b3ed; +} + +html.dark .devices-section { + border-top-color: #4a5568; +} + +html.dark .toggle-devices-button { + color: #63b3ed; +} + +html.dark .toggle-devices-button:hover { + color: #90cdf4; +} + +html.dark .devices-loading, +html.dark .no-devices { + color: #a0aec0; +} + +html.dark .device-item { + background: #4a5568; + border-color: #718096; +} + +html.dark .device-item.current-device { + border-color: #63b3ed; + background: #2c5282; +} + +html.dark .device-name { + color: #e2e8f0; +} + +html.dark .device-name .current-badge { + color: #90cdf4; +} + +html.dark .device-meta { + color: #a0aec0; +} + +html.dark .verify-email-page, +html.dark .link-device-page { + background: #1a202c; +} + +html.dark .verify-email-container, +html.dark .link-device-container { + background: #2d3748; + border: 1px solid #4a5568; +} + +html.dark .verify-email-container h2, +html.dark .link-device-container h2 { + color: #f7fafc; +} + +html.dark .verified-email, +html.dark .cryptid-username { + background: #2c5282; + color: #e2e8f0; +} + +html.dark .redirect-notice { + color: #a0aec0; +} + +html.dark .error-hint { + background: #4a5568; + color: #a0aec0; +} \ No newline at end of file diff --git a/worker/types.ts b/worker/types.ts index 2abb101..bfedfb1 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -6,21 +6,20 @@ export interface Environment { TLDRAW_BUCKET: R2Bucket BOARD_BACKUPS_BUCKET: R2Bucket AUTOMERGE_DURABLE_OBJECT: DurableObjectNamespace + CRYPTID_DB: D1Database DAILY_API_KEY: string; DAILY_DOMAIN: string; - // CryptID auth bindings - CRYPTID_DB?: D1Database; - SENDGRID_API_KEY?: string; - CRYPTID_EMAIL_FROM?: string; - APP_URL?: string; + SENDGRID_API_KEY: string; + CRYPTID_EMAIL_FROM: string; + APP_URL: string; } -// CryptID types for auth +// CryptID Database Types export interface User { id: string; cryptid_username: string; email: string | null; - email_verified: boolean; + email_verified: number; created_at: string; updated_at: string; } @@ -37,15 +36,13 @@ export interface DeviceKey { export interface VerificationToken { id: string; - user_id: string; + email: string; token: string; - type: 'email_verification' | 'device_link'; + token_type: 'email_verify' | 'device_link'; + public_key: string | null; + device_name: string | null; + user_agent: string | null; expires_at: string; + used: number; created_at: string; - metadata: string | null; - // Metadata fields that get parsed from JSON - email?: string; - public_key?: string; - device_name?: string; - user_agent?: string; -} \ No newline at end of file +} diff --git a/worker/worker.ts b/worker/worker.ts index 0a5ed77..8b5fa5a 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1,6 +1,15 @@ import { AutoRouter, cors, error, IRequest } from "itty-router" import { handleAssetDownload, handleAssetUpload } from "./assetUploads" import { Environment } from "./types" +import { + handleLinkEmail, + handleVerifyEmail, + handleRequestDeviceLink, + handleLinkDevice, + handleLookup, + handleGetDevices, + handleRevokeDevice +} from "./cryptidAuth" // make sure our sync durable objects are made available to cloudflare export { AutomergeDurableObject } from "./AutomergeDurableObject" @@ -800,6 +809,25 @@ const router = AutoRouter({ } }) + // CryptID Authentication Routes + .post("/auth/link-email", (request, env) => handleLinkEmail(request, env)) + + .get("/auth/verify-email/:token", (request, env) => + handleVerifyEmail(request.params.token, env)) + + .post("/auth/request-device-link", (request, env) => + handleRequestDeviceLink(request, env)) + + .get("/auth/link-device/:token", (request, env) => + handleLinkDevice(request.params.token, env)) + + .post("/auth/lookup", (request, env) => handleLookup(request, env)) + + .post("/auth/devices", (request, env) => handleGetDevices(request, env)) + + .delete("/auth/devices/:deviceId", (request, env) => + handleRevokeDevice(request.params.deviceId, request, env)) + async function backupAllBoards(env: Environment) { try { // List all room files from TLDRAW_BUCKET