Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#2563eb"/>
<path d="M16 8v11m0 0l-4-4m4 4l4-4" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 24h14" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1,30 @@
{
"name": "Media Downloader",
"short_name": "Media DL",
"description": "Media Downloader - Automated media archival system",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,558 @@
/**
* Passkeys.js - Frontend WebAuthn/Passkey Management
*
* This module handles passkey (WebAuthn) registration, authentication, and management.
* Passkeys provide phishing-resistant, passwordless authentication using biometrics
* or hardware security keys (Touch ID, Face ID, Windows Hello, YubiKey, etc.)
*/
const Passkeys = {
/**
* Check if WebAuthn is supported in the browser
* @returns {boolean}
*/
isSupported() {
return window.PublicKeyCredential !== undefined &&
navigator.credentials !== undefined;
},
/**
* Show browser not supported error
*/
showNotSupportedError() {
showNotification('error', 'Browser Not Supported',
'Your browser does not support passkeys. Please use a modern browser like Chrome, Safari, Edge, or Firefox.');
},
/**
* Register a new passkey
*/
async registerPasskey() {
if (!this.isSupported()) {
this.showNotSupportedError();
return;
}
try {
// Prompt for device name
const deviceName = await this.promptDeviceName();
if (!deviceName) {return;} // User cancelled
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
// Get registration options from server
showNotification('info', 'Starting Registration', 'Requesting registration options...');
const optionsResponse = await fetch('/api/auth/passkey/register/options', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get registration options');
}
const { options } = await optionsResponse.json();
// Convert options for WebAuthn API
const publicKeyOptions = this.convertRegistrationOptions(options);
// Prompt user for biometric/security key
showNotification('info', 'Authenticate', 'Please authenticate with your device (Touch ID, Face ID, Windows Hello, or security key)...');
let credential;
try {
credential = await navigator.credentials.create({
publicKey: publicKeyOptions
});
} catch (err) {
if (err.name === 'NotAllowedError') {
showNotification('warning', 'Cancelled', 'Registration was cancelled');
} else if (err.name === 'InvalidStateError') {
showNotification('error', 'Already Registered', 'This authenticator is already registered');
} else {
throw err;
}
return;
}
if (!credential) {
showNotification('error', 'Registration Failed', 'No credential was created');
return;
}
// Send credential to server for verification
const verificationResponse = await fetch('/api/auth/passkey/register/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
response: this.encodeCredential(credential),
deviceName: deviceName
})
});
if (!verificationResponse.ok) {
const error = await verificationResponse.json();
throw new Error(error.error || 'Failed to verify registration');
}
const result = await verificationResponse.json();
showNotification('success', 'Passkey Registered', `${deviceName} has been successfully registered!`);
// Reload passkey list
if (typeof loadPasskeyStatus === 'function') {
loadPasskeyStatus();
}
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Passkey registration error:', error);
showNotification('error', 'Registration Error', error.message);
}
},
/**
* Prompt user for device name
* @returns {Promise<string|null>}
*/
async promptDeviceName() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2><i class="fas fa-fingerprint"></i> Register Passkey</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove(); event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 15px;">Give this passkey a name to help you identify it later:</p>
<div class="form-group">
<label for="passkey-device-name">Device Name</label>
<input type="text" id="passkey-device-name"
placeholder="e.g., iPhone 12, MacBook Pro, YubiKey"
maxlength="100" required autofocus>
<small style="color: var(--gray-500); margin-top: 5px; display: block;">
Examples: "iPhone 15", "Windows PC", "YubiKey 5C"
</small>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove();">
Cancel
</button>
<button class="btn btn-primary" id="continue-registration-btn">
<i class="fas fa-arrow-right"></i> Continue
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector('#passkey-device-name');
const continueBtn = modal.querySelector('#continue-registration-btn');
// Auto-suggest device name
input.value = this.suggestDeviceName();
input.select();
const handleContinue = () => {
const value = input.value.trim();
if (value) {
modal.remove();
resolve(value);
} else {
input.style.borderColor = 'var(--danger)';
showNotification('warning', 'Name Required', 'Please enter a device name');
}
};
continueBtn.addEventListener('click', handleContinue);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleContinue();
}
});
// Handle modal close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
resolve(null);
}
});
});
},
/**
* Suggest a device name based on user agent
* @returns {string}
*/
suggestDeviceName() {
const ua = navigator.userAgent;
// Mobile devices
if (/iPhone/.test(ua)) {return 'iPhone';}
if (/iPad/.test(ua)) {return 'iPad';}
if (/Android/.test(ua)) {
if (/Mobile/.test(ua)) {return 'Android Phone';}
return 'Android Tablet';
}
// Desktop OS
if (/Macintosh/.test(ua)) {return 'Mac';}
if (/Windows/.test(ua)) {return 'Windows PC';}
if (/Linux/.test(ua)) {return 'Linux PC';}
if (/CrOS/.test(ua)) {return 'Chromebook';}
return 'My Device';
},
/**
* Convert registration options from server to WebAuthn format
* @param {object} options - Options from server
* @returns {object} - WebAuthn PublicKeyCredentialCreationOptions
*/
convertRegistrationOptions(options) {
return {
challenge: this.base64urlToBuffer(options.challenge),
rp: options.rp,
user: {
id: this.stringToBuffer(options.user.id), // Handle both base64url and plain text
name: options.user.name,
displayName: options.user.displayName
},
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
attestation: options.attestation,
excludeCredentials: options.excludeCredentials?.map(cred => ({
id: this.base64urlToBuffer(cred.id),
type: cred.type,
transports: cred.transports
})) || [],
authenticatorSelection: options.authenticatorSelection
};
},
/**
* Convert authentication options from server to WebAuthn format
* @param {object} options - Options from server
* @returns {object} - WebAuthn PublicKeyCredentialRequestOptions
*/
convertAuthenticationOptions(options) {
return {
challenge: this.base64urlToBuffer(options.challenge),
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
allowCredentials: options.allowCredentials?.map(cred => ({
id: this.base64urlToBuffer(cred.id),
type: cred.type,
transports: cred.transports
})) || []
};
},
/**
* Encode credential for sending to server
* @param {PublicKeyCredential} credential
* @returns {object}
*/
encodeCredential(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: this.bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
attestationObject: this.bufferToBase64url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : []
}
};
},
/**
* Encode authentication credential for sending to server
* @param {PublicKeyCredential} credential
* @returns {object}
*/
encodeAuthCredential(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: this.bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
authenticatorData: this.bufferToBase64url(response.authenticatorData),
signature: this.bufferToBase64url(response.signature),
userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null
}
};
},
/**
* Convert string to ArrayBuffer (handles both plain text and base64url)
* @param {string} str - String to convert
* @returns {ArrayBuffer}
*/
stringToBuffer(str) {
// Check if it looks like base64url (only contains base64url-safe characters)
if (/^[A-Za-z0-9_-]+$/.test(str) && str.length > 10) {
// Likely base64url encoded, try to decode
try {
return this.base64urlToBuffer(str);
} catch (e) {
// If decoding fails, treat as plain text
console.warn('Failed to decode as base64url, treating as plain text:', e);
}
}
// Plain text - convert to UTF-8 bytes
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
},
/**
* Convert base64url string to ArrayBuffer
* @param {string} base64url
* @returns {ArrayBuffer}
*/
base64urlToBuffer(base64url) {
try {
// Ensure input is a string and trim whitespace
if (typeof base64url !== 'string') {
throw new Error(`Expected string, got ${typeof base64url}`);
}
base64url = base64url.trim();
// Convert base64url to base64
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding
const padLen = (4 - (base64.length % 4)) % 4;
const padded = base64 + '='.repeat(padLen);
// Decode base64 to binary string
const binary = atob(padded);
// Convert to ArrayBuffer
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
} catch (error) {
console.error('base64urlToBuffer error:', error);
console.error('Input value:', base64url);
console.error('Input type:', typeof base64url);
throw new Error(`Failed to decode base64url: ${error.message}`);
}
},
/**
* Convert ArrayBuffer to base64url string
* @param {ArrayBuffer} buffer
* @returns {string}
*/
bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
},
/**
* Delete a passkey
* @param {string} credentialId - Credential ID to delete
* @param {string} deviceName - Device name for confirmation
*/
async deletePasskey(credentialId, deviceName) {
if (!confirm(`Are you sure you want to delete the passkey "${deviceName}"?\n\nThis action cannot be undone.`)) {
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete passkey');
}
showNotification('success', 'Passkey Deleted', `${deviceName} has been removed`);
// Reload passkey list
if (typeof loadPasskeyStatus === 'function') {
loadPasskeyStatus();
}
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Delete passkey error:', error);
showNotification('error', 'Delete Error', error.message);
}
},
/**
* Rename a passkey
* @param {string} credentialId - Credential ID to rename
* @param {string} currentName - Current device name
*/
async renamePasskey(credentialId, currentName) {
const newName = prompt('Enter a new name for this passkey:', currentName);
if (!newName || newName === currentName) {return;}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}/rename`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ deviceName: newName })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to rename passkey');
}
showNotification('success', 'Passkey Renamed', `Renamed to "${newName}"`);
// Reload passkey list
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Rename passkey error:', error);
showNotification('error', 'Rename Error', error.message);
}
},
/**
* Authenticate with passkey
* @param {string} username - Username (optional for resident keys)
* @returns {Promise<object>} - Authentication result with token
*/
async authenticate(username = null) {
if (!this.isSupported()) {
this.showNotSupportedError();
return null;
}
try {
// Get authentication options from server
const optionsResponse = await fetch('/api/auth/passkey/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get authentication options');
}
const { options } = await optionsResponse.json();
// Convert options for WebAuthn API
const publicKeyOptions = this.convertAuthenticationOptions(options);
// Prompt user for biometric/security key
showNotification('info', 'Authenticate', 'Please authenticate with your passkey...');
let credential;
try {
credential = await navigator.credentials.get({
publicKey: publicKeyOptions
});
} catch (err) {
if (err.name === 'NotAllowedError') {
showNotification('warning', 'Cancelled', 'Authentication was cancelled');
} else {
throw err;
}
return null;
}
if (!credential) {
showNotification('error', 'Authentication Failed', 'No credential was provided');
return null;
}
// Send credential to server for verification
const verificationResponse = await fetch('/api/auth/passkey/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
response: this.encodeAuthCredential(credential)
})
});
if (!verificationResponse.ok) {
const error = await verificationResponse.json();
throw new Error(error.error || 'Authentication failed');
}
const result = await verificationResponse.json();
showNotification('success', 'Authenticated', 'Successfully logged in with passkey!');
return result;
} catch (error) {
console.error('Passkey authentication error:', error);
showNotification('error', 'Authentication Error', error.message);
return null;
}
}
};
// Make globally available
window.Passkeys = Passkeys;

177
web/frontend/public/sw.js Normal file
View File

@@ -0,0 +1,177 @@
// Service Worker for Media Downloader PWA
const CACHE_NAME = 'media-downloader-v2';
const STATIC_CACHE_NAME = 'media-downloader-static-v2';
// Assets to cache immediately on install
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/apple-touch-icon.png',
'/icon-192.png',
'/icon-512.png'
];
// Install event - precache static assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(STATIC_CACHE_NAME)
.then((cache) => {
console.log('[SW] Precaching static assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => {
console.log('[SW] Install complete');
return self.skipWaiting();
})
.catch((err) => {
console.error('[SW] Precache failed:', err);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[SW] Activation complete');
return self.clients.claim();
})
);
});
// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip API requests - always go to network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
return;
}
// Skip external requests
if (url.origin !== self.location.origin) {
return;
}
// For hashed JS/CSS assets (Vite bundles like /assets/index-BYx1TwGc.js),
// use cache-first since the hash guarantees uniqueness - no stale cache risk.
// This prevents full reloads on iOS PWA when the app resumes from background.
if (url.pathname.startsWith('/assets/') && url.pathname.match(/\.[a-f0-9]{8,}\.(js|css)$/)) {
event.respondWith(
caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
}
return networkResponse;
});
})
);
return;
}
// Skip non-hashed JS (sw.js, totp.js, etc) - always go to network
if (url.pathname.endsWith('.js')) {
return;
}
// For static assets (css, images, fonts), use cache-first strategy
if (request.destination === 'style' ||
request.destination === 'image' ||
request.destination === 'font' ||
url.pathname.match(/\.(css|png|jpg|jpeg|gif|svg|woff|woff2|ico)$/)) {
event.respondWith(
caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
// Return cached version, but update cache in background
event.waitUntil(
fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, networkResponse));
}
})
.catch(() => {})
);
return cachedResponse;
}
// Not in cache, fetch from network and cache
return fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, responseClone));
}
return networkResponse;
});
})
);
return;
}
// For HTML/navigation requests, use network-first with cache fallback
if (request.mode === 'navigate' || request.destination === 'document') {
event.respondWith(
fetch(request)
.then((networkResponse) => {
// Cache the latest version
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, responseClone));
return networkResponse;
})
.catch(() => {
// Network failed, try cache
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Fallback to cached index.html for SPA routing
return caches.match('/index.html');
});
})
);
return;
}
});
// Handle messages from the app
self.addEventListener('message', (event) => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
if (event.data === 'clearCache') {
caches.keys().then((names) => {
names.forEach((name) => caches.delete(name));
});
}
});

506
web/frontend/public/totp.js Normal file
View File

@@ -0,0 +1,506 @@
/**
* TOTP Client-Side Module
* Handles Two-Factor Authentication setup, verification, and management
*/
const TOTP = {
/**
* Initialize TOTP setup process
*/
async setupTOTP() {
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('Please log in first', 'error');
return;
}
const response = await fetch('/api/auth/totp/setup', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to setup 2FA', 'error');
return;
}
// Show setup modal with QR code
this.showSetupModal(data.qrCodeDataURL, data.secret);
} catch (error) {
console.error('TOTP setup error:', error);
showNotification('Failed to setup 2FA', 'error');
}
},
/**
* Display TOTP setup modal with QR code
*/
showSetupModal(qrCodeDataURL, secret) {
const modal = document.createElement('div');
modal.id = 'totpSetupModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content totp-modal">
<div class="modal-header">
<h2>
<i class="fas fa-shield-alt"></i>
Enable Two-Factor Authentication
</h2>
<button class="close-btn" onclick="TOTP.closeSetupModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="totp-setup-steps">
<div class="step active" id="step1">
<h3>Step 1: Scan QR Code</h3>
<p>Open your authenticator app (Google Authenticator, Microsoft Authenticator, Authy, etc.) and scan this QR code:</p>
<div class="qr-code-container">
<img src="${qrCodeDataURL}" alt="QR Code" class="qr-code-image">
</div>
<div class="manual-entry">
<p>Can't scan the code? Enter this key manually:</p>
<div class="secret-key">
<code id="secretKey">${secret}</code>
<button class="btn-copy" onclick="TOTP.copySecret('${secret}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<button class="btn btn-primary" onclick="TOTP.showVerifyStep()">
Next: Verify Code
</button>
</div>
<div class="step" id="step2" style="display: none;">
<h3>Step 2: Verify Your Code</h3>
<p>Enter the 6-digit code from your authenticator app:</p>
<div class="verify-input-container">
<input
type="text"
id="verifyCode"
maxlength="6"
pattern="[0-9]{6}"
placeholder="000000"
autocomplete="off"
class="totp-code-input"
>
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.showScanStep()">
<i class="fas fa-arrow-left"></i> Back
</button>
<button class="btn btn-primary" onclick="TOTP.verifySetup()">
<i class="fas fa-check"></i> Verify & Enable
</button>
</div>
</div>
<div class="step" id="step3" style="display: none;">
<h3>Step 3: Save Backup Codes</h3>
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Important:</strong> Save these backup codes in a safe place.
You can use them to access your account if you lose your authenticator device.</p>
</div>
<div class="backup-codes" id="backupCodes">
<!-- Backup codes will be inserted here -->
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.downloadBackupCodes()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-secondary" onclick="TOTP.printBackupCodes()">
<i class="fas fa-print"></i> Print
</button>
<button class="btn btn-secondary" onclick="TOTP.copyBackupCodes()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
<div class="confirm-save">
<label>
<input type="checkbox" id="confirmSaved">
I have saved my backup codes
</label>
</div>
<button class="btn btn-success" onclick="TOTP.finishSetup()" disabled id="finishBtn">
<i class="fas fa-check-circle"></i> Finish Setup
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Enable finish button when checkbox is checked
document.getElementById('confirmSaved')?.addEventListener('change', (e) => {
document.getElementById('finishBtn').disabled = !e.target.checked;
});
// Auto-focus on verify code input when shown
document.getElementById('verifyCode')?.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '');
});
},
showScanStep() {
document.getElementById('step1').style.display = 'block';
document.getElementById('step2').style.display = 'none';
document.getElementById('step1').classList.add('active');
document.getElementById('step2').classList.remove('active');
},
showVerifyStep() {
document.getElementById('step1').style.display = 'none';
document.getElementById('step2').style.display = 'block';
document.getElementById('step1').classList.remove('active');
document.getElementById('step2').classList.add('active');
setTimeout(() => document.getElementById('verifyCode')?.focus(), 100);
},
/**
* Verify TOTP code and complete setup
*/
async verifySetup() {
const code = document.getElementById('verifyCode')?.value;
if (!code || code.length !== 6) {
showNotification('Please enter a 6-digit code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Invalid code', 'error');
return;
}
// Show backup codes
this.showBackupCodes(data.backupCodes);
document.getElementById('step2').style.display = 'none';
document.getElementById('step3').style.display = 'block';
document.getElementById('step2').classList.remove('active');
document.getElementById('step3').classList.add('active');
showNotification('Two-factor authentication enabled!', 'success');
} catch (error) {
console.error('TOTP verification error:', error);
showNotification('Failed to verify code', 'error');
}
},
/**
* Display backup codes
*/
showBackupCodes(codes) {
const container = document.getElementById('backupCodes');
if (!container) {return;}
this.backupCodes = codes; // Store for later use
container.innerHTML = `
<div class="backup-codes-grid">
${codes.map((code, index) => `
<div class="backup-code-item">
<span class="code-number">${index + 1}.</span>
<code>${code}</code>
</div>
`).join('')}
</div>
`;
},
/**
* Copy secret to clipboard
*/
copySecret(secret) {
navigator.clipboard.writeText(secret).then(() => {
showNotification('Secret key copied to clipboard', 'success');
}).catch(() => {
showNotification('Failed to copy secret key', 'error');
});
},
/**
* Download backup codes as text file
*/
downloadBackupCodes() {
if (!this.backupCodes) {return;}
const content = `Backup Central - Two-Factor Authentication Backup Codes
Generated: ${new Date().toLocaleString()}
IMPORTANT: Keep these codes in a safe place!
Each code can only be used once.
${this.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
If you lose access to your authenticator app, you can use one of these codes to log in.
`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup-codes-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Backup codes downloaded', 'success');
},
/**
* Print backup codes
*/
printBackupCodes() {
if (!this.backupCodes) {return;}
const printWindow = window.open('', '', 'width=600,height=400');
printWindow.document.write(`
<html>
<head>
<title>Backup Codes</title>
<style>
body { font-family: monospace; padding: 20px; }
h1 { font-size: 18px; }
.code { margin: 10px 0; font-size: 14px; }
@media print {
button { display: none; }
}
</style>
</head>
<body>
<h1>Backup Central - 2FA Backup Codes</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<hr>
${this.backupCodes.map((code, i) => `<div class="code">${i + 1}. ${code}</div>`).join('')}
<hr>
<p><strong>Keep these codes in a safe place!</strong></p>
<button onclick="window.print()">Print</button>
</body>
</html>
`);
printWindow.document.close();
},
/**
* Copy all backup codes to clipboard
*/
copyBackupCodes() {
if (!this.backupCodes) {return;}
const text = this.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
navigator.clipboard.writeText(text).then(() => {
showNotification('Backup codes copied to clipboard', 'success');
}).catch(() => {
showNotification('Failed to copy backup codes', 'error');
});
},
/**
* Complete setup and close modal
*/
finishSetup() {
this.closeSetupModal();
showNotification('Two-factor authentication is now active!', 'success');
// Refresh 2FA status in UI
if (typeof loadTOTPStatus === 'function') {
loadTOTPStatus();
}
},
/**
* Close setup modal
*/
closeSetupModal() {
const modal = document.getElementById('totpSetupModal');
if (modal) {
modal.remove();
}
},
/**
* Disable TOTP for current user
*/
async disableTOTP() {
const confirmed = confirm('Are you sure you want to disable two-factor authentication?\n\nThis will make your account less secure.');
if (!confirmed) {return;}
const password = prompt('Enter your password to confirm:');
if (!password) {return;}
const code = prompt('Enter your current 2FA code:');
if (!code || code.length !== 6) {
showNotification('Invalid code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/disable', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ password, code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to disable 2FA', 'error');
return;
}
showNotification('Two-factor authentication disabled', 'success');
// Refresh 2FA status in UI
if (typeof loadTOTPStatus === 'function') {
loadTOTPStatus();
}
} catch (error) {
console.error('Disable TOTP error:', error);
showNotification('Failed to disable 2FA', 'error');
}
},
/**
* Regenerate backup codes
*/
async regenerateBackupCodes() {
const confirmed = confirm('This will invalidate your old backup codes.\n\nAre you sure you want to generate new backup codes?');
if (!confirmed) {return;}
const code = prompt('Enter your current 2FA code to confirm:');
if (!code || code.length !== 6) {
showNotification('Invalid code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/regenerate-backup-codes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to regenerate codes', 'error');
return;
}
// Show new backup codes
this.showBackupCodesModal(data.backupCodes);
} catch (error) {
console.error('Regenerate codes error:', error);
showNotification('Failed to regenerate backup codes', 'error');
}
},
/**
* Show backup codes in a modal (for regeneration)
*/
showBackupCodesModal(codes) {
this.backupCodes = codes;
const modal = document.createElement('div');
modal.id = 'backupCodesModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2>New Backup Codes</h2>
<button class="close-btn" onclick="TOTP.closeBackupCodesModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Important:</strong> Save these new backup codes. Your old codes are no longer valid.</p>
</div>
<div class="backup-codes" id="newBackupCodes">
<div class="backup-codes-grid">
${codes.map((code, index) => `
<div class="backup-code-item">
<span class="code-number">${index + 1}.</span>
<code>${code}</code>
</div>
`).join('')}
</div>
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.downloadBackupCodes()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-secondary" onclick="TOTP.printBackupCodes()">
<i class="fas fa-print"></i> Print
</button>
<button class="btn btn-secondary" onclick="TOTP.copyBackupCodes()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
showNotification('New backup codes generated', 'success');
},
closeBackupCodesModal() {
const modal = document.getElementById('backupCodesModal');
if (modal) {
modal.remove();
}
}
};
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = TOTP;
}