PageSourceSearch

https://classaction.cv/login/login.js

js classaction.cv collected 2026-10-07 13:18:08 UTC 36,630 bytes, 1,029 lines download raw bytes

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, '&lt;')
94    .replace(/>/g, '&gt;')
95    .replace(/"/g, '&quot;')
96    .replace(/'/g, '&#39;');
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 &gt; 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.