feat: add CryptID email recovery and device management

- Add /verify-email and /link-device routes to App.tsx
- Add email linking flow to CryptID.tsx (register with email option)
- Add Profile.tsx email management UI (link email, view/revoke devices)
- Add comprehensive crypto-auth.css styling for modals
- Update worker/types.ts with D1 schema types
- Add CryptID auth API routes to worker.ts

Requires D1 database setup before deployment:
- task-018: Create cryptid-auth D1 database
- task-019: Configure SendGrid secrets
- task-017: Test on dev environment

🤖 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:26:06 -08:00
parent f15f46f742
commit c0310791ae
6 changed files with 1041 additions and 44 deletions

View File

@ -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 */}
<Route path="/login/" element={<AuthPage />} />
{/* Email verification routes (no trailing slash for email links) */}
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/link-device" element={<LinkDevice />} />
{/* Optional auth routes */}
<Route path="/" element={
<OptionalAuthRoute>

View File

@ -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<CryptIDProps> = ({ onSuccess, onCancel }) => {
const [username, setUsername] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [email, setEmail] = useState('');
const [authMode, setAuthMode] = useState<AuthMode>('login');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
const [emailLinkSent, setEmailLinkSent] = useState(false);
const [pendingCryptId, setPendingCryptId] = useState<string | null>(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<CryptIDProps> = ({ 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<CryptIDProps> = ({ 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<CryptIDProps> = ({ 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<CryptIDProps> = ({ 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<CryptIDProps> = ({ onSuccess, onCancel }) => {
);
}
// Email link sent - waiting for user to click verification
if (authMode === 'email-link' && emailLinkSent) {
return (
<div className="crypto-login-container">
<h2>Check Your Email</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>
</p>
)}
<p className="email-instructions">
Click the link in the email to complete sign in on this device.
The link expires in 1 hour.
</p>
<button
onClick={() => {
setEmailLinkSent(false);
setAuthMode('login');
setEmail('');
setPendingCryptId(null);
}}
className="toggle-button"
>
Back to Sign In
</button>
</div>
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Cancel
</button>
)}
</div>
);
}
// Email link mode - enter email to link new device
if (authMode === 'email-link') {
return (
<div className="crypto-login-container">
<h2>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.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email..."
required
disabled={isLoading}
autoComplete="email"
/>
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={isLoading || !email.trim()}
className="crypto-auth-button"
>
{isLoading ? 'Sending...' : 'Send Verification Link'}
</button>
</form>
<div className="auth-toggle">
<button
onClick={() => {
setAuthMode('login');
setError(null);
setEmail('');
}}
disabled={isLoading}
className="toggle-button"
>
Back to Sign In
</button>
</div>
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Cancel
</button>
)}
</div>
);
}
return (
<div className="crypto-login-container">
<h2>{isRegistering ? 'Create CryptID Account' : 'CryptID Sign In'}</h2>
{/* Show existing users if available */}
{existingUsers.length > 0 && !isRegistering && (
<div className="existing-users">
@ -203,7 +377,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</div>
</div>
)}
<div className="crypto-info">
<p>
{isRegistering
@ -239,9 +413,9 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={isLoading || !username.trim()}
<button
type="submit"
disabled={isLoading || !username.trim()}
className="crypto-auth-button"
>
{isLoading ? 'Processing...' : isRegistering ? 'Create Account' : 'Sign In'}
@ -249,17 +423,18 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</form>
<div className="auth-toggle">
<button
<button
onClick={() => {
setIsRegistering(!isRegistering);
const newMode = isRegistering ? 'login' : 'register';
setAuthMode(newMode);
setError(null);
// Clear username when switching modes
if (!isRegistering) {
if (newMode === 'register') {
setUsername('');
} else if (existingUsers.length > 0) {
setUsername(existingUsers[0]);
}
}}
}}
disabled={isLoading}
className="toggle-button"
>
@ -267,6 +442,28 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</button>
</div>
{/* Email sign-in option - for new devices */}
{!isRegistering && (
<div className="email-signin-section">
<div className="divider">
<span>or</span>
</div>
<button
onClick={() => {
setAuthMode('email-link');
setError(null);
}}
disabled={isLoading}
className="email-signin-button"
>
Sign in with Email (new device)
</button>
<p className="email-signin-hint">
Use this if you've linked an email to your CryptID on another device.
</p>
</div>
)}
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Cancel

View File

@ -1,5 +1,12 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import {
linkEmailToAccount,
checkEmailStatus,
getLinkedDevices,
revokeDevice,
Device
} from '../../lib/auth/cryptidEmailService';
interface ProfileProps {
onLogout?: () => void;
@ -11,6 +18,101 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
const [vaultPath, setVaultPath] = useState(session.obsidianVaultPath || '');
const [isEditingVault, setIsEditingVault] = useState(false);
// Email linking state
const [email, setEmail] = useState('');
const [emailStatus, setEmailStatus] = useState<{
linked: boolean;
verified: boolean;
email?: string;
}>({ linked: false, verified: false });
const [emailLoading, setEmailLoading] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
// Linked devices state
const [devices, setDevices] = useState<Device[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [showDevices, setShowDevices] = useState(false);
// Check email status on mount
useEffect(() => {
if (session.authed && session.username) {
checkEmailStatusHandler();
}
}, [session.authed, session.username]);
const checkEmailStatusHandler = async () => {
if (!session.username) return;
const status = await checkEmailStatus(session.username);
if (status.found) {
setEmailStatus({
linked: true,
verified: status.emailVerified || false,
email: status.email
});
}
};
const handleLinkEmail = async () => {
if (!session.username || !email) return;
setEmailLoading(true);
setEmailError(null);
setEmailSuccess(null);
const result = await linkEmailToAccount(email, session.username);
setEmailLoading(false);
if (result.success) {
if (result.emailVerified) {
setEmailSuccess('Email already verified!');
setEmailStatus({ linked: true, verified: true, email });
} else if (result.emailSent) {
setEmailSuccess('Verification email sent! Check your inbox.');
setEmailStatus({ linked: true, verified: false, email });
} else {
setEmailSuccess('Email linked but verification email failed to send.');
setEmailStatus({ linked: true, verified: false, email });
}
setShowEmailForm(false);
} else {
setEmailError(result.error || 'Failed to link email');
}
};
const loadDevices = async () => {
if (!session.username) return;
setDevicesLoading(true);
const deviceList = await getLinkedDevices(session.username);
setDevices(deviceList);
setDevicesLoading(false);
};
const handleRevokeDevice = async (deviceId: string) => {
if (!session.username) return;
const confirmed = window.confirm('Are you sure you want to revoke this device? It will no longer be able to access your account.');
if (!confirmed) return;
const result = await revokeDevice(session.username, deviceId);
if (result.success) {
loadDevices(); // Reload device list
} else {
alert(result.error || 'Failed to revoke device');
}
};
const toggleDevices = () => {
if (!showDevices) {
loadDevices();
}
setShowDevices(!showDevices);
};
const handleVaultPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVaultPath(e.target.value);
};
@ -148,12 +250,137 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
</details>
</div>
{/* Email & Multi-Device Section */}
<div className="profile-settings email-settings">
<h4>Email & Devices</h4>
{/* Email Status */}
<div className="email-status-section">
{emailStatus.linked ? (
<div className="email-linked">
<div className="email-info">
<span className="email-label">Linked Email:</span>
<span className="email-value">{emailStatus.email}</span>
{emailStatus.verified ? (
<span className="email-verified-badge">Verified</span>
) : (
<span className="email-pending-badge">Pending Verification</span>
)}
</div>
{!emailStatus.verified && (
<p className="email-hint">Check your inbox for the verification email.</p>
)}
{emailStatus.verified && (
<p className="email-hint">
You can now sign in on other devices using this email.
</p>
)}
</div>
) : (
<div className="email-not-linked">
{!showEmailForm ? (
<>
<p className="email-description">
Link an email to access your CryptID from multiple devices.
</p>
<button
onClick={() => setShowEmailForm(true)}
className="link-email-button"
>
Link Email Address
</button>
</>
) : (
<div className="email-form">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email..."
className="email-input"
disabled={emailLoading}
/>
<div className="email-form-actions">
<button
onClick={handleLinkEmail}
className="save-button"
disabled={emailLoading || !email}
>
{emailLoading ? 'Linking...' : 'Link Email'}
</button>
<button
onClick={() => {
setShowEmailForm(false);
setEmail('');
setEmailError(null);
}}
className="cancel-button"
disabled={emailLoading}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
{emailError && <p className="email-error">{emailError}</p>}
{emailSuccess && <p className="email-success">{emailSuccess}</p>}
</div>
{/* Linked Devices */}
{emailStatus.linked && emailStatus.verified && (
<div className="devices-section">
<button onClick={toggleDevices} className="toggle-devices-button">
{showDevices ? 'Hide Linked Devices' : 'View Linked Devices'}
</button>
{showDevices && (
<div className="devices-list">
{devicesLoading ? (
<p className="devices-loading">Loading devices...</p>
) : devices.length === 0 ? (
<p className="no-devices">No devices linked yet.</p>
) : (
<ul className="device-list">
{devices.map((device) => (
<li key={device.id} className={`device-item ${device.isCurrentDevice ? 'current-device' : ''}`}>
<div className="device-info">
<span className="device-name">
{device.deviceName}
{device.isCurrentDevice && <span className="current-badge"> (this device)</span>}
</span>
<span className="device-meta">
Added: {new Date(device.createdAt).toLocaleDateString()}
{device.lastUsed && ` | Last used: ${new Date(device.lastUsed).toLocaleDateString()}`}
</span>
</div>
{!device.isCurrentDevice && (
<button
onClick={() => handleRevokeDevice(device.id)}
className="revoke-device-button"
title="Revoke this device"
>
Revoke
</button>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div>
<div className="profile-actions">
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
</div>
{!session.backupCreated && (
<div className="backup-reminder">
<p>Remember to back up your encryption keys to prevent data loss!</p>

View File

@ -642,4 +642,546 @@ html.dark .debug-input {
html.dark .debug-results h3 {
color: #e2e8f0;
}
}
/* =============================================
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;
}

View File

@ -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;
}
}

View File

@ -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<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// 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