BIN
web/frontend/public/apple-touch-icon.png
Normal file
BIN
web/frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
5
web/frontend/public/favicon.svg
Normal file
5
web/frontend/public/favicon.svg
Normal 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 |
BIN
web/frontend/public/icon-192.png
Normal file
BIN
web/frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
web/frontend/public/icon-512.png
Normal file
BIN
web/frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
30
web/frontend/public/manifest.json
Normal file
30
web/frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
558
web/frontend/public/passkeys.js
Normal file
558
web/frontend/public/passkeys.js
Normal 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
177
web/frontend/public/sw.js
Normal 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
506
web/frontend/public/totp.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user