KSvend Claude Opus 4.6 (1M context) commited on
Commit
fcb9daa
Β·
1 Parent(s): 6fc693d

feat: add login flow, history page, session management, and nav guard

Browse files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. frontend/js/app.js +187 -3
frontend/js/app.js CHANGED
@@ -3,7 +3,7 @@
3
  * Orchestrates page transitions and wires up all feature modules.
4
  */
5
 
6
- import { submitJob, getJob } from './api.js';
7
  import { initAoiMap, activateDrawRect, activateDrawPolygon, geocode, loadGeoJSON, initResultsMap } from './map.js';
8
  import { initIndicators, getSelectedIds } from './indicators.js';
9
  import { renderResults } from './results.js';
@@ -11,6 +11,7 @@ import { renderResults } from './results.js';
11
  /* ── Application State ───────────────────────────────────── */
12
 
13
  const state = {
 
14
  aoi: null, // { name, bbox }
15
  timeRange: null, // { start, end }
16
  indicators: [], // string[]
@@ -18,9 +19,30 @@ const state = {
18
  jobData: null, // full job response
19
  };
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  /* ── Router ──────────────────────────────────────────────── */
22
 
23
- const PAGES = ['landing', 'define-area', 'indicators', 'confirm', 'status', 'results'];
24
  let _currentPage = null;
25
  let _pollTimer = null;
26
 
@@ -30,6 +52,12 @@ function navigate(pageId) {
30
  _pollTimer = null;
31
  }
32
 
 
 
 
 
 
 
33
  // Hide all pages
34
  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
35
 
@@ -47,17 +75,84 @@ function navigate(pageId) {
47
 
48
  const pageSetup = {
49
  'landing': setupLanding,
 
50
  'define-area': setupDefineArea,
51
  'indicators': setupIndicators,
52
  'confirm': setupConfirm,
53
  'status': setupStatus,
54
  'results': setupResults,
 
55
  };
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  /* Landing ─────────────────────────────────────────────────── */
58
 
59
  function setupLanding() {
60
- document.getElementById('cta-start').addEventListener('click', () => navigate('define-area'), { once: true });
61
  }
62
 
63
  /* Define Area ─────────────────────────────────────────────── */
@@ -185,6 +280,13 @@ function setupConfirm() {
185
  document.getElementById('confirm-period-end').textContent = state.timeRange?.end || 'β€”';
186
  document.getElementById('confirm-indicators').textContent = state.indicators.length;
187
 
 
 
 
 
 
 
 
188
  const backBtn = document.getElementById('confirm-back-btn');
189
  const submitBtn = document.getElementById('confirm-submit-btn');
190
 
@@ -304,6 +406,86 @@ function setupResults() {
304
  }
305
  }
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  /* ── Steps Indicator ─────────────────────────────────────── */
308
 
309
  const STEP_PAGES = ['define-area', 'indicators', 'confirm'];
@@ -361,5 +543,7 @@ function _capFirst(s) {
361
  /* ── Bootstrap ───────────────────────────────────────────── */
362
 
363
  document.addEventListener('DOMContentLoaded', () => {
 
 
364
  navigate('landing');
365
  });
 
3
  * Orchestrates page transitions and wires up all feature modules.
4
  */
5
 
6
+ import { submitJob, getJob, requestMagicLink, verifyToken, listJobs } from './api.js';
7
  import { initAoiMap, activateDrawRect, activateDrawPolygon, geocode, loadGeoJSON, initResultsMap } from './map.js';
8
  import { initIndicators, getSelectedIds } from './indicators.js';
9
  import { renderResults } from './results.js';
 
11
  /* ── Application State ───────────────────────────────────── */
12
 
13
  const state = {
14
+ session: null, // { email, token }
15
  aoi: null, // { name, bbox }
16
  timeRange: null, // { start, end }
17
  indicators: [], // string[]
 
19
  jobData: null, // full job response
20
  };
21
 
22
+ /* ── Session Persistence ────────────────────────────────── */
23
+
24
+ const SESSION_KEY = 'aperture_session';
25
+
26
+ function saveSession(email, token) {
27
+ state.session = { email, token };
28
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(state.session));
29
+ }
30
+
31
+ function loadSession() {
32
+ const raw = sessionStorage.getItem(SESSION_KEY);
33
+ if (raw) {
34
+ try { state.session = JSON.parse(raw); } catch { state.session = null; }
35
+ }
36
+ }
37
+
38
+ function clearSession() {
39
+ state.session = null;
40
+ sessionStorage.removeItem(SESSION_KEY);
41
+ }
42
+
43
  /* ── Router ──────────────────────────────────────────────── */
44
 
45
+ const PAGES = ['landing', 'login', 'define-area', 'indicators', 'confirm', 'status', 'results', 'history'];
46
  let _currentPage = null;
47
  let _pollTimer = null;
48
 
 
52
  _pollTimer = null;
53
  }
54
 
55
+ // Auth guard β€” redirect to login if not authenticated
56
+ const AUTH_REQUIRED = ['define-area', 'indicators', 'confirm', 'status', 'results', 'history'];
57
+ if (AUTH_REQUIRED.includes(pageId) && !state.session) {
58
+ pageId = 'login';
59
+ }
60
+
61
  // Hide all pages
62
  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
63
 
 
75
 
76
  const pageSetup = {
77
  'landing': setupLanding,
78
+ 'login': setupLogin,
79
  'define-area': setupDefineArea,
80
  'indicators': setupIndicators,
81
  'confirm': setupConfirm,
82
  'status': setupStatus,
83
  'results': setupResults,
84
+ 'history': setupHistory,
85
  };
86
 
87
+ /* ── Login Page ─────────────────────────────────────────── */
88
+
89
+ let _loginInit = false;
90
+
91
+ function setupLogin() {
92
+ if (_loginInit) return;
93
+ _loginInit = true;
94
+
95
+ const emailStep = document.getElementById('login-email-step');
96
+ const tokenStep = document.getElementById('login-token-step');
97
+ const emailInput = document.getElementById('login-email');
98
+ const tokenInput = document.getElementById('login-token');
99
+ const sendBtn = document.getElementById('login-send-btn');
100
+ const verifyBtn = document.getElementById('login-verify-btn');
101
+ const backLink = document.getElementById('login-back-link');
102
+ const heading = document.getElementById('login-token-heading');
103
+
104
+ let _loginEmail = '';
105
+
106
+ sendBtn.addEventListener('click', async () => {
107
+ const email = emailInput.value.trim();
108
+ if (!email) return;
109
+ sendBtn.disabled = true;
110
+ try {
111
+ const resp = await requestMagicLink(email);
112
+ _loginEmail = email;
113
+ emailStep.style.display = 'none';
114
+ tokenStep.style.display = 'block';
115
+ // Demo mode: auto-fill token
116
+ if (resp.demo_token) {
117
+ heading.textContent = 'Demo code';
118
+ tokenInput.value = resp.demo_token;
119
+ } else {
120
+ heading.textContent = 'Check your email';
121
+ }
122
+ } catch (err) {
123
+ showError(err.message || 'Failed to send magic link');
124
+ } finally {
125
+ sendBtn.disabled = false;
126
+ }
127
+ });
128
+
129
+ verifyBtn.addEventListener('click', async () => {
130
+ const token = tokenInput.value.trim();
131
+ if (!token) return;
132
+ verifyBtn.disabled = true;
133
+ try {
134
+ await verifyToken(_loginEmail, token);
135
+ saveSession(_loginEmail, token);
136
+ navigate('history');
137
+ } catch (err) {
138
+ showError(err.message || 'Invalid or expired code');
139
+ } finally {
140
+ verifyBtn.disabled = false;
141
+ }
142
+ });
143
+
144
+ backLink.addEventListener('click', (e) => {
145
+ e.preventDefault();
146
+ tokenStep.style.display = 'none';
147
+ emailStep.style.display = 'block';
148
+ tokenInput.value = '';
149
+ });
150
+ }
151
+
152
  /* Landing ─────────────────────────────────────────────────── */
153
 
154
  function setupLanding() {
155
+ document.getElementById('cta-start').addEventListener('click', () => navigate('login'), { once: true });
156
  }
157
 
158
  /* Define Area ─────────────────────────────────────────────── */
 
280
  document.getElementById('confirm-period-end').textContent = state.timeRange?.end || 'β€”';
281
  document.getElementById('confirm-indicators').textContent = state.indicators.length;
282
 
283
+ // Auto-fill email from session
284
+ const emailInput = document.getElementById('confirm-email');
285
+ if (state.session && state.session.email) {
286
+ emailInput.value = state.session.email;
287
+ emailInput.readOnly = true;
288
+ }
289
+
290
  const backBtn = document.getElementById('confirm-back-btn');
291
  const submitBtn = document.getElementById('confirm-submit-btn');
292
 
 
406
  }
407
  }
408
 
409
+ /* ── History Page ────────────────────────────────────────── */
410
+
411
+ let _historyInit = false;
412
+
413
+ function setupHistory() {
414
+ const listEl = document.getElementById('history-list');
415
+ const emptyEl = document.getElementById('history-empty');
416
+
417
+ // Wire up buttons (once)
418
+ if (!_historyInit) {
419
+ _historyInit = true;
420
+ document.getElementById('history-new-btn').addEventListener('click', () => navigate('define-area'));
421
+ document.getElementById('history-empty-cta').addEventListener('click', () => navigate('define-area'));
422
+ document.getElementById('history-signout').addEventListener('click', (e) => {
423
+ e.preventDefault();
424
+ clearSession();
425
+ navigate('landing');
426
+ });
427
+ }
428
+
429
+ // Fetch and render jobs
430
+ listJobs().then(jobs => {
431
+ listEl.innerHTML = '';
432
+ if (jobs.length === 0) {
433
+ listEl.style.display = 'none';
434
+ emptyEl.style.display = 'flex';
435
+ return;
436
+ }
437
+ listEl.style.display = 'grid';
438
+ emptyEl.style.display = 'none';
439
+ for (const job of jobs) {
440
+ const card = document.createElement('div');
441
+ card.className = 'history-card';
442
+ card.setAttribute('role', 'button');
443
+ card.setAttribute('tabindex', '0');
444
+ const dateStr = new Date(job.created_at).toLocaleDateString(undefined, {
445
+ year: 'numeric', month: 'short', day: 'numeric',
446
+ });
447
+ card.innerHTML = `
448
+ <div class="history-card-name">${_esc(job.aoi_name || 'Untitled')}</div>
449
+ <div class="history-card-meta">
450
+ <span class="history-card-date">${_esc(dateStr)}</span>
451
+ <span><span class="badge badge-${job.status}">${_esc(job.status)}</span></span>
452
+ </div>
453
+ <div class="history-card-indicators">${job.indicator_count} indicator${job.indicator_count !== 1 ? 's' : ''}</div>
454
+ `;
455
+ card.addEventListener('click', () => {
456
+ state.jobId = job.id;
457
+ navigate(job.status === 'complete' ? 'results' : 'status');
458
+ });
459
+ card.addEventListener('keydown', (e) => {
460
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
461
+ });
462
+ listEl.appendChild(card);
463
+ }
464
+ }).catch(err => {
465
+ showError('Failed to load analyses');
466
+ });
467
+ }
468
+
469
+ /* ── Global Topbar Wiring ───────────────────────────────── */
470
+
471
+ function wireTopbarLinks() {
472
+ document.querySelectorAll('.nav-my-analyses').forEach(link => {
473
+ link.addEventListener('click', (e) => { e.preventDefault(); navigate('history'); });
474
+ });
475
+ document.querySelectorAll('.nav-signout').forEach(link => {
476
+ link.addEventListener('click', (e) => {
477
+ e.preventDefault();
478
+ clearSession();
479
+ navigate('landing');
480
+ });
481
+ });
482
+ // Results page "New analysis" button
483
+ const resultsNewBtn = document.getElementById('results-new-btn');
484
+ if (resultsNewBtn) {
485
+ resultsNewBtn.addEventListener('click', () => navigate('define-area'));
486
+ }
487
+ }
488
+
489
  /* ── Steps Indicator ─────────────────────────────────────── */
490
 
491
  const STEP_PAGES = ['define-area', 'indicators', 'confirm'];
 
543
  /* ── Bootstrap ───────────────────────────────────────────── */
544
 
545
  document.addEventListener('DOMContentLoaded', () => {
546
+ loadSession();
547
+ wireTopbarLinks();
548
  navigate('landing');
549
  });