@javagt/express-easy-auth 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ import { AuthClient } from '/auth-sdk.js';
2
+
3
+ /* ─────────────────────────────────────────────────────────────────────
4
+ Auth Server — Frontend App
5
+ ───────────────────────────────────────────────────────────────────── */
6
+
7
+ const auth = new AuthClient();
8
+
9
+ // ─── UTILS ───────────────────────────────────────────────────────────────────
10
+
11
+ const $ = id => document.getElementById(id);
12
+
13
+ const toast = (msg, type = 'success') => {
14
+ const el = document.createElement('div');
15
+ el.className = `toast${type !== 'success' ? ` ${type}` : ''}`;
16
+ el.textContent = msg;
17
+ const container = $('toast-container');
18
+ if (container) {
19
+ container.appendChild(el);
20
+ setTimeout(() => el.remove(), 3800);
21
+ } else {
22
+ console.warn('toast-container not found');
23
+ }
24
+ };
25
+
26
+ // ─── THEME MANAGER ───────────────────────────────────────────────────────────
27
+
28
+ function initTheme() {
29
+ const savedTheme = localStorage.getItem('theme');
30
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
31
+
32
+ if (savedTheme) {
33
+ document.documentElement.setAttribute('data-theme', savedTheme);
34
+ } else if (systemDark) {
35
+ // We don't set the attribute yet so it respects system updates
36
+ }
37
+ }
38
+
39
+ function toggleTheme() {
40
+ const current = document.documentElement.getAttribute('data-theme');
41
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
42
+
43
+ let next;
44
+ if (current === 'dark') next = 'light';
45
+ else if (current === 'light') next = 'dark';
46
+ else next = systemDark ? 'light' : 'dark';
47
+
48
+ document.documentElement.setAttribute('data-theme', next);
49
+ localStorage.setItem('theme', next);
50
+ toast(`Switched to ${next} mode`);
51
+ }
52
+
53
+ initTheme();
54
+
55
+ /**
56
+ * Wrapper for the SDK's reportError to also show a toast
57
+ */
58
+ async function reportError(error, context = {}) {
59
+ console.error(`[error] ${error.message || error}`, { error, context });
60
+ auth.reportError(error, context);
61
+ toast(error.message || String(error), 'error');
62
+ }
63
+
64
+ // Global error handling
65
+ window.onerror = (message, source, lineno, colno, error) => {
66
+ reportError(error || message, { source, lineno, colno, type: 'global' });
67
+ };
68
+
69
+ window.onunhandledrejection = (event) => {
70
+ reportError(event.reason, { type: 'promise_rejection' });
71
+ };
72
+
73
+ function formatDate(ts) {
74
+ if (!ts) return 'Never';
75
+ return new Date(ts).toLocaleDateString('en-NZ', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
76
+ }
77
+
78
+ // ─── STATE ────────────────────────────────────────────────────────────────────
79
+
80
+ const state = {
81
+ user: null,
82
+ security: null,
83
+ has2FA: false,
84
+ };
85
+
86
+ // ─── NAVIGATION ──────────────────────────────────────────────────────────────
87
+
88
+ function showView(id) {
89
+ const authViews = ['view-login', 'view-register', 'view-reset-request', 'view-reset-confirm'];
90
+ const isAuthView = authViews.includes(id);
91
+
92
+ $('view-auth').style.display = 'none';
93
+ $('view-dashboard').style.display = 'none';
94
+ $('view-profile').style.display = 'none';
95
+ $('dash-header').style.display = 'none';
96
+
97
+ if (isAuthView) {
98
+ $('view-auth').style.display = 'grid';
99
+ document.querySelectorAll('.auth-form').forEach(v => v.classList.remove('active'));
100
+ const view = $(id);
101
+ if (view) view.classList.add('active');
102
+ } else {
103
+ const view = $(id);
104
+ if (view) view.style.display = 'block';
105
+ if (id === 'view-dashboard' || id === 'view-profile') {
106
+ $('dash-header').style.display = 'flex';
107
+ }
108
+ }
109
+ }
110
+
111
+ // ─── CORE LOGIC ───────────────────────────────────────────────────────────────
112
+
113
+ async function refreshStatus() {
114
+ try {
115
+ const status = await auth.getStatus();
116
+ if (status.authenticated) {
117
+ state.user = status.user;
118
+ state.security = status.security;
119
+ state.has2FA = status.security?.has2FA;
120
+ } else {
121
+ state.user = null;
122
+ state.security = null;
123
+ state.has2FA = false;
124
+ }
125
+ } catch (e) {
126
+ state.user = null;
127
+ }
128
+ }
129
+
130
+ async function loadDashboard() {
131
+ if (!state.user) return showView('view-login');
132
+ showView('view-dashboard');
133
+
134
+ $('dashboard-user').innerHTML = `
135
+ <div class="security-block wide">
136
+ <div class="security-block-header">
137
+ <div>
138
+ <h3>Welcome back, ${state.user.username}</h3>
139
+ <p>${state.user.email}</p>
140
+ </div>
141
+ <div class="security-badge ${state.has2FA ? 'on' : ''}">
142
+ MFA ${state.has2FA ? 'ENABLED' : 'DISABLED'}
143
+ </div>
144
+ </div>
145
+ </div>
146
+ `;
147
+
148
+ await Promise.all([
149
+ loadSessions(),
150
+ loadPasskeys(),
151
+ loadApiKeys(),
152
+ loadSecurityTab()
153
+ ]);
154
+ }
155
+
156
+ async function loadSessions() {
157
+ const list = $('sessions-list');
158
+ list.innerHTML = 'Loading...';
159
+ try {
160
+ const { sessions } = await auth.request('/sessions');
161
+ list.innerHTML = sessions.map(s => `
162
+ <div class="session-item ${s.isCurrent ? 'current' : ''}">
163
+ <div class="sess-info">
164
+ <div class="sess-label">${s.isCurrent ? 'Current Session' : 'Active Session'}</div>
165
+ <div class="sess-meta">Started on ${formatDate(s.created_at)}</div>
166
+ </div>
167
+ ${!s.isCurrent ? `<button class="btn-xs btn-danger action-revoke-session" data-id="${s.id}">Revoke</button>` : ''}
168
+ </div>
169
+ `).join('');
170
+ } catch (e) {
171
+ list.innerHTML = 'Failed to load sessions';
172
+ }
173
+ }
174
+
175
+ async function loadPasskeys() {
176
+ const list = $('passkeys-list');
177
+ list.innerHTML = 'Loading...';
178
+ try {
179
+ const { passkeys } = await auth.listPasskeys();
180
+ if (!passkeys.length) {
181
+ list.innerHTML = '<div class="empty-msg">No passkeys found</div>';
182
+ } else {
183
+ list.innerHTML = passkeys.map(pk => `
184
+ <div class="passkey-item">
185
+ <div class="pk-info">
186
+ <div class="pk-name">${pk.name || 'Unnamed Device'}</div>
187
+ <div class="pk-meta">Registered ${formatDate(pk.created_at)}</div>
188
+ </div>
189
+ <button class="btn-xs btn-danger action-delete-passkey" data-id="${pk.id}">Remove</button>
190
+ </div>
191
+ `).join('');
192
+ }
193
+ auth.syncPasskeys(passkeys.map(pk => pk.credential_id));
194
+ } catch (e) {
195
+ list.innerHTML = 'Failed to load passkeys';
196
+ }
197
+ }
198
+
199
+ async function loadApiKeys() {
200
+ const list = $('key-list-container');
201
+ list.innerHTML = 'Loading...';
202
+ try {
203
+ const res = await auth.request('/api-keys');
204
+ if (!res.keys || !res.keys.length) {
205
+ list.innerHTML = '<div class="empty-msg">No API keys found</div>';
206
+ } else {
207
+ list.innerHTML = res.keys.map(k => `
208
+ <div class="api-key-item">
209
+ <div class="pk-info">
210
+ <div class="pk-name">${k.name}</div>
211
+ <div class="pk-meta">Permissions: ${k.permissions.join(', ')}</div>
212
+ <div class="pk-meta">Created: ${formatDate(k.created_at)}</div>
213
+ </div>
214
+ <button class="btn-xs btn-danger action-revoke-key" data-id="${k.id}">Revoke</button>
215
+ </div>
216
+ `).join('');
217
+ }
218
+ } catch (e) {
219
+ list.innerHTML = 'Failed to load API keys';
220
+ }
221
+ }
222
+
223
+ async function loadSecurityTab() {
224
+ const has2FA = state.has2FA;
225
+ $('btn-setup-2fa').style.display = has2FA ? 'none' : 'inline-block';
226
+ $('btn-confirm-2fa').style.display = 'none';
227
+ $('btn-confirm-disable-2fa').style.display = has2FA ? 'inline-block' : 'none';
228
+
229
+ $('totp-code').style.display = 'none';
230
+ $('totp-code').parentElement.style.display = 'none';
231
+ }
232
+
233
+ // ─── INITIALIZATION ──────────────────────────────────────────────────────────
234
+
235
+ async function init() {
236
+ await refreshStatus();
237
+ if (state.user) {
238
+ loadDashboard();
239
+ } else {
240
+ showView('view-login');
241
+ }
242
+ }
243
+
244
+ // Header Navigation
245
+ $('go-dashboard').addEventListener('click', () => loadDashboard());
246
+ $('go-profile').addEventListener('click', async () => {
247
+ showView('view-profile');
248
+ try {
249
+ const res = await fetch('/api/v1/profile/me');
250
+ const data = await res.json();
251
+ const p = data.profile || {};
252
+ $('prof-display-name').value = p.display_name || '';
253
+ $('prof-bio').value = p.bio || '';
254
+ $('prof-location').value = p.location || '';
255
+ $('prof-website').value = p.website || '';
256
+
257
+ const prefs = p.preferences || {};
258
+ $('prof-pref-theme').value = prefs.theme || 'system';
259
+ $('prof-pref-notifications').checked = !!prefs.notifications;
260
+ } catch (e) {
261
+ reportError(new Error('Failed to load profile'));
262
+ }
263
+ });
264
+ $('go-logout').addEventListener('click', async () => {
265
+ await auth.logout();
266
+ state.user = null;
267
+ showView('view-login');
268
+ toast('Logged out');
269
+ });
270
+
271
+ $('theme-toggle').addEventListener('click', () => toggleTheme());
272
+ $('theme-toggle-public').addEventListener('click', () => toggleTheme());
273
+
274
+ // Forms
275
+ $('form-login').addEventListener('submit', async (e) => {
276
+ e.preventDefault();
277
+ const username = $('login-username').value.trim();
278
+ const password = $('login-password').value;
279
+ const totp = $('login-totp').value.trim();
280
+
281
+ try {
282
+ await auth.login(username, password, totp);
283
+ await refreshStatus();
284
+ loadDashboard();
285
+ $('login-totp-field').style.display = 'none';
286
+ $('login-totp').value = '';
287
+ } catch (e) {
288
+ if (e.code === '2FA_REQUIRED') {
289
+ $('login-totp-field').style.display = 'block';
290
+ $('login-totp').focus();
291
+ }
292
+ reportError(e);
293
+ }
294
+ });
295
+
296
+ $('form-register').addEventListener('submit', async (e) => {
297
+ e.preventDefault();
298
+ const username = $('register-username').value.trim();
299
+ const email = $('register-email').value.trim();
300
+ const password = $('register-password').value;
301
+ try {
302
+ await auth.register(username, email, password);
303
+ toast('Registered! Please login.');
304
+ showView('view-login');
305
+ } catch (e) { reportError(e); }
306
+ });
307
+
308
+ $('btn-passkey-login').addEventListener('click', async () => {
309
+ try {
310
+ const username = $('login-username').value.trim() || undefined;
311
+ await auth.loginWithPasskey(username);
312
+ await refreshStatus();
313
+ loadDashboard();
314
+ toast('Logged in with passkey');
315
+ } catch (e) { if (e.name !== 'NotAllowedError') reportError(e); }
316
+ });
317
+
318
+ $('btn-add-passkey').addEventListener('click', async () => {
319
+ const name = prompt('Name for this passkey:', 'My Device');
320
+ if (!name) return;
321
+ try {
322
+ await auth.registerPasskey(name);
323
+ toast('Passkey registered');
324
+ loadPasskeys();
325
+ } catch (e) { if (e.name !== 'NotAllowedError') reportError(e); }
326
+ });
327
+
328
+ $('btn-create-key').addEventListener('click', async () => {
329
+ const name = $('api-key-name').value.trim();
330
+ if (!name) return toast('Name required', 'error');
331
+
332
+ const permsCheckboxes = document.querySelectorAll('input[name="api-perm"]:checked');
333
+ const permissions = Array.from(permsCheckboxes).map(cb => cb.value);
334
+
335
+ if (!permissions.length) return toast('At least one permission required', 'error');
336
+
337
+ try {
338
+ const res = await auth.request('/api-keys', { method: 'POST', body: { name, permissions } });
339
+ $('test-api-key').value = res.key;
340
+ alert('Your API Key (save it!): ' + res.key);
341
+ $('api-key-name').value = '';
342
+ loadApiKeys();
343
+ } catch (e) { reportError(e); }
344
+ });
345
+
346
+ // API Key Testing
347
+ let selectedMethod = 'GET';
348
+ document.querySelectorAll('.method-tab').forEach(tab => {
349
+ tab.addEventListener('click', () => {
350
+ document.querySelectorAll('.method-tab').forEach(t => t.classList.remove('active'));
351
+ tab.classList.add('active');
352
+ selectedMethod = tab.dataset.method;
353
+ });
354
+ });
355
+
356
+ $('btn-test-key').addEventListener('click', async () => {
357
+ const key = $('test-api-key').value.trim();
358
+ if (!key) return toast('API Key required', 'error');
359
+
360
+ const log = $('test-result-log');
361
+ const container = $('test-result-container');
362
+
363
+ container.style.display = 'block';
364
+ log.textContent = 'Testing...';
365
+ log.className = '';
366
+
367
+ try {
368
+ const res = await fetch('/api/public/data', {
369
+ method: selectedMethod,
370
+ headers: { 'X-API-Key': key }
371
+ });
372
+
373
+ const data = await res.json();
374
+ log.textContent = JSON.stringify(data, null, 2);
375
+
376
+ if (res.ok) {
377
+ log.classList.add('test-success');
378
+ toast('API Key valid!');
379
+ } else {
380
+ log.classList.add('test-error');
381
+ toast('Request failed: ' + (data.error || 'Unknown error'), 'error');
382
+ }
383
+ } catch (e) {
384
+ log.textContent = 'Connection Error: ' + e.message;
385
+ log.classList.add('test-error');
386
+ toast('Failed to reach API', 'error');
387
+ }
388
+ });
389
+
390
+ $('btn-setup-2fa').addEventListener('click', async () => {
391
+ try {
392
+ const data = await auth.setup2FA();
393
+ $('totp-setup-container').innerHTML = `
394
+ <div class="qr-setup">
395
+ <img src="${data.qrCode}" alt="QR Code">
396
+ <p>Secret: <code>${data.secret}</code></p>
397
+ <p class="form-hint" style="margin-top: 10px;">Scan this QR code and enter the verification code below.</p>
398
+ </div>
399
+ `;
400
+ $('btn-setup-2fa').style.display = 'none';
401
+ $('btn-confirm-2fa').style.display = 'inline-block';
402
+ $('totp-code').style.display = 'block';
403
+ $('totp-code').parentElement.style.display = 'block';
404
+ } catch (e) { reportError(e); }
405
+ });
406
+
407
+ $('btn-confirm-2fa').addEventListener('click', async () => {
408
+ const token = $('totp-code').value.trim();
409
+ try {
410
+ await auth.verify2FASetup(token);
411
+ toast('2FA Enabled');
412
+ $('totp-code').value = '';
413
+ await refreshStatus();
414
+ loadSecurityTab();
415
+ } catch (e) { reportError(e); }
416
+ });
417
+
418
+ $('btn-confirm-disable-2fa').addEventListener('click', async () => {
419
+ const password = prompt('Enter password to disable 2FA:');
420
+ if (!password) return;
421
+ try {
422
+ await auth.disable2FA(password);
423
+ toast('2FA Disabled');
424
+ $('totp-code').value = '';
425
+ await refreshStatus();
426
+ loadSecurityTab();
427
+ } catch (e) { reportError(e); }
428
+ });
429
+
430
+ $('btn-save-profile').addEventListener('click', async (e) => {
431
+ e.preventDefault();
432
+ const body = {
433
+ display_name: $('prof-display-name').value,
434
+ bio: $('prof-bio').value,
435
+ location: $('prof-location').value,
436
+ website: $('prof-website').value,
437
+ preferences: {
438
+ theme: $('prof-pref-theme').value,
439
+ notifications: $('prof-pref-notifications').checked
440
+ }
441
+ };
442
+ try {
443
+ const res = await fetch('/api/v1/profile/me', {
444
+ method: 'PATCH',
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: JSON.stringify(body)
447
+ });
448
+ if (!res.ok) throw new Error('Failed to save profile');
449
+ toast('Profile saved');
450
+ } catch (e) { reportError(e); }
451
+ });
452
+
453
+ $('btn-submit-reset-request').addEventListener('click', async (e) => {
454
+ e.preventDefault();
455
+ const username = $('reset-request-identifier').value.trim();
456
+ try {
457
+ const res = await auth.request('/password-reset/request', { method: 'POST', body: { username } });
458
+ toast('Code: ' + res.token);
459
+ showView('view-reset-confirm');
460
+ } catch (e) { reportError(e); }
461
+ });
462
+
463
+ $('btn-submit-reset-confirm').addEventListener('click', async (e) => {
464
+ e.preventDefault();
465
+ const token = $('reset-token-input').value.trim();
466
+ const newPassword = $('reset-new-password').value;
467
+ try {
468
+ await auth.request('/password-reset/reset', { method: 'POST', body: { token, newPassword } });
469
+ toast('Password reset success');
470
+ showView('view-login');
471
+ } catch (e) { reportError(e); }
472
+ });
473
+
474
+ // ─── EVENT DELEGATION ────────────────────────────────────────────────────────
475
+ document.body.addEventListener('click', async (e) => {
476
+ const btn = e.target.closest('button');
477
+ if (!btn) return;
478
+
479
+ const id = btn.dataset.id;
480
+
481
+ if (btn.classList.contains('action-revoke-session')) {
482
+ if (!confirm('Revoke this session?')) return;
483
+ try {
484
+ await auth.request('/sessions/' + id, { method: 'DELETE' });
485
+ toast('Session revoked');
486
+ loadSessions();
487
+ } catch (e) { reportError(e); }
488
+ }
489
+
490
+ if (btn.classList.contains('action-delete-passkey')) {
491
+ if (!confirm('Delete this passkey?')) return;
492
+ try {
493
+ await auth.deletePasskey(id);
494
+ toast('Passkey deleted');
495
+ loadPasskeys();
496
+ } catch (e) { reportError(e); }
497
+ }
498
+
499
+ if (btn.classList.contains('action-revoke-key')) {
500
+ if (!confirm('Revoke this API key?')) return;
501
+ try {
502
+ await auth.request('/api-keys/' + id, { method: 'DELETE' });
503
+ toast('API key revoked');
504
+ loadApiKeys();
505
+ } catch (e) { reportError(e); }
506
+ }
507
+ });
508
+
509
+ init();
510
+
511
+ // ─── MAILBOX ───────────────────────────────────────────────────────────────
512
+ async function loadMailbox() {
513
+ try {
514
+ const res = await fetch('/api/v1/test/mailbox');
515
+ const data = await res.json();
516
+ const body = $('mailbox-body');
517
+ if (!body) return;
518
+
519
+ if (!data.messages || !data.messages.length) {
520
+ body.innerHTML = '<div class="empty-msg">No messages yet...</div>';
521
+ return;
522
+ }
523
+
524
+ body.innerHTML = data.messages.map(m => `
525
+ <div class="mailbox-item">
526
+ <div class="msg-header">
527
+ <span class="msg-type">${m.type}</span>
528
+ <span class="msg-time">${formatDate(m.timestamp)}</span>
529
+ </div>
530
+ <div class="msg-subject">${m.subject}</div>
531
+ <div class="msg-body">${m.body}</div>
532
+ </div>
533
+ `).join('');
534
+ } catch (e) {
535
+ // Silently fail mailbox
536
+ }
537
+ }
538
+
539
+ setInterval(loadMailbox, 3000);
540
+ loadMailbox();