|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { Suspense, useState, useEffect } from 'react'; |
| 4 | +import { useRouter, useSearchParams } from 'next/navigation'; |
| 5 | +import { useAuthStore } from '@/lib/stores/auth.js'; |
| 6 | +import { useShallow } from 'zustand/react/shallow'; |
| 7 | +import { useMessagesStore } from '@/lib/stores/messages.js'; |
| 8 | +import { createSupabaseClient } from '@/lib/supabase.js'; |
| 9 | +import { keyManager } from '@/lib/crypto/key-manager.js'; |
| 10 | +import { privateKeyManager } from '@/lib/crypto/private-key-manager.js'; |
| 11 | +import { postQuantumEncryption } from '@/lib/crypto/post-quantum-encryption.js'; |
| 12 | +import Message from '@/lib/components/Message.jsx'; |
| 13 | + |
| 14 | +/** Map register-anon error codes to friendly copy. */ |
| 15 | +const INVITE_ERRORS = { |
| 16 | + invite_used: 'This invite has already been used. Ask for a fresh one.', |
| 17 | + expired: 'This invite has expired. Ask for a fresh one.', |
| 18 | + bad_signature: 'This invite link is invalid or has been tampered with.', |
| 19 | + bad_format: 'This invite link is malformed.', |
| 20 | + unknown_issuer: 'This invite was issued by an unrecognized source.' |
| 21 | +}; |
| 22 | + |
| 23 | +function AnonRegister() { |
| 24 | + const router = useRouter(); |
| 25 | + const searchParams = useSearchParams(); |
| 26 | + const { authenticated } = useAuthStore(useShallow((s) => ({ authenticated: !!s.user }))); |
| 27 | + const msgs = useMessagesStore((s) => s.messages); |
| 28 | + const removeMsg = useMessagesStore((s) => s.remove); |
| 29 | + const msgStore = useMessagesStore.getState(); |
| 30 | + |
| 31 | + const [invite, setInvite] = useState(''); |
| 32 | + const [step, setStep] = useState('loading'); // loading | invalid | profile | backup |
| 33 | + const [username, setUsername] = useState(''); |
| 34 | + const [displayName, setDisplayName] = useState(''); |
| 35 | + const [submitting, setSubmitting] = useState(false); |
| 36 | + const [anonSession, setAnonSession] = useState(null); |
| 37 | + const [backupPin, setBackupPin] = useState(''); |
| 38 | + const [confirmBackupPin, setConfirmBackupPin] = useState(''); |
| 39 | + const [showBackupPin, setShowBackupPin] = useState(false); |
| 40 | + |
| 41 | + useEffect(() => { |
| 42 | + if (authenticated) { |
| 43 | + router.replace('/chat'); |
| 44 | + return; |
| 45 | + } |
| 46 | + const token = searchParams.get('invite'); |
| 47 | + if (!token || !token.startsWith('qci1.')) { |
| 48 | + setStep('invalid'); |
| 49 | + return; |
| 50 | + } |
| 51 | + setInvite(token); |
| 52 | + setStep('profile'); |
| 53 | + }, [authenticated, searchParams, router]); |
| 54 | + |
| 55 | + async function handleCreate(e) { |
| 56 | + e?.preventDefault(); |
| 57 | + if (!username.trim() || submitting) return; |
| 58 | + setSubmitting(true); |
| 59 | + msgStore.clear(); |
| 60 | + try { |
| 61 | + // 1. Anonymous Supabase session (no phone, no email). |
| 62 | + const supabase = createSupabaseClient(); |
| 63 | + const { data: anonData, error: anonErr } = await supabase.auth.signInAnonymously(); |
| 64 | + if (anonErr || !anonData?.session) { |
| 65 | + msgStore.error('Could not start an anonymous session. Please try again.'); |
| 66 | + setSubmitting(false); |
| 67 | + return; |
| 68 | + } |
| 69 | + const session = anonData.session; |
| 70 | + |
| 71 | + // 2. Generate on-device post-quantum keys; share the ML-KEM-1024 public key. |
| 72 | + await postQuantumEncryption.initialize(); |
| 73 | + const publicKey = await postQuantumEncryption.getPublicKey(); |
| 74 | + await keyManager.generateUserKeys(); |
| 75 | + |
| 76 | + // 3. Redeem the invite + create the anonymous account. |
| 77 | + const res = await fetch('/api/auth/register-anon', { |
| 78 | + method: 'POST', |
| 79 | + headers: { |
| 80 | + 'Content-Type': 'application/json', |
| 81 | + Authorization: `Bearer ${session.access_token}` |
| 82 | + }, |
| 83 | + body: JSON.stringify({ |
| 84 | + inviteToken: invite, |
| 85 | + username: username.trim(), |
| 86 | + displayName: displayName.trim() || username.trim(), |
| 87 | + publicKey |
| 88 | + }) |
| 89 | + }); |
| 90 | + const data = await res.json().catch(() => ({})); |
| 91 | + |
| 92 | + if (!res.ok || !data.success) { |
| 93 | + // Roll back the throwaway anon auth session so a retry is clean. |
| 94 | + await supabase.auth.signOut().catch(() => {}); |
| 95 | + const friendly = data.code ? INVITE_ERRORS[data.code] : null; |
| 96 | + msgStore.error(friendly || data.error || 'Failed to create your account.'); |
| 97 | + setSubmitting(false); |
| 98 | + return; |
| 99 | + } |
| 100 | + |
| 101 | + // 4. Persist session + user the same way the phone path does. |
| 102 | + localStorage.setItem('qrypt_user', JSON.stringify(data.user)); |
| 103 | + localStorage.setItem('qrypt_session', JSON.stringify(session)); |
| 104 | + useAuthStore.setState({ user: data.user }); |
| 105 | + |
| 106 | + setAnonSession(session); |
| 107 | + setStep('backup'); |
| 108 | + msgStore.success('Anonymous account created! Set a Backup PIN to protect your keys.'); |
| 109 | + } catch { |
| 110 | + msgStore.error('Something went wrong creating your account. Please try again.'); |
| 111 | + } finally { |
| 112 | + setSubmitting(false); |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + async function setBackupPinFn(e) { |
| 117 | + e?.preventDefault(); |
| 118 | + if (!backupPin || !/^\d+$/.test(backupPin) || backupPin.length < 6 || backupPin.length > 12) { |
| 119 | + msgStore.error('PIN must be 6-12 digits'); |
| 120 | + return; |
| 121 | + } |
| 122 | + if (backupPin !== confirmBackupPin) { |
| 123 | + msgStore.error('PINs do not match'); |
| 124 | + return; |
| 125 | + } |
| 126 | + setSubmitting(true); |
| 127 | + try { |
| 128 | + const headers = { 'Content-Type': 'application/json' }; |
| 129 | + if (anonSession?.access_token) headers['Authorization'] = `Bearer ${anonSession.access_token}`; |
| 130 | + const pinRes = await fetch('/api/auth/backup-pin', { |
| 131 | + method: 'POST', |
| 132 | + headers, |
| 133 | + body: JSON.stringify({ pin: backupPin }) |
| 134 | + }); |
| 135 | + if (!pinRes.ok) { |
| 136 | + const err = await pinRes.json().catch(() => ({})); |
| 137 | + throw new Error(err.error || 'Failed to save PIN'); |
| 138 | + } |
| 139 | + const encryptedData = await privateKeyManager.exportPrivateKeys(backupPin); |
| 140 | + const backupRes = await fetch('/api/auth/key-backup', { |
| 141 | + method: 'PUT', |
| 142 | + headers, |
| 143 | + body: JSON.stringify({ encrypted_keys: encryptedData }) |
| 144 | + }); |
| 145 | + if (!backupRes.ok) { |
| 146 | + const err = await backupRes.json().catch(() => ({})); |
| 147 | + throw new Error(err.error || 'Key backup failed'); |
| 148 | + } |
| 149 | + msgStore.success('Backup PIN set and keys backed up! Welcome to QryptChat!'); |
| 150 | + router.push('/chat'); |
| 151 | + } catch (err) { |
| 152 | + msgStore.error('Failed to set backup PIN: ' + (err.message || 'Unknown error')); |
| 153 | + } finally { |
| 154 | + setSubmitting(false); |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + return ( |
| 159 | + <div className="auth-container"> |
| 160 | + <div className="auth-card"> |
| 161 | + <div className="auth-header"> |
| 162 | + <div className="logo"> |
| 163 | + <div className="logo-icon">🕶️</div> |
| 164 | + <h1>QryptChat</h1> |
| 165 | + </div> |
| 166 | + <p className="subtitle">Anonymous, invite-only access</p> |
| 167 | + </div> |
| 168 | + |
| 169 | + <div className="messages-container"> |
| 170 | + {msgs.map((m) => ( |
| 171 | + <Message |
| 172 | + key={m.id} |
| 173 | + type={m.type} |
| 174 | + message={m.message} |
| 175 | + title={m.title} |
| 176 | + dismissible={m.dismissible} |
| 177 | + autoDismiss={m.autoDismiss} |
| 178 | + onDismiss={() => removeMsg(m.id)} |
| 179 | + /> |
| 180 | + ))} |
| 181 | + </div> |
| 182 | + |
| 183 | + {step === 'loading' && ( |
| 184 | + <div className="auth-step"> |
| 185 | + <p className="step-description">Checking your invite…</p> |
| 186 | + </div> |
| 187 | + )} |
| 188 | + |
| 189 | + {step === 'invalid' && ( |
| 190 | + <div className="auth-step"> |
| 191 | + <h2>Invite required</h2> |
| 192 | + <p className="step-description"> |
| 193 | + This page needs a valid anonymous invite link. Ask whoever invited you for a fresh |
| 194 | + <code> qrypt.chat/anon?invite=… </code> link, or sign up with a phone number instead. |
| 195 | + </p> |
| 196 | + <button className="primary-button" onClick={() => router.push('/auth')}> |
| 197 | + Sign up with phone |
| 198 | + </button> |
| 199 | + </div> |
| 200 | + )} |
| 201 | + |
| 202 | + {step === 'profile' && ( |
| 203 | + <div className="auth-step"> |
| 204 | + <h2>Create your anonymous identity</h2> |
| 205 | + <p className="step-description"> |
| 206 | + No phone, no email — just pick a username. Your account is end-to-end encrypted and |
| 207 | + not linked to whoever invited you. |
| 208 | + </p> |
| 209 | + <form onSubmit={handleCreate}> |
| 210 | + <div className="input-group"> |
| 211 | + <label htmlFor="username">Username *</label> |
| 212 | + <input |
| 213 | + id="username" |
| 214 | + type="text" |
| 215 | + value={username} |
| 216 | + onChange={(e) => setUsername(e.target.value)} |
| 217 | + placeholder="ghost42" |
| 218 | + required |
| 219 | + disabled={submitting} |
| 220 | + pattern="[a-zA-Z0-9_]+" |
| 221 | + autoComplete="off" |
| 222 | + /> |
| 223 | + </div> |
| 224 | + <div className="input-group"> |
| 225 | + <label htmlFor="displayName">Display Name</label> |
| 226 | + <input |
| 227 | + id="displayName" |
| 228 | + type="text" |
| 229 | + value={displayName} |
| 230 | + onChange={(e) => setDisplayName(e.target.value)} |
| 231 | + placeholder="Anonymous" |
| 232 | + disabled={submitting} |
| 233 | + autoComplete="off" |
| 234 | + /> |
| 235 | + </div> |
| 236 | + <button |
| 237 | + type="submit" |
| 238 | + disabled={submitting || !username.trim()} |
| 239 | + className="primary-button" |
| 240 | + > |
| 241 | + {submitting ? ( |
| 242 | + <> |
| 243 | + <span className="loading-spinner" /> Creating… |
| 244 | + </> |
| 245 | + ) : ( |
| 246 | + 'Create anonymous account' |
| 247 | + )} |
| 248 | + </button> |
| 249 | + </form> |
| 250 | + </div> |
| 251 | + )} |
| 252 | + |
| 253 | + {step === 'backup' && ( |
| 254 | + <div className="auth-step"> |
| 255 | + <h2>Set Your Backup PIN</h2> |
| 256 | + <p className="step-description">This PIN protects your encryption keys.</p> |
| 257 | + <div className="backup-warning"> |
| 258 | + <p> |
| 259 | + <strong>Important:</strong> There is no phone or email on this account. If you lose |
| 260 | + this PIN <em>and</em> this device, your account cannot be recovered. |
| 261 | + </p> |
| 262 | + </div> |
| 263 | + <form onSubmit={setBackupPinFn}> |
| 264 | + <div className="input-group"> |
| 265 | + <label>Backup PIN (6-12 digits) *</label> |
| 266 | + <div className="password-input"> |
| 267 | + <input |
| 268 | + type={showBackupPin ? 'text' : 'password'} |
| 269 | + inputMode="numeric" |
| 270 | + value={backupPin} |
| 271 | + onChange={(e) => setBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12))} |
| 272 | + placeholder="Enter 6-12 digit PIN" |
| 273 | + required |
| 274 | + disabled={submitting} |
| 275 | + className="code-input" |
| 276 | + /> |
| 277 | + <button |
| 278 | + type="button" |
| 279 | + className="toggle-password" |
| 280 | + onClick={() => setShowBackupPin(!showBackupPin)} |
| 281 | + > |
| 282 | + {showBackupPin ? '👁️' : '👁️🗨️'} |
| 283 | + </button> |
| 284 | + </div> |
| 285 | + </div> |
| 286 | + <div className="input-group"> |
| 287 | + <label>Confirm PIN *</label> |
| 288 | + <div className="password-input"> |
| 289 | + <input |
| 290 | + type={showBackupPin ? 'text' : 'password'} |
| 291 | + inputMode="numeric" |
| 292 | + value={confirmBackupPin} |
| 293 | + onChange={(e) => |
| 294 | + setConfirmBackupPin(e.target.value.replace(/\D/g, '').slice(0, 12)) |
| 295 | + } |
| 296 | + placeholder="Confirm your PIN" |
| 297 | + required |
| 298 | + disabled={submitting} |
| 299 | + className="code-input" |
| 300 | + /> |
| 301 | + </div> |
| 302 | + </div> |
| 303 | + <div className="button-group"> |
| 304 | + <button |
| 305 | + type="submit" |
| 306 | + disabled={submitting || !backupPin || !confirmBackupPin} |
| 307 | + className="primary-button" |
| 308 | + > |
| 309 | + {submitting ? ( |
| 310 | + <> |
| 311 | + <span className="loading-spinner" /> Setting PIN… |
| 312 | + </> |
| 313 | + ) : ( |
| 314 | + 'Set Backup PIN' |
| 315 | + )} |
| 316 | + </button> |
| 317 | + <button |
| 318 | + type="button" |
| 319 | + className="secondary-button" |
| 320 | + onClick={() => { |
| 321 | + msgStore.warning('Backup PIN skipped. You can set one later in Settings.'); |
| 322 | + router.push('/chat'); |
| 323 | + }} |
| 324 | + > |
| 325 | + Skip (Not Recommended) |
| 326 | + </button> |
| 327 | + </div> |
| 328 | + </form> |
| 329 | + </div> |
| 330 | + )} |
| 331 | + |
| 332 | + <div className="auth-footer"> |
| 333 | + <p className="privacy-note">Your data is encrypted end-to-end. We cannot read your messages.</p> |
| 334 | + </div> |
| 335 | + </div> |
| 336 | + |
| 337 | + <style>{` |
| 338 | + .auth-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); } |
| 339 | + .auth-card { background: var(--color-bg-primary); border-radius: 1rem; padding: 2rem; width: 100%; max-width: 400px; box-shadow: var(--shadow-lg); border: 1px solid var(--color-border-primary); } |
| 340 | + .auth-header { text-align: center; margin-bottom: 2rem; } |
| 341 | + .logo { display: flex; align-items: center; justify-content: center; gap: .5rem; margin-bottom: .5rem; } |
| 342 | + .logo-icon { font-size: 2rem; } |
| 343 | + .logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--color-brand-primary); margin: 0; } |
| 344 | + .subtitle { color: var(--color-text-secondary); margin: 0; } |
| 345 | + .auth-step h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: .5rem; color: var(--color-text-primary); } |
| 346 | + .step-description { color: var(--color-text-secondary); margin-bottom: 1.5rem; line-height: 1.5; } |
| 347 | + .step-description code { font-family: monospace; font-size: .85em; color: var(--color-text-primary); } |
| 348 | + .input-group { margin-bottom: 1rem; } |
| 349 | + .input-group label { display: block; font-weight: 500; margin-bottom: .5rem; color: var(--color-text-primary); } |
| 350 | + .input-group input { width: 100%; padding: .75rem; border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: 1rem; background: var(--color-bg-primary); color: var(--color-text-primary); } |
| 351 | + .input-group input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 3px rgb(99 102 241 / .1); } |
| 352 | + .code-input { font-family: monospace; font-size: 1.25rem; text-align: center; letter-spacing: .25em; } |
| 353 | + .primary-button { width: 100%; padding: .75rem; background: var(--color-brand-primary); color: white; border: none; border-radius: .5rem; font-size: 1rem; font-weight: 500; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .5rem; } |
| 354 | + .primary-button:hover:not(:disabled) { background: var(--color-brand-secondary); } |
| 355 | + .primary-button:disabled { opacity: .6; cursor: not-allowed; } |
| 356 | + .secondary-button { width: 100%; padding: .75rem; background: var(--color-bg-secondary); color: var(--color-text-secondary); border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: .875rem; font-weight: 500; cursor: pointer; } |
| 357 | + .secondary-button:hover:not(:disabled) { background: var(--color-bg-tertiary); } |
| 358 | + .messages-container { margin-bottom: 1rem; } |
| 359 | + .loading-spinner { width: 1rem; height: 1rem; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; } |
| 360 | + @keyframes spin { to { transform: rotate(360deg); } } |
| 361 | + .auth-footer { margin-top: 2rem; text-align: center; } |
| 362 | + .privacy-note { font-size: .75rem; color: var(--color-text-secondary); } |
| 363 | + .backup-warning { background: rgba(239,68,68,.1); border: 1px solid #ef4444; border-radius: .375rem; padding: 1rem; margin: 1rem 0; } |
| 364 | + .backup-warning p { color: #dc2626; margin: 0; font-weight: 500; font-size: .875rem; } |
| 365 | + .button-group { display: flex; flex-direction: column; gap: .75rem; margin-top: 1.5rem; } |
| 366 | + .password-input { position: relative; display: flex; align-items: center; } |
| 367 | + .password-input input { padding-right: 3rem; } |
| 368 | + .toggle-password { position: absolute; right: .75rem; background: none; border: none; cursor: pointer; font-size: 1rem; } |
| 369 | + `}</style> |
| 370 | + </div> |
| 371 | + ); |
| 372 | +} |
| 373 | + |
| 374 | +export default function AnonPage() { |
| 375 | + return ( |
| 376 | + <Suspense fallback={<div className="auth-container" />}> |
| 377 | + <AnonRegister /> |
| 378 | + </Suspense> |
| 379 | + ); |
| 380 | +} |
0 commit comments