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:
parent
f15f46f742
commit
c0310791ae
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue