Compare commits
No commits in common. "d672275adf9a2b9405eb034c4b0e181ae06e1112" and "c0310791ae6c338bb0e61c5f1a6ee5cc064877c1" have entirely different histories.
d672275adf
...
c0310791ae
|
|
@ -1,35 +0,0 @@
|
||||||
---
|
|
||||||
id: task-020
|
|
||||||
title: Implement Google Data Sovereignty (Local-First Encrypted Storage)
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 12:32'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- security
|
|
||||||
- google-integration
|
|
||||||
- offline-storage
|
|
||||||
dependencies: []
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Implement secure, local-first storage for Google Workspace data (Gmail, Drive, Photos, Calendar) with client-side encryption, selective sharing to canvas boards, and optional R2 encrypted backup. See docs/GOOGLE_DATA_SOVEREIGNTY.md for full architecture.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 IndexedDB schema created for encrypted Google data
|
|
||||||
- [ ] #2 Key derivation from existing WebCrypto auth keys
|
|
||||||
- [ ] #3 Google OAuth 2.0 with PKCE implemented
|
|
||||||
- [ ] #4 Gmail messages can be imported and encrypted locally
|
|
||||||
- [ ] #5 Drive documents can be imported and encrypted locally
|
|
||||||
- [ ] #6 Photos thumbnails can be imported and encrypted locally
|
|
||||||
- [ ] #7 Calendar events can be imported and encrypted locally
|
|
||||||
- [ ] #8 Data can be selectively shared to canvas board (Automerge sync)
|
|
||||||
- [ ] #9 Encrypted R2 backup and restore working
|
|
||||||
- [ ] #10 Safari 7-day eviction mitigations in place
|
|
||||||
- [ ] #11 Storage quota warnings implemented
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
---
|
|
||||||
id: task-021
|
|
||||||
title: Implement Multi-Item File Upload with Encrypted Local Storage
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-04 12:37'
|
|
||||||
labels:
|
|
||||||
- feature
|
|
||||||
- security
|
|
||||||
- offline-storage
|
|
||||||
- upload
|
|
||||||
dependencies: []
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Add drag-and-drop / file picker interface for batch importing local files into encrypted IndexedDB storage. Supports images, PDFs, documents, text files, audio, and video. Files are encrypted client-side and can be selectively shared to boards or backed up to R2. See docs/LOCAL_FILE_UPLOAD.md for full architecture.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 File drop zone component with drag-and-drop
|
|
||||||
- [ ] #2 Support for images, PDFs, documents, text, audio, video
|
|
||||||
- [ ] #3 Image thumbnail generation
|
|
||||||
- [ ] #4 PDF text extraction and thumbnail
|
|
||||||
- [ ] #5 All files encrypted before IndexedDB storage
|
|
||||||
- [ ] #6 File browser panel with type filtering
|
|
||||||
- [ ] #7 Search within uploaded files
|
|
||||||
- [ ] #8 Storage quota display and warnings
|
|
||||||
- [ ] #9 Drag files from browser to canvas
|
|
||||||
- [ ] #10 Canvas shapes for images, PDFs, documents, generic files
|
|
||||||
- [ ] #11 Share confirmation dialog for private files
|
|
||||||
- [ ] #12 Per-file privacy controls
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
@ -29,10 +29,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
||||||
const [emailLinkSent, setEmailLinkSent] = useState(false);
|
const [emailLinkSent, setEmailLinkSent] = useState(false);
|
||||||
const [pendingCryptId, setPendingCryptId] = useState<string | null>(null);
|
const [pendingCryptId, setPendingCryptId] = useState<string | null>(null);
|
||||||
const [needsUsername, setNeedsUsername] = useState(false);
|
|
||||||
const [newAccountUsername, setNewAccountUsername] = useState('');
|
|
||||||
const [isNewAccountFlow, setIsNewAccountFlow] = useState(false);
|
|
||||||
const [needsEmailVerificationFlow, setNeedsEmailVerificationFlow] = useState(false);
|
|
||||||
const [browserSupport, setBrowserSupport] = useState<{
|
const [browserSupport, setBrowserSupport] = useState<{
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
|
|
@ -134,24 +130,15 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle email link request (Device B - new device)
|
* Handle email link request (Device B - new device)
|
||||||
* Combined flow: This will verify email AND link device in one step
|
|
||||||
*/
|
*/
|
||||||
const handleEmailLinkRequest = async () => {
|
const handleEmailLinkRequest = async () => {
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
// If we need a username but don't have one, show the username form
|
|
||||||
if (needsUsername && !newAccountUsername.trim()) {
|
|
||||||
setError('Please enter a CryptID username to create your account');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await requestDeviceLink(email, {
|
const result = await requestDeviceLink(email);
|
||||||
cryptidUsername: needsUsername ? newAccountUsername.trim() : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.alreadyLinked) {
|
if (result.alreadyLinked) {
|
||||||
|
|
@ -168,30 +155,13 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
// Email sent - show waiting message
|
// Email sent - show waiting message
|
||||||
setEmailLinkSent(true);
|
setEmailLinkSent(true);
|
||||||
setPendingCryptId(result.cryptidUsername || null);
|
setPendingCryptId(result.cryptidUsername || null);
|
||||||
setNeedsUsername(false);
|
addNotification('Verification email sent! Check your inbox.', 'success');
|
||||||
setIsNewAccountFlow(result.isNewAccount || false);
|
|
||||||
setNeedsEmailVerificationFlow(result.needsEmailVerification || false);
|
|
||||||
|
|
||||||
// Contextual message based on flow type
|
|
||||||
if (result.isNewAccount) {
|
|
||||||
addNotification('Setup email sent! Check your inbox to complete registration.', 'success');
|
|
||||||
} else if (result.needsEmailVerification) {
|
|
||||||
addNotification('Verification email sent! This will verify your email and link this device.', 'success');
|
|
||||||
} else {
|
|
||||||
addNotification('Device link email sent! Check your inbox.', 'success');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to send verification email');
|
setError('Failed to send verification email');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Handle needsUsername response from server
|
|
||||||
if (result.needsUsername) {
|
|
||||||
setNeedsUsername(true);
|
|
||||||
setError('No account found for this email. Please choose a CryptID username to create your account.');
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to request device link');
|
setError(result.error || 'Failed to request device link');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Email link request error:', err);
|
console.error('Email link request error:', err);
|
||||||
setError('An unexpected error occurred');
|
setError('An unexpected error occurred');
|
||||||
|
|
@ -282,41 +252,21 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
|
||||||
// Email link sent - waiting for user to click verification
|
// Email link sent - waiting for user to click verification
|
||||||
if (authMode === 'email-link' && emailLinkSent) {
|
if (authMode === 'email-link' && emailLinkSent) {
|
||||||
// Determine contextual messaging
|
|
||||||
const getHeadline = () => {
|
|
||||||
if (isNewAccountFlow) return 'Complete Your Setup';
|
|
||||||
if (needsEmailVerificationFlow) return 'Verify & Connect';
|
|
||||||
return 'Check Your Email';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDescription = () => {
|
|
||||||
if (isNewAccountFlow) {
|
|
||||||
return 'Click the link in the email to verify your email and set up your CryptID account on this device.';
|
|
||||||
}
|
|
||||||
if (needsEmailVerificationFlow) {
|
|
||||||
return 'Click the link to verify your email and connect this device to your account in one step.';
|
|
||||||
}
|
|
||||||
return 'Click the link in the email to complete sign in on this device.';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crypto-login-container">
|
<div className="crypto-login-container">
|
||||||
<h2>{getHeadline()}</h2>
|
<h2>Check Your Email</h2>
|
||||||
<div className="email-link-pending">
|
<div className="email-link-pending">
|
||||||
<div className="email-icon">📧</div>
|
<div className="email-icon">📧</div>
|
||||||
<p>We sent a verification link to:</p>
|
<p>We sent a verification link to:</p>
|
||||||
<p className="email-address"><strong>{email}</strong></p>
|
<p className="email-address"><strong>{email}</strong></p>
|
||||||
{pendingCryptId && (
|
{pendingCryptId && (
|
||||||
<p className="cryptid-info">
|
<p className="cryptid-info">
|
||||||
{isNewAccountFlow
|
This will link this device to CryptID: <strong>{pendingCryptId}</strong>
|
||||||
? <>Your CryptID will be: <strong>{pendingCryptId}</strong></>
|
|
||||||
: <>This will link this device to CryptID: <strong>{pendingCryptId}</strong></>
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="email-instructions">
|
<p className="email-instructions">
|
||||||
{getDescription()}
|
Click the link in the email to complete sign in on this device.
|
||||||
{' '}The link expires in 1 hour.
|
The link expires in 1 hour.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -324,8 +274,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
setAuthMode('login');
|
setAuthMode('login');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setPendingCryptId(null);
|
setPendingCryptId(null);
|
||||||
setIsNewAccountFlow(false);
|
|
||||||
setNeedsEmailVerificationFlow(false);
|
|
||||||
}}
|
}}
|
||||||
className="toggle-button"
|
className="toggle-button"
|
||||||
>
|
>
|
||||||
|
|
@ -345,12 +293,11 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
if (authMode === 'email-link') {
|
if (authMode === 'email-link') {
|
||||||
return (
|
return (
|
||||||
<div className="crypto-login-container">
|
<div className="crypto-login-container">
|
||||||
<h2>{needsUsername ? 'Create CryptID Account' : 'Sign In with Email'}</h2>
|
<h2>Sign In with Email</h2>
|
||||||
<div className="crypto-info">
|
<div className="crypto-info">
|
||||||
<p>
|
<p>
|
||||||
{needsUsername
|
Enter the email address linked to your CryptID account.
|
||||||
? 'No account found for this email. Choose a CryptID username to create your account.'
|
We'll send a verification link to complete sign in on this device.
|
||||||
: 'Enter your email address to sign in or create an account. One click will verify your email and link this device.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -361,48 +308,22 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
setEmail(e.target.value);
|
|
||||||
// Reset needsUsername when email changes
|
|
||||||
if (needsUsername) {
|
|
||||||
setNeedsUsername(false);
|
|
||||||
setNewAccountUsername('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Enter your email..."
|
placeholder="Enter your email..."
|
||||||
required
|
required
|
||||||
disabled={isLoading || needsUsername}
|
disabled={isLoading}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{needsUsername && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="newAccountUsername">CryptID Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="newAccountUsername"
|
|
||||||
value={newAccountUsername}
|
|
||||||
onChange={(e) => setNewAccountUsername(e.target.value)}
|
|
||||||
placeholder="Choose a username..."
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
autoComplete="username"
|
|
||||||
minLength={3}
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
<p className="field-hint">This will be your unique CryptID identity</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !email.trim() || (needsUsername && !newAccountUsername.trim())}
|
disabled={isLoading || !email.trim()}
|
||||||
className="crypto-auth-button"
|
className="crypto-auth-button"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Sending...' : needsUsername ? 'Create Account & Send Link' : 'Send Verification Link'}
|
{isLoading ? 'Sending...' : 'Send Verification Link'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -412,8 +333,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
setAuthMode('login');
|
setAuthMode('login');
|
||||||
setError(null);
|
setError(null);
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setNeedsUsername(false);
|
|
||||||
setNewAccountUsername('');
|
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="toggle-button"
|
className="toggle-button"
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,6 @@ export interface DeviceLinkResult {
|
||||||
alreadyLinked?: boolean;
|
alreadyLinked?: boolean;
|
||||||
emailSent?: boolean;
|
emailSent?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
// Combined flow fields
|
|
||||||
isNewAccount?: boolean;
|
|
||||||
needsEmailVerification?: boolean;
|
|
||||||
needsUsername?: boolean;
|
|
||||||
// Completion fields
|
|
||||||
emailVerified?: boolean;
|
|
||||||
emailWasJustVerified?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LookupResult {
|
export interface LookupResult {
|
||||||
|
|
@ -137,21 +130,16 @@ export async function checkEmailStatus(cryptidUsername: string): Promise<LookupR
|
||||||
* Request to link a new device using email
|
* Request to link a new device using email
|
||||||
* Called from Device B (new device)
|
* Called from Device B (new device)
|
||||||
*
|
*
|
||||||
* Combined Flow:
|
* Flow:
|
||||||
* 1. Generate new keypair on Device B
|
* 1. Generate new keypair on Device B
|
||||||
* 2. Send email + publicKey (+ optional username for new accounts) to server
|
* 2. Send email + publicKey to server
|
||||||
* 3. Server sends verification email
|
* 3. Server sends verification email
|
||||||
* 4. User clicks link in email (on Device B)
|
* 4. User clicks link in email (on Device B)
|
||||||
* 5. Device B's key is linked AND email is verified in one step
|
* 5. Device B's key is linked to the account
|
||||||
*
|
|
||||||
* If email is not found and no username provided, server returns needsUsername: true
|
|
||||||
*/
|
*/
|
||||||
export async function requestDeviceLink(
|
export async function requestDeviceLink(
|
||||||
email: string,
|
email: string,
|
||||||
options?: {
|
deviceName?: string
|
||||||
deviceName?: string;
|
|
||||||
cryptidUsername?: string; // Required for new accounts
|
|
||||||
}
|
|
||||||
): Promise<DeviceLinkResult & { publicKey?: string }> {
|
): Promise<DeviceLinkResult & { publicKey?: string }> {
|
||||||
try {
|
try {
|
||||||
// Generate a new keypair for this device
|
// Generate a new keypair for this device
|
||||||
|
|
@ -180,8 +168,7 @@ export async function requestDeviceLink(
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
publicKey,
|
publicKey,
|
||||||
deviceName: options?.deviceName || getDeviceName(),
|
deviceName: deviceName || getDeviceName()
|
||||||
cryptidUsername: options?.cryptidUsername
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,8 +177,7 @@ export async function requestDeviceLink(
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: data.error || 'Failed to request device link',
|
error: data.error || 'Failed to request device link'
|
||||||
needsUsername: data.needsUsername
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,8 +189,6 @@ export async function requestDeviceLink(
|
||||||
email,
|
email,
|
||||||
publicKey,
|
publicKey,
|
||||||
cryptidUsername: data.cryptidUsername,
|
cryptidUsername: data.cryptidUsername,
|
||||||
isNewAccount: data.isNewAccount,
|
|
||||||
needsEmailVerification: data.needsEmailVerification,
|
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -424,12 +408,7 @@ export function hasPendingDeviceLink(): boolean {
|
||||||
/**
|
/**
|
||||||
* Get pending device link info
|
* Get pending device link info
|
||||||
*/
|
*/
|
||||||
export function getPendingDeviceLink(): {
|
export function getPendingDeviceLink(): { email: string; cryptidUsername: string } | null {
|
||||||
email: string;
|
|
||||||
cryptidUsername: string;
|
|
||||||
isNewAccount?: boolean;
|
|
||||||
needsEmailVerification?: boolean;
|
|
||||||
} | null {
|
|
||||||
const pending = sessionStorage.getItem('pendingDeviceLink');
|
const pending = sessionStorage.getItem('pendingDeviceLink');
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
|
|
||||||
|
|
@ -438,9 +417,7 @@ export function getPendingDeviceLink(): {
|
||||||
if (Date.now() - data.timestamp < 60 * 60 * 1000) {
|
if (Date.now() - data.timestamp < 60 * 60 * 1000) {
|
||||||
return {
|
return {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
cryptidUsername: data.cryptidUsername,
|
cryptidUsername: data.cryptidUsername
|
||||||
isNewAccount: data.isNewAccount,
|
|
||||||
needsEmailVerification: data.needsEmailVerification
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -264,10 +264,7 @@ export async function handleVerifyEmail(
|
||||||
/**
|
/**
|
||||||
* Request to link a new device (Device B enters email)
|
* Request to link a new device (Device B enters email)
|
||||||
* POST /auth/request-device-link
|
* POST /auth/request-device-link
|
||||||
* Body: { email, publicKey, deviceName, cryptidUsername? }
|
* Body: { email, publicKey, deviceName }
|
||||||
*
|
|
||||||
* Combined flow: If email not verified yet, this will verify email AND link device
|
|
||||||
* in one step when user clicks the link. Saves user time vs separate flows.
|
|
||||||
*/
|
*/
|
||||||
export async function handleRequestDeviceLink(
|
export async function handleRequestDeviceLink(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -278,10 +275,9 @@ export async function handleRequestDeviceLink(
|
||||||
email: string;
|
email: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
cryptidUsername?: string; // Optional: for new accounts being set up
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { email, publicKey, deviceName, cryptidUsername: providedUsername } = body;
|
const { email, publicKey, deviceName } = body;
|
||||||
|
|
||||||
if (!email || !publicKey) {
|
if (!email || !publicKey) {
|
||||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||||
|
|
@ -290,15 +286,6 @@ export async function handleRequestDeviceLink(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid email format' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = env.CRYPTID_DB;
|
const db = env.CRYPTID_DB;
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||||
|
|
@ -307,56 +294,34 @@ export async function handleRequestDeviceLink(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this public key is already registered
|
// Check if email exists and is verified
|
||||||
const existingKey = await db.prepare(
|
const user = await db.prepare(
|
||||||
'SELECT dk.*, u.cryptid_username FROM device_keys dk JOIN users u ON dk.user_id = u.id WHERE dk.public_key = ?'
|
'SELECT * FROM users WHERE email = ? AND email_verified = 1'
|
||||||
).bind(publicKey).first<DeviceKey & { cryptid_username: string }>();
|
|
||||||
|
|
||||||
if (existingKey) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: 'Device already linked',
|
|
||||||
cryptidUsername: existingKey.cryptid_username,
|
|
||||||
alreadyLinked: true
|
|
||||||
}), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email exists in system (verified or not)
|
|
||||||
let user = await db.prepare(
|
|
||||||
'SELECT * FROM users WHERE email = ?'
|
|
||||||
).bind(email).first<User>();
|
).bind(email).first<User>();
|
||||||
|
|
||||||
const isNewAccount = !user;
|
if (!user) {
|
||||||
const needsEmailVerification = !user || !user.email_verified;
|
|
||||||
|
|
||||||
// If no user exists and no username provided, we need a username
|
|
||||||
if (!user && !providedUsername) {
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: 'No account found for this email. Please provide a CryptID username to create an account.',
|
error: 'No verified CryptID account found for this email'
|
||||||
needsUsername: true
|
|
||||||
}), {
|
}), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user if doesn't exist
|
// Check if this public key is already registered
|
||||||
if (!user && providedUsername) {
|
const existingKey = await db.prepare(
|
||||||
const userId = generateUUID();
|
'SELECT * FROM device_keys WHERE public_key = ?'
|
||||||
await db.prepare(
|
).bind(publicKey).first<DeviceKey>();
|
||||||
'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)'
|
|
||||||
).bind(userId, email, providedUsername).run();
|
|
||||||
|
|
||||||
user = {
|
if (existingKey) {
|
||||||
id: userId,
|
return new Response(JSON.stringify({
|
||||||
email,
|
success: true,
|
||||||
cryptid_username: providedUsername,
|
message: 'Device already linked',
|
||||||
email_verified: 0,
|
cryptidUsername: user.cryptid_username,
|
||||||
created_at: new Date().toISOString(),
|
alreadyLinked: true
|
||||||
updated_at: new Date().toISOString()
|
}), {
|
||||||
};
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userAgent = request.headers.get('User-Agent') || null;
|
const userAgent = request.headers.get('User-Agent') || null;
|
||||||
|
|
@ -364,65 +329,37 @@ export async function handleRequestDeviceLink(
|
||||||
// Clean up old tokens
|
// Clean up old tokens
|
||||||
await cleanupExpiredTokens(db);
|
await cleanupExpiredTokens(db);
|
||||||
|
|
||||||
// Create combined device link + email verification token
|
// Create device link token (1 hour expiry for security)
|
||||||
// Uses 'device_link' type but will also verify email when clicked
|
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
await db.prepare(
|
await db.prepare(
|
||||||
'INSERT INTO verification_tokens (id, email, token, token_type, public_key, device_name, user_agent, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO verification_tokens (id, email, token, token_type, public_key, device_name, user_agent, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).bind(generateUUID(), email, token, 'device_link', publicKey, deviceName || 'New Device', userAgent, expiresAt).run();
|
).bind(generateUUID(), email, token, 'device_link', publicKey, deviceName || 'New Device', userAgent, expiresAt).run();
|
||||||
|
|
||||||
// Send combined verification email
|
// Send device link email
|
||||||
const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`;
|
const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`;
|
||||||
|
const emailSent = await sendEmail(
|
||||||
// Different email content based on whether this is new account or existing
|
env,
|
||||||
const emailSubject = isNewAccount
|
email,
|
||||||
? 'Complete your CryptID setup'
|
'Link new device to your CryptID',
|
||||||
: needsEmailVerification
|
|
||||||
? 'Verify email and link device to your CryptID'
|
|
||||||
: 'Link new device to your CryptID';
|
|
||||||
|
|
||||||
const emailContent = isNewAccount
|
|
||||||
? `
|
|
||||||
<h2>Complete Your CryptID Setup</h2>
|
|
||||||
<p>Click the link below to verify your email and set up your CryptID: <strong>${user!.cryptid_username}</strong></p>
|
|
||||||
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
|
||||||
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Complete Setup</a></p>
|
|
||||||
<p>Or copy this link: ${linkUrl}</p>
|
|
||||||
<p>This link expires in 1 hour.</p>
|
|
||||||
<p style="color: #666; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
|
|
||||||
`
|
`
|
||||||
: needsEmailVerification
|
|
||||||
? `
|
|
||||||
<h2>Verify Email & Link Device</h2>
|
|
||||||
<p>Click the link below to verify your email and link this device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
|
|
||||||
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
|
||||||
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify & Link Device</a></p>
|
|
||||||
<p>Or copy this link: ${linkUrl}</p>
|
|
||||||
<p>This link expires in 1 hour.</p>
|
|
||||||
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong></p>
|
|
||||||
`
|
|
||||||
: `
|
|
||||||
<h2>New Device Link Request</h2>
|
<h2>New Device Link Request</h2>
|
||||||
<p>Someone is trying to link a new device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
|
<p>Someone is trying to link a new device to your CryptID: <strong>${user.cryptid_username}</strong></p>
|
||||||
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
||||||
<p>If this was you, click the button below to approve:</p>
|
<p>If this was you, click the button below to approve:</p>
|
||||||
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
|
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
|
||||||
<p>Or copy this link: ${linkUrl}</p>
|
<p>Or copy this link: ${linkUrl}</p>
|
||||||
<p>This link expires in 1 hour.</p>
|
<p>This link expires in 1 hour.</p>
|
||||||
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong> Someone may be trying to access your account.</p>
|
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong> Someone may be trying to access your account.</p>
|
||||||
`;
|
`
|
||||||
|
);
|
||||||
const emailSent = await sendEmail(env, email, emailSubject, emailContent);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: emailSent ? 'Verification email sent to your address' : 'Failed to send email',
|
message: emailSent ? 'Verification email sent to your address' : 'Failed to send email',
|
||||||
emailSent,
|
emailSent,
|
||||||
cryptidUsername: user!.cryptid_username,
|
cryptidUsername: user.cryptid_username
|
||||||
isNewAccount,
|
|
||||||
needsEmailVerification
|
|
||||||
}), {
|
}), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
@ -439,10 +376,6 @@ export async function handleRequestDeviceLink(
|
||||||
/**
|
/**
|
||||||
* Complete device link (clicked from email on Device B)
|
* Complete device link (clicked from email on Device B)
|
||||||
* GET /auth/link-device/:token
|
* GET /auth/link-device/:token
|
||||||
*
|
|
||||||
* Combined flow: This also verifies email if not already verified.
|
|
||||||
* User clicks link from Device B, which links the device AND verifies the email
|
|
||||||
* in one action - saving the user from needing to do two separate verifications.
|
|
||||||
*/
|
*/
|
||||||
export async function handleLinkDevice(
|
export async function handleLinkDevice(
|
||||||
token: string,
|
token: string,
|
||||||
|
|
@ -481,9 +414,6 @@ export async function handleLinkDevice(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track if we're also verifying email
|
|
||||||
const wasEmailUnverified = user.email_verified === 0;
|
|
||||||
|
|
||||||
// Add the new device key
|
// Add the new device key
|
||||||
await db.prepare(
|
await db.prepare(
|
||||||
'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)'
|
'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
|
@ -495,14 +425,6 @@ export async function handleLinkDevice(
|
||||||
tokenRecord.user_agent
|
tokenRecord.user_agent
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
// COMBINED FLOW: Also verify email if not already verified
|
|
||||||
// This saves the user from needing to click a separate email verification link
|
|
||||||
if (wasEmailUnverified) {
|
|
||||||
await db.prepare(
|
|
||||||
"UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?"
|
|
||||||
).bind(user.id).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark token as used
|
// Mark token as used
|
||||||
await db.prepare(
|
await db.prepare(
|
||||||
'UPDATE verification_tokens SET used = 1 WHERE id = ?'
|
'UPDATE verification_tokens SET used = 1 WHERE id = ?'
|
||||||
|
|
@ -510,13 +432,9 @@ export async function handleLinkDevice(
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: wasEmailUnverified
|
message: 'Device linked successfully',
|
||||||
? 'Device linked and email verified successfully'
|
|
||||||
: 'Device linked successfully',
|
|
||||||
cryptidUsername: user.cryptid_username,
|
cryptidUsername: user.cryptid_username,
|
||||||
email: user.email,
|
email: user.email
|
||||||
emailVerified: true, // Now definitely verified
|
|
||||||
emailWasJustVerified: wasEmailUnverified
|
|
||||||
}), {
|
}), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue