1// ------------------------------------------------------------------------- 2// Step 22 â login/index.html migrated to import from shared/ modules. 3// 4// Removed duplicate implementations of: 5// constants â shared/constants.js 6// crypto helpers â shared/crypto-utils.js 7// supabase fetch â shared/supabase.js 8// session/profile â shared/cloud-auth.js 9// workspace crypto â shared/workspace-crypto.js 10// password UI â shared/password-ui.js 11// 12// Three thin LOCAL WRAPPERS are kept for saveCloudSession, clearCloudSession, 13// and saveCloudProfile because each has login-specific state.* side effects. 14// 15// Inline event handlers replaced with data-action attributes + event 16// delegation â no window.* exports needed. 17// ------------------------------------------------------------------------- 18 19import { PASSWORD_RECOVERY_URL, CLOUD_SYNC_KEYS } from '../shared/constants.js'; 20import { isWebCryptoReady, bytesToBase64, base64ToBytes } from '../shared/crypto-utils.js'; 21import { supabaseAuthRequest, supabaseRe
21stRequest } from '../shared/supabase.js'; 22import { 23 getStoredCloudSession, 24 saveCloudSession as _saveCloudSession, 25 clearCloudSession as _clearCloudSession, 26 ensureCloudSession, 27 normalizeCloudProfile, 28 getStoredCloudProfile, 29 saveCloudProfile as _saveCloudProfile, 30 isInvalidRefreshTokenError, 31} from '../shared/cloud-auth.js'; 32import { 33 generateRecoveryKey, 34 hasHostedWorkspaceWrappers, 35 clearHostedEncryptedLocalCaches, 36 fetchWorkspaceKeyRecord, 37 accountHasEncryptedHostedCourses, 38 persistWorkspaceKeyRecord, 39 buildWrappedWorkspaceKeyRecord, 40 stageWorkspaceKeyHandoff, 41 persistUnlockedWorkspaceKeyPayload, 42 unlockWorkspaceKeyForHandoff, 43 unlockWorkspaceKeyFromRecoveryKey, 44 createWorkspaceKeyForLogin, 45 primeWorkspaceKeyHandoff, 46 clearExpiredWorkspaceKeyHandoff, 47} from '../shared/workspace-crypto.js'; 48import { installPasswordToggleObserver } from '../shared/password-ui.js'; 49import { 50 getMfaStatus, 51 createMfaChallenge, 52 verifyMfaChallenge, 53} from '../shared/mfa.js'; 54// Passkey module is lazy-loaded â a failed import must never break the login form. 55let _passkeyModule = null; 56async function getPasskeyModule() { 57 if (!_passkeyModule) { 58 try { _passkeyModule = await import('../shared/passkey-crypto.js'); } catch { _passkeyModule = null; } 59 } 60 return _passkeyModule; 61} 62 63const state = { 64 booting: true, 65 email: '', 66 password: '', 67 sharedDevice: false, 68 busy: '', 69 error: '', 70 notice: '', 71 pending: false, 72 session: null, 73 profile: null, 74 recoveryKey: '', 75 recoveryTitle: '', 76 recoveryCopy: '', 77 recoveryInput: '', 78 recoveryReconnect: false, 79 pendingPassword: '', 80 pendingStorageMode: 'local', 81 passkeyAvailable: false, // true if passkey + wrapped key exist for this user 82 passkeySetupOffered: false, // true when showing the inline setup prompt 83 mfaRequired: false, // true when password succeeded but TOTP code needed 84 mfaFactorId: '', // the verified TOTP factor ID 85 mfaCode: '', // user-entered 6-digit code 86 mfaPassword: '', // stashed password for workspace key after MFA verify 87 mfaStorageMode: 'local', // stashed storage mode for after MFA verify 88}; 89 90function escapeHtml(value) { 91 return String(value ?? '') 92 .replace(/&/g, '&') 93 .replace(/</g, '<') 94 .replace(/>/g, '>') 95 .replace(/"/g, '"') 96 .replace(/'/g, '''); 97} 98 99function getFriendlyCloudAuthErrorMessage(error, context = 'general') { 100 const message = error?.message || 'Request failed.'; 101 102 if (/invalid login credentials/i.test(message)) { 103 return 'Those credentials did not work. Check the email and password, then try again.'; 104 } 105 if (/email not confirmed/i.test(message)) { 106 return 'This account still needs email confirmation before it can sign in.'; 107 } 108 if (/rate limit/i.test(message) || /\b429\b/.test(message)) { 109 return context === 'reset' 110 ? 'Too many reset requests hit Supabase. Wait a minute, then try again.' 111 : 'Supabase rate limits were hit. Wait a minute and try again.'; 112 } 113 if (/confirmation email/i.test(message)) { 114 return 'Supabase could not send the email. Finish the email setup first, then try again.'; 115 } 116 117 return message; 118} 119 120// ------------------------------------------------------------------------- 121// Thin local wrappers â delegate storage to shared/ but update login's 122// local state object as a side effect. 123// ------------------------------------------------------------------------- 124 125function saveCloudSession(session, storageMode = 'local') { 126 _saveCloudSession(session, storageMode); 127 state.session = session; 128} 129 130function clearCloudSession() { 131 _clearCloudSession(); 132 state.session = null; 133 state.profile = null; 134 state.pending = false; 135 clearWorkspaceFlowState(); 136} 137 138function saveCloudProfile(profile, storageMode = getStoredCloudSession().storage || 'local') { 139 _saveCloudProfile(profile, storageMode); 140 const normalized = normalizeCloudProfile(profile, getStoredCloudSession().session?.user); 141 state.profile = normalized; 142 return normalized; 143} 144 145function clearWorkspaceFlowState() { 146 state.recoveryKey = ''; 147 state.recoveryTitle = ''; 148 state.recoveryCopy = ''; 149 state.recoveryInput = ''; 150 state.recoveryReconnect = false;
151 state.pendingPassword = ''; 152 state.pendingStorageMode = 'local'; 153} 154 155async function fetchCloudProfile(session) { 156 const params = new URLSearchParams({ 157 select: 'id,email,full_name,role_title,institution_name,bio,approved,approved_at,approval_notes,is_admin', 158 id: `eq.${session.user.id}`, 159 limit: '1' 160 }); 161 const legacyParams = new URLSearchParams({ 162 select: 'id,email,approved,approved_at,approval_notes,is_admin', 163 id: `eq.${session.user.id}`, 164 limit: '1' 165 }); 166 167 for (let attempt = 0; attempt < 3; attempt += 1) { 168 let rows; 169 try { 170 rows = await supabaseRestRequest(`/rest/v1/profiles?${params.toString()}`, session.access_token, { 171 method: 'GET' 172 }); 173 } catch (error) { 174 const message = String(error?.message || ''); 175 if ( 176 !message.includes('full_name') && 177 !message.includes('institution_name') && 178 !message.includes('role_title') && 179 !message.includes('bio') && 180 !message.includes('42703') 181 ) { 182 throw error; 183 } 184 rows = await supabaseRestRequest(`/rest/v1/profiles?${legacyParams.toString()}`, session.access_token, { 185 method: 'GET' 186 }); 187 } 188 const row = Array.isArray(rows) ? rows[0] || null : null; 189 if (row?.id) { 190 const profile = normalizeCloudProfile(row, session.user); 191 return saveCloudProfile(profile, getStoredCloudSession().storage); 192 } 193 if (attempt < 2) { 194 await new Promise((resolve) => window.setTimeout(resolve, 150)); 195 } 196 } 197 198 const storedProfile = getStoredCloudProfile().profile; 199 if (storedProfile?.id === session.user.id && storedProfile.approved === true) { 200 return saveCloudProfile(storedProfile, getStoredCloudSession().storage); 201 } 202 203 throw new Error('We could not load your Class Action account record. Please try again.'); 204} 205 206/** Returns true if this device is an iPhone or iPad (the only devices where Face ID passkey adds value). */ 207function isIOSDevice() { 208 return /iPhone|iPad/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 209} 210 211/** 212 * Check if passkey unlock is available for the current user. 213 * Only offered on iOS â Mac already has Touch ID password autofill. 214 */ 215async function checkPasskeyAvailability(userId) { 216 try { 217 if (!isIOSDevice()) return false; 218 const passkey = await getPasskeyModule(); 219 if (!passkey) return false; 220 if (!passkey.isPasskeyPrfSupported()) return false; 221 return passkey.hasPasskeyUnlockReady(userId); 222 } catch { 223 return false; 224 } 225} 226 227/** 228 * Unlock with Face ID / Touch ID â no password needed. 229 * Refreshes the Supabase session, unwraps the workspace key via PRF, 230 * stages the handoff, and redirects to /app/. 231 */ 232async function passkeyUnlock() { 233 state.busy = 'passkey'; 234 state.error = ''; 235 renderApp(); 236 237 try { 238 // Refresh the Supabase session (uses stored refresh token) 239 const session = await ensureCloudSession(); 240 if (!session?.user?.id) { 241 state.error = 'Your session has expired. Please sign in with your password.'; 242 state.passkeyAvailable = false; 243 state.busy = ''; 244 renderApp(); 245 return; 246 } 247 248 // Unwrap workspace key with passkey PRF 249 const passkey = await getPasskeyModule(); 250 if (!passkey) throw new Error('Passkey module unavailable.'); 251 const rawKeyBytes = await passkey.unlockWorkspaceKeyWithPasskey(session.user.id); 252 const keyB64 = bytesToBase64(rawKeyBytes); 253 254 // Stage the handoff so /app/ can pick it up 255 const storageMode = state.sharedDevice ? 'session' : 'local'; 256 stageWorkspaceKeyHandoff(session.user.id, keyB64); 257 persistUnlockedWorkspaceKeyPayload(session.user.id, keyB64, storageMode); 258 259 redirectToHostedWorkspace(); 260 } catch (err) { 261 state.busy = ''; 262 if (err?.name === 'NotAllowedError') { 263 // User cancelled Face ID â just go back to the form, no error 264 renderApp(); 265 return; 266 } 267 state.error = 'Face ID didn\u2019t work. Sign in with your password instead.'; 268 state.passkeyAvailable = false; 269 renderApp(); 270 } 271} 272 273/** 274 * After a successful password login, offer passkey setup via an inline banner 275 * (not a native confirm dialog). Non-blocking: if user declines, login proceeds. 276 */ 277async function maybeOfferPasskeySetup(session, password, storageMode) { 278 try { 279 const userId = session?.user?.id; 280 if (!userId) return; 281 if (!isIOSDevice()) return; 282 const passkey = await getPasskeyModule(); 283 if (!passkey) return; 284 if (!passkey.isPasskeyPrfSupported()) return; 285 if (passkey.hasPasskeyUnlockReady(userId)) return; 286 287 // Only prompt once per device â check if user dismissed before
288 const dismissKey = `alr_passkey_prompt_dismissed_${userId}`; 289 if (localStorage.getItem(dismissKey)) return; 290 291 // Show inline setup prompt instead of redirecting immediately. 292 // The user sees the banner and can accept or skip. 293 state.session = session; 294 state.busy = ''; 295 state.notice = ''; 296 state.error = ''; 297 state.passkeySetupOffered = true; 298 renderApp(); 299 300 // Wait for user decision (resolved by setupPasskeyAccept / setupPasskeySkip) 301 await new Promise((resolve) => { window._passkeySetupResolve = resolve; }); 302 } catch (_) { 303 // Silent fail 304 } 305} 306 307async function setupPasskeyAccept() { 308 state.busy = 'passkey-setup'; 309 renderApp(); 310 try { 311 const passkey = await getPasskeyModule(); 312 const handoffRaw = sessionStorage.getItem(CLOUD_SYNC_KEYS.sessionWorkspaceKey); 313 if (!handoffRaw || !passkey) throw new Error('Setup unavailable â no passkey module or workspace key handoff.'); 314 const handoff = JSON.parse(handoffRaw); 315 if (!handoff?.keyB64) throw new Error('Workspace key not available in handoff.'); 316 const rawKey = base64ToBytes(handoff.keyB64); 317 await passkey.registerPasskeyAndWrapKey(state.session.user.id, state.session.user?.email || '', rawKey); 318 state.notice = 'Face ID enabled!'; 319 } catch (err) { 320 console.error('[passkey-setup]', err); 321 // Dismiss prompt on failure so it doesn't nag every login 322 const userId = state.session?.user?.id; 323 if (userId) { 324 localStorage.setItem(`alr_passkey_prompt_dismissed_${userId}`, Date.now().toString()); 325 } 326 if (err?.name === 'NotAllowedError') { 327 // User cancelled the biometric prompt â don't show error, just skip 328 state.notice = ''; 329 } else { 330 state.error = 'Face ID setup failed: ' + (err?.message || 'Unknown error') + '. You can try again in Settings > Security.'; 331 } 332 } 333 state.passkeySetupOffered = false; 334 state.busy = ''; 335 renderApp(); 336 // Brief pause so user can read the result before redirect 337 if (state.notice || state.error) { 338 await new Promise(r => setTimeout(r, 2000)); 339 } 340 if (window._passkeySetupResolve) window._passkeySetupResolve(); 341} 342 343function setupPasskeySkip() { 344 const userId = state.session?.user?.id; 345 if (userId) { 346 localStorage.setItem(`alr_passkey_prompt_dismissed_${userId}`, Date.now().toString()); 347 } 348 state.passkeySetupOffered = false; 349 renderApp(); 350 if (window._passkeySetupResolve) window._passkeySetupResolve(); 351} 352 353let _redirecting = false; 354function redirectToHostedWorkspace() { 355 _redirecting = true; 356 window.location.replace('/app/'); 357} 358 359function openRequestAccessPage() { 360 const target = new URL('/request-access/', window.location.origin); 361 const email = state.email.trim(); 362 if (email) target.searchParams.set('email', email); 363 window.location.href = target.toString(); 364} 365 366function handleInput(field, value) { 367 state[field] = value; 368} 369 370function handleSharedDeviceToggle(checked) { 371 state.sharedDevice = checked === true; 372} 373 374function renderLoading() { 375 return ` 376 <div class="auth-shell auth-shell-loading"> 377 <div class="loading-card"> 378 <div class="loading-spinner"></div> 379 <div class="loading-text">Loading</div> 380 </div> 381 </div> 382 `; 383} 384 385function renderPending() { 386 const note = state.profile?.approvalNotes 387 ? `<div class="banner warn">
387Admin note: ${escapeHtml(state.profile.approvalNotes)}</div>` 388 : ''; 389 390 return ` 391 <div class="auth-shell"> 392 ${state.error ? `<div class="banner danger">${escapeHtml(state.error)}</div>` : ''} 393 <div> 394 <h2 class="section-title">Account pending approval</h2> 395 <p class="section-copy">Once approved, you will receive an email notifying you to log in. Approvals are typically granted within 24 hours.</p> 396 </div> 397 ${note} 398 <div class="actions"> 399 <button class="btn" type="button" data-action="refresh-access" ${state.busy ? 'disabled' : ''}> 400 ${state.busy === 'refresh' ? 'Checking...' : 'Refresh Access'} 401 </button> 402 <button class="btn secondary" type="button" data-action="sign-out" ${state.busy ? 'disabled' : ''}> 403 Sign Out 404 </button> 405 </div> 406 <div class="inline-links"> 407 <a class="text-link" href="/">Back To Class Action</a> 408 </div> 409 </div> 410 `; 411} 412 413function renderRecoveryNotice() { 414 const accountEmail = state.session?.user?.email || state.email || 'this account'; 415 return ` 416 <div class="auth-shell"> 417 ${state.error ? `<div class="banner danger">${escapeHtml(state.error)}</div>` : ''} 418 ${state.notice ? `<div class="banner success">${escapeHtml(state.notice)}</div>` : ''} 419 <div> 420 <h2 class="section-title">${escapeHtml(state.recoveryTitle || 'Save your recovery key.')}</h2> 421 <p class="section-copy">${escapeHtml(state.recoveryCopy || 'Save this recovery key before continuing to Class Action.')}</p> 422 </div> 423 <div class="banner warn">Class Action does not keep a plaintext copy of this Recovery Key for you. Store it in a password manager or another safe place before continuing.</div> 424 <div class="field"> 425 <label for="loginRecoveryKeyDisplay">Recovery Key</label> 426 <input id="loginRecoveryKeyDisplay" type="text" readonly value="${escapeHtml(state.recoveryKey)}" style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em;"> 427 </div> 428 <div class="actions"> 429 <button class="btn secondary" type="button" data-action="copy-recovery-key">Copy</button> 430 <button class="btn" type="button" data-action="continue-after-recovery">Continue to Class Action</button> 431 </div> 432 <p class="section-copy">This Recovery Key is for ${escapeHtml(accountEmail)}.</p> 433 <div class="inline-links"> 434 <button class="text-link" type="button" data-action="sign-out">Sign Out</button> 435 </div> 436 </div> 437 `; 438} 439 440function renderRecoveryReconnect() { 441 return ` 442 <div class="auth-shell"> 443 ${state.error ? `<div class="banner danger">${escapeHtml(state.error)}</div>` : ''} 444 ${state.notice ? `<div class="banner success">${escapeHtml(state.notice)}</div>` : ''} 445 <div> 446 <h2 class="section-title">Reconnect encrypted courses</h2> 447 <p class="section-copy">Your password changed, so Class Action needs your Recovery Key once before it can reopen your encrypted cloud courses.</p> 448 </div> 449 <form class="auth-form" data-action="recovery-reconnect-submit"> 450 <div class="field"> 451 <label for="loginRecoveryInput">Recovery Key</label> 452 <input id="loginRecoveryInput" name="recovery_key" type="text" autocomplete="one-time-code" placeholder="Enter your Recovery Key" value="${escapeHtml(state.recoveryInput)}" data-action="input" data-field="recoveryInput"> 453 </div> 454 <div class="actions"> 455 <button class="btn" type="submit" ${state.busy ? 'disabled' : ''}> 456 ${state.busy === 'recovery' ? 'Reconnecting...' : 'Use Recovery Key'} 457 </button> 458 <button class="btn secondary" type="button" data-action="sign-out" ${state.busy ? 'disabled' : ''}> 459 Sign Out 460 </button> 461 </div> 462 </form> 463 </div> 464 `; 465} 466 467function renderPasskeySetupOffer() { 468 const isBusy = state.busy === 'passkey-setup'; 469 return ` 470 <div class="auth-shell"> 471 <div> 472 <h2 class="section-title">Enable Face ID unlock?</h2> 473 <p class="section-copy">Skip your password next time by unlocking with Face ID. Your passkey syncs across your Ap
473ple devices via iCloud Keychain.</p> 474 </div> 475 <div class="actions" style="gap:12px;"> 476 <button class="btn" type="button" data-action="setup-passkey-accept" ${isBusy ? 'disabled' : ''}> 477 ${isBusy ? '<i class="fas fa-spinner fa-spin"></i> Setting up...' : '<i class="fas fa-fingerprint"></i> Enable Face ID'} 478 </button> 479 <button class="btn secondary" type="button" data-action="setup-passkey-skip" ${isBusy ? 'disabled' : ''}> 480 Skip for now 481 </button> 482 </div> 483 <p class="section-copy" style="font-size:0.78rem; color:var(--ink-muted); margin-top:8px;">You can always enable this later in Settings > Security.</p> 484 </div> 485 `; 486} 487 488function renderForm() { 489 const showPasskeyBtn = state.passkeyAvailable && state.session?.user?.id; 490 return ` 491 <div class="auth-shell"> 492 ${state.error ? `<div class="banner danger">${escapeHtml(state.error)}</div>` : ''} 493 ${state.notice ? `<div class="banner success">${escapeHtml(state.notice)}</div>` : ''} 494 ${showPasskeyBtn ? ` 495 <div style="text-align:center; margin-bottom:8px;"> 496 <button class="btn" type="button" data-action="passkey-unlock" ${state.busy ? 'disabled' : ''} style="width:100%; padding:14px 20px; font-size:0.95rem;"> 497 ${state.busy === 'passkey' ? '<i class="fas fa-spinner fa-spin"></i> Unlocking...' : '<i class="fas fa-fingerprint"></i> Unlock with Face ID'} 498 </button> 499 <div style="display:flex; align-items:center; gap:12px; margin:16px 0 8px;"> 500 <hr style="flex:1; border:none; border-top:1px solid var(--border);"> 501 <span style="font-size:0.78rem; color:var(--ink-muted); text-transform:uppercase; letter-spacing:0.06em;">or sign in with password</span> 502 <hr style="flex:1; border:none; border-top:1px solid var(--border);"> 503 </div> 504 </div> 505 ` : ''} 506 <div> 507 <h2 class="section-title">Login</h2> 508 <p class="section-copy">Use your approved Class Action account to open Class Action online.</p> 509 </div> 510 <form class="auth-form" data-action="login-submit"> 511 <div class="form-grid"> 512 <div class="field"> 513 <label for="loginEmail">Email</label> 514 <input id="loginEmail" name="email" type="email" autocomplete="username" placeholder="[email protected]" value="${escapeHtml(state.email)}" data-action="input" data-field="email"> 515 </div> 516 <div class="field"> 517 <label for="loginPassword">Password</label> 518 <input id="loginPassword" name="password" type="password" autocomplete="current-password" data-password-toggle="true" placeholder="Password" value="${escapeHtml(state.password)}" data-action="input" data-field="password"> 519 </div> 520 </div> 521 <div class="device-row"> 522 <div class="device-copy"> 523 <strong>Shared device</strong> 524 <p>Use session-only login on a podium computer or borrowed machine.</p> 525 </div> 526 <label class="switch"> 527 <input type="checkbox" ${state.sharedDevice ? 'checked' : ''} data-action="shared-device-toggle"> 528 <span class="switch-track"></span> 529 <span class="switch-label">${state.sharedDevice ? 'On' : 'Off'}</span> 530 </label> 531 </div> 532 <div class="actions"> 533 <button class="btn" type="submit" ${state.busy ? 'disabled' : ''}> 534 ${state.busy === 'signin' ? 'Logging In...' : 'Login'} 535 </button> 536 </div> 537 </form> 538 <div class="inline-links"> 539 <button class="text-link" type="button" data-action="open-request-access" ${state.busy ? 'disabled' : ''}>Request Access</button> 540 <button class="text-link" type="button" data-action="send-password-reset" ${state.busy ? 'disabled' : ''}>Forgot Password</button> 541 </div> 542 </div> 543 `; 544} 545 546function renderMfaChallenge() { 547 return ` 548 <div class="auth-shell"> 549 ${state.error ? `<div class="banner danger">${escapeHtml(state.error)}</div>` : ''} 550 <div> 551 <h2 class="section-title">Two-Factor Authentication</h2> 552 <p class="section-copy">Enter the 6-digit code from your authenticator app.</p> 553 </div> 554 <form class="auth-form" data-action="mfa-submit"> 555 <div class="form-grid"> 556 <div class="field"> 557 <label for="mfaCodeInput">6-digit code</label> 558 <input id="mfaCodeInput" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" value="${escapeHtml(state.mfaCode)}" data-action="input" data-field="mfaCode" style="max-width:220px; font-size:1.4rem; letter-spacing:0.2em; text-align:center;"> 559 </div> 560 </div> 561 <div class="actions"> 562 <button class="btn" type="submit" ${state.busy ? 'disabled' : ''}> 563 ${state.busy === 'mfa' ? 'Verifying...' : 'Verify'} 564 </button> 565 </div> 566 </form> 567 <div class="inline-links"> 568 <button class="text-link" type="button" data-action="cancel-mfa" ${state.busy ? 'disabled' : ''}>Back to Login</button> 569 </div> 570 </div> 571 `; 572} 573 574function renderApp() { 575 const mount = document.getElementById('app'); 576 if (!mount) return; 577 if (state.booting) { 578 mount.innerHTML = renderLoading(); 579 return; 580 } 581 if (state.recoveryReconnect) { 582 mount.innerHTML = renderRecoveryReconnect(); 583 const reconnectInput = document.getElementById('loginRecoveryInput'); 584 if (reconnectInput) reconnectInput.focus(); 585 return; 586 } 587 if (state.recoveryKey) { 588 mount.innerHTML = renderRecoveryNotice(); 589 const recoveryInput = document.getElementById('loginRecoveryKeyDisplay'); 590 if (recoveryInput) { 591 recoveryInput.focus(); 592 recoveryInput.select(); 593 } 594 return; 595 } 596 if (state.passkeySetupOffered) { 597 mount.innerHTML = renderPasskeySetupOffer(); 598 return; 599 } 600 if (state.mfaRequired) {
601 mount.innerHTML = renderMfaChallenge(); 602 const codeInput = document.getElementById('mfaCodeInput'); 603 if (codeInput) codeInput.focus(); 604 return; 605 } 606 mount.innerHTML = state.pending ? renderPending() : renderForm(); 607} 608 609function handleLoginSubmit(event) { 610 event.preventDefault(); 611 signIn(); 612} 613 614function handleRecoveryReconnectSubmit(event) { 615 event.preventDefault(); 616 reconnectEncryptedCourses(); 617} 618 619 620async function copyRecoveryKey() { 621 const recoveryKey = state.recoveryKey || ''; 622 if (!recoveryKey) return; 623 const input = document.getElementById('loginRecoveryKeyDisplay'); 624 try { 625 if (navigator.clipboard?.writeText) { 626 await navigator.clipboard.writeText(recoveryKey); 627 } else if (input) { 628 input.focus(); 629 input.select(); 630 document.execCommand?.('copy'); 631 } 632 state.notice = 'Recovery Key copied.'; 633 } catch (_) { 634 if (input) { 635 input.focus(); 636 input.select(); 637 } 638 state.notice = 'Select and copy the Recovery Key before continuing.'; 639 } 640 renderApp(); 641} 642 643function continueAfterRecoverySetup() { 644 const wasReconnect = state.recoveryTitle.toLowerCase().includes('replacement'); 645 clearWorkspaceFlowState(); 646 state.notice = wasReconnect ? 'Encrypted courses reconnected. Opening Class Action now...' : 'Opening Class Action now...'; 647 renderApp(); 648 window.setTimeout(() => { 649 redirectToHostedWorkspace(); 650 }, 250); 651} 652 653// Post-password-reset reconnect flow for accounts with encrypted courses. 654// Sequence: 655// 1. Unlock workspace key with the (still-valid) recovery key 656// 2. Re-wrap the key with the new account password 657// 3. Generate a fresh recovery key (old one is now consumed/invalid) 658// 4. Write all six wrapper fields back to workspace_keys 659// 5. Stage handoff + persist so the redirect into the app is silent 660// 6. Show the new recovery key before redirecting 661// *** The old recovery key is invalidated after step 4. The user MUST 662// save the new recovery key shown in step 6 before navigating away. *** 663async function reconnectEncryptedCourses() { 664 const recoveryKey = state.recoveryInput.trim(); 665 const session = state.session || getStoredCloudSession().session; 666 const password = state.pendingPassword; 667 const storageMode = state.pendingStorageMode || getStoredCloudSession().storage || 'local'; 668 669 if (!session?.access_token || !session?.user?.id || !password) { 670 state.error = 'This reconnect session expired. Sign in again to continue.'; 671 state.recoveryReconnect = false; 672 renderApp(); 673 return; 674 } 675 676 if (!recoveryKey) { 677 state.error = 'Enter your Recovery Key first.'; 678 renderApp(); 679 return; 680 } 681 682 state.busy = 'recovery'; 683 state.error = ''; 684 state.notice = ''; 685 renderApp(); 686 687 try { 688 const record = await fetchWorkspaceKeyRecord(session.access_token, session.user.id); 689 if (!hasHostedWorkspaceWrappers(record)) { 690 throw new Error('This account is not ready for Recovery Key reconnect.'); 691 } 692 693 const rawWorkspaceKey = await unlockWorkspaceKeyFromRecoveryKey(record, recoveryKey); 694 const replacementRecoveryKey = generateRecoveryKey(); 695 await persistWorkspaceKeyRecord( 696 session.access_token, 697 session.user.id, 698 await buildWrappedWorkspaceKeyRecord(rawWorkspaceKey, password, replacementRecoveryKey) 699 ); 700 701 const keyB64 = bytesToBase64(rawWorkspaceKey); 702 stageWorkspaceKeyHandoff(session.user.id, keyB64); 703 persistUnlockedWorkspaceKeyPayload(session.user.id, keyB64, storageMode); 704 705 state.recoveryReconnect = false; 706 state.recoveryInput = ''; 707 state.recoveryKey = replacementRecoveryKey; 708 state.recoveryTitle = 'Save your replacement recovery key.'; 709 state.recoveryCopy = 'Your encrypted cloud courses are reconnected now. Class Action rotated the Recovery Key, so replace the old key with this new one before continuing.'; 710 state.notice = ''; 711 } catch (error) { 712 state.error = error.message || 'Could not reconnect your encrypted courses.'; 713 } finally { 714 state.busy = ''; 715 renderApp(); 716 } 717} 718 719async function completeApprovedLogin(session, password, storageMode) { 720 const workspaceReady = isWebCryptoReady() 721 ? await primeWorkspaceKeyHandoff(session, password, storageMode) 722 : { status: 'ready' }; 723 724 if (workspaceReady?.status === 'ready') { 725 redirectToHostedWorkspace(); 726 return; 727 } 728 if (workspaceReady?.status === 'setup' && workspaceReady?.recoveryKey) { 729 state.recoveryKey = workspaceReady.recoveryKey; 730 state.recoveryTitle = workspaceReady.title || 'Save your recovery key.'; 731 state.recoveryCopy = workspaceReady.copy || 'Save this recovery key before continuing to Class Action.'; 732 return; 733 } 734 if (workspaceReady?.status === 'recovery_required') { 735 state.recoveryReconnect = true; 736 state.pendingPassword = password; 737 state.pendingStorageMode = storageMode; 738 state.notice = ''; 739 state.error = ''; 740 return; 741 } 742 743 throw new Error('We could not prepare your encrypted Class Action workspace. Please try again.'); 744} 745 746async function signIn() { 747 const email = state.email.trim(); 748 const password = state.password; 749 const storageMode = state.sharedDevice ? 'session' : 'local'; 750 751 if (!email || !password) { 752 state.error = 'Enter your email and password first.'; 753 renderApp(); 754 return; 755 } 756 757 state.busy = 'signin'; 758 state.error = ''; 759 state.notice = ''; 760 clearWorkspaceFlowState(); 761 renderApp(); 762 763 try { 764 const result = await supabaseAuthRequest('/auth/v1/token?grant_type=password', { 765 method: 'POST', 766 body: { email, password } 767 }); 768 769 state.session = result; 770 771 // Check if user has MFA enabled 772 let mfaStatus = null; 773 try { mfaStatus = await getMfaStatus(result); } catch (_) {} 774 if (mfaStatus?.requiresChallenge && mfaStatus?.primaryFactor?.id) { 775 // Password succeeded but MFA is required â stash password and show TOTP screen 776 state.mfaRequired = true; 777 state.mfaFactorId = mfaStatus.primaryFactor.id; 778 state.mfaCode = ''; 779 state.mfaPassword = password; 780 state.mfaStorageMode = storageMode; 781 state.password = ''; 782 state.busy = ''; 783 renderApp(); 784 return; 785 } 786 787 saveCloudSession(result, storageMode); 788 const profile = await fetchCloudProfile(result); 789 state.profile = profile; 790 791 if (profile?.approved) { 792 state.password = ''; 793 await completeApprovedLogin(result, password, storageMode); 794 return; 795 } 796 797 state.password = ''; 798 state.pending = true; 799 state.notice = 'Signed in. This account is still waiting for approval.'; 800 } catch (error) { 801 state.error = getFriendlyCloudAuthErrorMessage(error); 802 } finally { 803 if (!_redirecting) { state.busy = ''; renderApp(); } 804 } 805} 806 807async function verifyMfaCode() { 808 const code = state.mfaCode.trim(); 809 if (!code || code.length < 6) { 810 state.error = 'Enter the 6-digit code from your authenticator app.'; 811 renderApp(); 812 return; 813 } 814 815 state.busy = 'mfa'; 816 state.error = ''; 817 renderApp(); 818 819 try { 820 const session = state.session; 821 const challenge = await createMfaChallenge(session, state.mfaFactorId); 822 const challengeId = challenge?.id || challenge?.challenge_id || ''; 823 const verifiedSession = await verifyMfaChallenge(session, state.mfaFactorId, challengeId, code); 824 825 // Upgrade session to AAL2 826 if (verifiedSession?.access_token) { 827 saveCloudSession(verifiedSession, state.mfaStorageMode); 828 state.session = verifiedSession; 829 } 830 831 // Now continue the normal login flow with the stashed password 832 const password = state.mfaPassword; 833 const storageMode = state.mfaStorageMode; 834 state.mfaRequired = false;
835 state.mfaFactorId = ''; 836 state.mfaCode = ''; 837 state.mfaPassword = ''; 838 839 const profile = await fetchCloudProfile(state.session); 840 state.profile = profile; 841 842 if (profile?.approved) { 843 await completeApprovedLogin(state.session, password, storageMode); 844 return; 845 } 846 847 state.pending = true; 848 state.notice = 'Signed in. This account is still waiting for approval.'; 849 } catch (error) { 850 state.error = error?.message || 'Invalid code. Try again.'; 851 } finally { 852 if (!_redirecting) { state.busy = ''; renderApp(); } 853 } 854} 855 856function cancelMfa() { 857 state.mfaRequired = false; 858 state.mfaFactorId = ''; 859 state.mfaCode = ''; 860 state.mfaPassword = ''; 861 state.session = null; 862 clearCloudSession(); 863 renderApp(); 864} 865 866async function sendPasswordReset() { 867 const email = state.email.trim(); 868 if (!email) { 869 state.error = 'Enter your email first, then request the reset link.'; 870 renderApp(); 871 return; 872 } 873 874 state.busy = 'reset'; 875 state.error = ''; 876 state.notice = ''; 877 renderApp(); 878 879 try { 880 await supabaseAuthRequest('/auth/v1/recover', { 881 method: 'POST', 882 body: { 883 email, 884 redirect_to: PASSWORD_RECOVERY_URL 885 } 886 }); 887 888 state.notice = `Password reset email sent to ${email}.`; 889 } catch (error) { 890 state.error = getFriendlyCloudAuthErrorMessage(error, 'reset'); 891 } finally { 892 state.busy = ''; 893 renderApp(); 894 } 895} 896 897async function refreshAccess() { 898 const session = await ensureCloudSession(); 899 if (!session?.access_token) { 900 state.pending = false; 901 state.session = null; 902 renderApp(); 903 return; 904 } 905 906 state.session = session; 907 state.busy = 'refresh'; 908 state.error = ''; 909 state.notice = ''; 910 renderApp(); 911 912 try { 913 const profile = await fetchCloudProfile(session); 914 state.profile = profile; 915 if (profile?.approved) { 916 state.pending = false; 917 state.notice = 'Access approved. Enter your password to continue to Class Action.'; 918 state.password = ''; 919 return; 920 } 921 state.notice = 'Still waiting for approval.'; 922 } catch (error) { 923 state.error = getFriendlyCloudAuthErrorMessage(error); 924 } finally { 925 state.busy = ''; 926 renderApp(); 927 } 928} 929 930function signOut() { 931 const email = state.session?.user?.email || state.email; 932 clearCloudSession(); 933 state.email = email || ''; 934 state.password = ''; 935 state.sharedDevice = false; 936 state.passkeyAvailable = false; 937 state.passkeySetupOffered = false; 938 state.error = ''; 939 state.notice = 'Signed out.'; 940 state.pending = false; 941 renderApp(); 942} 943 944async function bootstrapLoginPage() { 945 clearExpiredWorkspaceKeyHandoff(); 946 const params = new URLSearchParams(window.location.search); 947 const email = params.get('email'); 948 if (email) state.email = email; 949 950 const stored = getStoredCloudSession(); 951 if (!stored.session) { 952 state.booting = false; 953 renderApp(); 954 return; 955 } 956 957 state.session = stored.session; 958 state.email = stored.session.user?.email || state.email; 959 state.profile = getStoredCloudProfile().profile; 960 state.pending = state.profile?.approved === false; 961 // Face ID / passkey unlock disabled. 962 state.passkeyAvailable = false; 963 964 state.notice = state.pending ? '' : 'Enter your password to continue to Class Action.'; 965 state.booting = false; 966 renderApp(); 967 968 if (state.pending) { 969 fetchCloudProfile(stored.session).then((profile) => { 970 state.profile = profile; 971 const pending = profile?.approved === false; 972 state.pending = pending; 973 state.notice = pending ? '' : 'Enter your password to continue to Class Action.'; 974 renderApp(); 975 }).catch(() => {}); 976 } 977} 978 979// ------------------------------------------------------------------------- 980// Event delegation â replaces window.* exports and inline event handlers. 981// data-action attributes on elements drive all UI interactions. 982// ------------------------------------------------------------------------- 983document.addEventListener('click', (e) => { 984 const el = e.target.closest('[data-action]'); 985 if (!el) return; 986 switch (el.dataset.action) { 987 case 'refresh-access': refreshAccess(); break; 988 case 'sign-out': signOut(); break; 989 case 'copy-recovery-key': copyRecoveryKey(); break; 990 case 'continue-after-recovery': continueAfterRecoverySetup(); break; 991 case 'setup-passkey-accept': setupPasskeyAccept(); break; 992 case 'setup-passkey-skip': setupPasskeySkip(); break; 993 case 'passkey-unlock': passkeyUnlock(); break; 994 case 'open-request-access': openRequestAccessPage(); break; 995 case 'send-password-reset': sendPasswordReset(); break; 996 case 'cancel-mfa': cancelMfa(); break; 997 } 998}); 999 1000document.addEventListener('submit', (e) => { 1001 const form = e.target.closest('[data-action]'); 1002 if (!form) return; 1003 e.preventDefault(); 1004 switch (form.dataset.action) { 1005 case 'login-submit': signIn(); break; 1006 case 'recovery-reconnect-submit': reconnectEncryptedCourses(); break; 1007 case 'mfa-submit': verifyMfaCode(); break; 1008 } 1009}); 1010 1011document.addEventListener('input', (e) => { 1012 const el = e.target.closest('[data-field]');
1013 if (el) handleInput(el.dataset.field, el.value); 1014}); 1015 1016document.addEventListener('change', (e) => { 1017 const el = e.target.closest('[data-action="shared-device-toggle"]'); 1018 if (el) handleSharedDeviceToggle(el.checked); 1019}); 1020 1021document.addEventListener('DOMContentLoaded', () => { 1022 installPasswordToggleObserver(); 1023 bootstrapLoginPage().catch((error) => { 1024 clearCloudSession(); 1025 state.booting = false; 1026 state.error = getFriendlyCloudAuthErrorMessage(error); 1027 renderApp(); 1028 }); 1029});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.