// ==UserScript==
// @name Open2FA
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Open2FA is a free and open-source 2FA generator that helps you create secure tokens anytime, anywhere. Lightweight, fast, and privacy-friendly – no accounts, no tracking, just instant codes.
// @author airpl4ne
// @author Aleyru
// @author Ha2ixz
// @match *://*/*
// @match https://greasyfork.org
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @require https://unpkg.com/@zxing/library@latest/umd/index.min.js
// @icon https://github.com/pillowslua/crackduo/blob/main/OPEN%202FA.png?raw=true
// @license MIT
// ==/UserScript==
// please use the script in greasyfork pages! it works the best in greasyfork page!
(function() {
'use strict';
// Inject Tailwind CSS
function injectTailwind() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.tailwindcss.com';
document.head.appendChild(link);
const script = document.createElement('script');
script.src = 'https://cdn.tailwindcss.com';
script.onload = () => {
// Configure Tailwind with custom theme
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#3B82F6',
secondary: '#10B981',
accent: '#8B5CF6',
danger: '#EF4444'
}
}
}
};
initializeDashboard();
};
document.head.appendChild(script);
}
// Create dashboard UI (all in English)
function createDashboard() {
const savedPosition = GM_getValue('2fa-position', { top: '20px', right: '20px' });
const isDarkMode = GM_getValue('2fa-dark-mode', false);
const isFullScreen = GM_getValue('2fa-full-screen', false);
const dashboard = document.createElement('div');
dashboard.id = '2fa-dashboard';
dashboard.className = `fixed ${isFullScreen ? 'inset-0' : 'w-80'} bg-white dark:bg-gray-800 rounded-lg shadow-2xl p-4 z-[9999] max-h-[90vh] overflow-y-auto transition-all duration-300`;
dashboard.style.top = isFullScreen ? '0' : savedPosition.top;
dashboard.style.right = isFullScreen ? '0' : savedPosition.right;
dashboard.classList.toggle('dark', isDarkMode);
dashboard.innerHTML = `
<div id="2fa-header" class="flex justify-between items-center mb-4 cursor-move">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Open2FA</h2>
<div class="flex space-x-2">
<button id="2fa-theme-toggle" class="text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100" aria-label="Toggle light/dark mode">
<svg id="2fa-theme-icon" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
${isDarkMode ? '<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>' : '<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/>'}
</svg>
</button>
<button id="2fa-fullscreen-toggle" class="text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100" aria-label="Toggle full-screen">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
${isFullScreen ? '<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>' : '<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm11 1H6v8h8V6z" clip-rule="evenodd"/>'}
</svg>
</button>
<button id="2fa-close" class="text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100" aria-label="Close dashboard">✕</button>
</div>
</div>
<div class="mb-3">
<label for="2fa-input" class="block text-sm font-medium text-gray-700 dark:text-gray-200">2FA Link or Secret Key</label>
<input id="2fa-input" class="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-gray-50 dark:bg-gray-700 text-sm text-gray-800 dark:text-gray-100"
placeholder="otpauth://... or secret key" aria-label="Enter 2FA link or secret key">
</div>
<div class="flex items-center mb-3">
<input type="checkbox" id="2fa-remember" class="mr-2 text-primary focus:ring-primary">
<label for="2fa-remember" class="text-xs text-gray-600 dark:text-gray-300">Save secret</label>
</div>
<button id="2fa-load" class="w-full bg-primary text-white p-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-primary text-sm transition transform hover:scale-105"
aria-label="Generate 2FA code">Generate</button>
<div class="mt-3">
<label for="2fa-qr-input" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Upload QR Code Image</label>
<input type="file" id="2fa-qr-input" accept="image/*" class="w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-700 text-sm text-gray-800 dark:text-gray-100">
</div>
<div id="2fa-saved" class="mt-3 hidden">
<button id="2fa-toggle-saved" class="text-sm text-primary hover:underline mb-1" aria-label="Toggle saved secrets">Show Saved Secrets</button>
<div id="2fa-saved-content" class="hidden">
<label for="2fa-select-saved" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Saved Secrets</label>
<select id="2fa-select-saved" class="w-full p-2 border rounded-md mb-2 focus:outline-none focus:ring-2 focus:ring-primary bg-gray-50 dark:bg-gray-700 text-sm text-gray-800 dark:text-gray-100"
aria-label="Select saved secret">
<option value="">-- Select a saved secret --</option>
</select>
<div class="flex space-x-2">
<button id="2fa-load-saved" class="flex-1 bg-secondary text-white p-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-secondary text-sm transition transform hover:scale-105"
aria-label="Load selected secret">Load</button>
<button id="2fa-delete-saved" class="flex-1 bg-danger text-white p-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-danger text-sm transition transform hover:scale-105"
aria-label="Delete selected secret">Delete</button>
</div>
</div>
</div>
<div id="2fa-info" class="text-xs text-gray-600 dark:text-gray-300 mt-2" role="alert"></div>
<div id="2fa-code" class="text-xl font-bold text-center text-gray-800 dark:text-gray-100 my-3" aria-live="polite"></div>
<div class="flex space-x-2 mb-3">
<button id="2fa-copy" class="flex-1 bg-gray-600 dark:bg-gray-500 text-white p-2 rounded-md hover:bg-gray-700 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 text-sm transition transform hover:scale-105 hidden"
aria-label="Copy 2FA code">Copy</button>
<button id="2fa-next" class="flex-1 bg-accent text-white p-2 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-accent text-sm transition transform hover:scale-105 hidden"
aria-label="Next HOTP code">Next</button>
</div>
<div id="2fa-timer" class="text-xs text-gray-600 dark:text-gray-300 text-center"></div>
<div id="2fa-progress" class="w-full h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden hidden">
<div id="2fa-progress-bar" class="h-full bg-primary transition-all duration-1000"></div>
</div>
<div class="mt-4 flex justify-center">
<a id="2fa-discord" href="https://discord.gg/m3EV55SpYw" target="_blank" class="text-sm text-primary hover:underline" aria-label="Join Discord">Join Discord</a>
</div>
`;
document.body.appendChild(dashboard);
const qrInput = document.getElementById('2fa-qr-input');
qrInput.addEventListener('change', handleQRUpload);
}
// Handle QR code upload and scanning
function handleQRUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const code = ZXing.MultiFormatReader.decodeFromImageData(imageData.data, img.width, img.height, null);
if (code) {
document.getElementById('2fa-input').value = code.text;
document.getElementById('2fa-load').click(); // Auto generate
const info = document.getElementById('2fa-info');
info.textContent = 'QR code scanned successfully!';
info.classList.add('text-green-600');
} else {
const info = document.getElementById('2fa-info');
info.textContent = 'No QR code found in image.';
info.classList.add('text-red-600');
}
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Make dashboard draggable (fixed to work only in compact mode)
function makeDraggable() {
const header = document.getElementById('2fa-header');
let isDragging = false, currentX, currentY, initialX, initialY;
header.addEventListener('mousedown', (e) => {
if (!document.getElementById('2fa-dashboard').classList.contains('inset-0')) {
currentX = document.getElementById('2fa-dashboard').offsetLeft;
currentY = document.getElementById('2fa-dashboard').offsetTop;
initialX = e.clientX - currentX;
initialY = e.clientY - currentY;
isDragging = true;
}
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
e.preventDefault();
const dashboard = document.getElementById('2fa-dashboard');
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
dashboard.style.left = `${currentX}px`;
dashboard.style.right = 'auto';
dashboard.style.top = `${currentY}px`;
dashboard.style.bottom = 'auto';
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
const dashboard = document.getElementById('2fa-dashboard');
GM_setValue('2fa-position', {
top: dashboard.style.top,
right: dashboard.style.right || 'auto',
left: dashboard.style.left || 'auto',
bottom: dashboard.style.bottom || 'auto'
});
}
});
}
// Base32 decoding (RFC4648) - improved with better error handling
function base32Decode(secret) {
secret = secret.replace(/=/g, '').toUpperCase();
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0, value = 0;
const bytes = [];
for (let char of secret) {
const idx = alphabet.indexOf(char);
if (idx === -1) throw new Error('Invalid base32 character in secret');
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xFF);
bits -= 8;
}
}
return new Uint8Array(bytes);
}
// HMAC using Web Crypto API - improved error handling
async function computeHMAC(algo, key, data) {
try {
const cryptoKey = await crypto.subtle.importKey(
'raw', key, { name: 'HMAC', hash: algo }, false, ['sign']
);
return await crypto.subtle.sign('HMAC', cryptoKey, data);
} catch (error) {
throw new Error(`HMAC computation failed: ${error.message}. Check if the algorithm is supported.`);
}
}
// Core HOTP generation
async function generateHOTP(keyBytes, counter, digits, algo) {
const counterBytes = new Uint8Array(8);
let c = BigInt(counter);
for (let i = 7; i >= 0; i--) {
counterBytes[i] = Number(c & 0xffn);
c >>= 8n;
}
const hmacResult = await computeHMAC(algo, keyBytes, counterBytes);
const hmacBytes = new Uint8Array(hmacResult);
const offset = hmacBytes[hmacBytes.length - 1] & 0x0f;
const code = ((hmacBytes[offset] & 0x7f) << 24) |
((hmacBytes[offset + 1] & 0xff) << 16) |
((hmacBytes[offset + 2] & 0xff) << 8) |
(hmacBytes[offset + 3] & 0xff);
return code;
}
// Numeric OTP for TOTP/HOTP
async function generateNumericOTP(keyBytes, counter, digits, algo) {
const code = await generateHOTP(keyBytes, counter, digits, algo);
return (code % (10 ** digits)).toString().padStart(digits, '0');
}
// SteamGuard code (base26)
async function generateSteamCode(keyBytes, counter, algo) {
const alphabet = '23456789BCDFGHJKMNPQRTVWXY';
const code = await generateHOTP(keyBytes, counter, 5, algo);
let steamCode = '';
let temp = code;
for (let i = 0; i < 5; i++) {
steamCode += alphabet[temp % 26];
temp = Math.floor(temp / 26);
}
return steamCode;
}
// Parse input (otpauth://, custom URL, or plain secret) - improved with better validation
function parseInput(input) {
if (!input.trim()) throw new Error('Please enter a 2FA link or secret key');
let url;
try {
url = new URL(input.startsWith('otpauth://') ? input : 'https://dummy.com?' + input);
} catch {
// Plain secret fallback
const secret = input.trim();
if (!/^[A-Z2-7]+$/i.test(secret)) throw new Error('Invalid plain secret format (must be base32 characters)');
return {
type: 'totp',
secret,
algorithm: 'SHA-1',
digits: 6,
period: 30,
counter: 0,
issuer: '',
label: 'Unnamed',
isSteam: false
};
}
let type = url.protocol === 'otpauth:' ? url.hostname.toLowerCase() : 'totp';
if (type !== 'totp' && type !== 'hotp') {
type = url.searchParams.get('type') ? url.searchParams.get('type').toLowerCase() :
(url.searchParams.has('period') ? 'totp' : (url.searchParams.has('counter') ? 'hotp' : 'totp'));
}
const secret = url.searchParams.get('secret');
if (!secret) throw new Error('No secret found in the link');
if (!/^[A-Z2-7]+$/i.test(secret)) throw new Error('Invalid secret format in link (must be base32 characters)');
const algorithm = (url.searchParams.get('algorithm') || 'SHA1').toUpperCase().replace('SHA', 'SHA-');
const digits = parseInt(url.searchParams.get('digits') || '6', 10);
if (isNaN(digits) || digits < 1 || digits > 10) throw new Error('Invalid digits parameter (must be 1-10)');
const period = parseInt(url.searchParams.get('period') || '30', 10);
if (type === 'totp' && (isNaN(period) || period < 1 || period > 60)) throw new Error('Invalid period parameter (must be 1-60 for TOTP)');
const counter = parseInt(url.searchParams.get('counter') || '0', 10);
if (type === 'hotp' && isNaN(counter)) throw new Error('Invalid counter parameter for HOTP');
let path = decodeURIComponent(url.pathname.slice(1));
let issuer = url.searchParams.get('issuer') || '';
let label = path.includes(':') ? path.split(':')[1] : path || 'Unnamed';
if (!issuer && path.includes(':')) issuer = path.split(':')[0];
const isSteam = (issuer.toLowerCase() === 'steam' || label.toLowerCase().includes('steam')) && digits === 5 && algorithm === 'SHA-1';
return { type, secret, algorithm, digits, period, counter, issuer, label, isSteam };
}
// GM storage helpers
function getSavedSecrets() {
return GM_getValue('2fa-saved', []) || [];
}
function setSavedSecrets(secrets) {
GM_setValue('2fa-saved', secrets);
}
// Update saved secrets UI
function updateSavedUI(saved) {
const select = document.getElementById('2fa-select-saved');
select.innerHTML = '<option value="">-- Select a saved secret --</option>';
saved.forEach((item, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${item.label} (${item.issuer || 'No issuer'})`;
select.appendChild(option);
});
document.getElementById('2fa-saved').classList.toggle('hidden', saved.length === 0);
}
// Main logic
let currentParams = null;
let intervalId = null;
let keyBytes = null;
let savedSecrets = [];
async function loadSecret(inputValue = null, savedIndex = null) {
const input = inputValue || document.getElementById('2fa-input').value.trim();
const remember = document.getElementById('2fa-remember').checked;
const info = document.getElementById('2fa-info');
const codeDiv = document.getElementById('2fa-code');
const copyBtn = document.getElementById('2fa-copy');
const nextBtn = document.getElementById('2fa-next');
const timerDiv = document.getElementById('2fa-timer');
const progressDiv = document.getElementById('2fa-progress');
const progressBar = document.getElementById('2fa-progress-bar');
info.textContent = '';
codeDiv.textContent = '';
copyBtn.classList.add('hidden');
nextBtn.classList.add('hidden');
timerDiv.textContent = '';
progressDiv.classList.add('hidden');
if (intervalId) clearInterval(intervalId);
try {
if (savedIndex !== null) {
currentParams = savedSecrets[savedIndex];
document.getElementById('2fa-input').value = currentParams.secret;
} else {
currentParams = parseInput(input);
}
keyBytes = base32Decode(currentParams.secret);
if (!['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'].includes(currentParams.algorithm)) {
throw new Error('Unsupported algorithm');
}
info.textContent = `Type: ${currentParams.type.toUpperCase()}${currentParams.isSteam ? ' (Steam)' : ''}, Digits: ${currentParams.digits}`;
if (currentParams.label) info.textContent += `, Label: ${currentParams.label}`;
if (currentParams.issuer) info.textContent += `, Issuer: ${currentParams.issuer}`;
info.classList.remove('text-red-600');
info.classList.add('text-green-600');
if (remember && savedIndex === null) {
savedSecrets.push(currentParams);
setSavedSecrets(savedSecrets);
updateSavedUI(savedSecrets);
}
await updateCode();
copyBtn.classList.remove('hidden');
if (currentParams.type === 'totp') {
progressDiv.classList.remove('hidden');
intervalId = setInterval(updateCode, 1000);
} else if (currentParams.type === 'hotp') {
nextBtn.classList.remove('hidden');
}
} catch (error) {
info.textContent = `Error: ${error.message}`;
info.classList.remove('text-green-600');
info.classList.add('text-red-600');
}
}
async function updateCode() {
if (!currentParams || !keyBytes) return;
const codeDiv = document.getElementById('2fa-code');
const timerDiv = document.getElementById('2fa-timer');
const progressBar = document.getElementById('2fa-progress-bar');
let counter;
if (currentParams.type === 'totp') {
counter = Math.floor(Date.now() / 1000 / currentParams.period);
const timeLeft = currentParams.period - (Math.floor(Date.now() / 1000) % currentParams.period);
timerDiv.textContent = `Refresh in ${timeLeft} seconds`;
const progress = (timeLeft / currentParams.period) * 100;
progressBar.style.width = `${progress}%`;
} else {
counter = currentParams.counter;
timerDiv.textContent = `Counter: ${counter}`;
}
let code;
if (currentParams.isSteam) {
code = await generateSteamCode(keyBytes, counter, currentParams.algorithm);
} else {
code = await generateNumericOTP(keyBytes, counter, currentParams.digits, currentParams.algorithm);
}
codeDiv.textContent = code;
}
function copyToClipboard() {
const code = document.getElementById('2fa-code').textContent;
if (code) {
navigator.clipboard.writeText(code).then(() => {
const info = document.getElementById('2fa-info');
info.textContent = 'Code copied!';
info.classList.remove('text-red-600');
info.classList.add('text-green-600');
setTimeout(() => {
if (info.textContent === 'Code copied!') {
info.textContent = currentParams ?
`Type: ${currentParams.type.toUpperCase()}${currentParams.isSteam ? ' (Steam)' : ''}, Digits: ${currentParams.digits}${currentParams.label ? `, Label: ${currentParams.label}` : ''}${currentParams.issuer ? `, Issuer: ${currentParams.issuer}` : ''}` : '';
}
}, 2000);
}).catch(err => {
const info = document.getElementById('2fa-info');
info.textContent = `Failed to copy: ${err}`;
info.classList.add('text-red-600');
});
}
}
function nextHOTP() {
if (currentParams.type === 'hotp') {
currentParams.counter++;
updateCode();
const select = document.getElementById('2fa-select-saved');
const savedIndex = select.value;
if (savedIndex) {
savedSecrets[savedIndex] = currentParams;
setSavedSecrets(savedSecrets);
}
}
}
async function loadSaved() {
const select = document.getElementById('2fa-select-saved');
const index = select.value;
if (index) {
await loadSecret(null, index);
}
}
async function deleteSaved() {
const select = document.getElementById('2fa-select-saved');
const index = select.value;
if (index) {
savedSecrets.splice(index, 1);
setSavedSecrets(savedSecrets);
updateSavedUI(savedSecrets);
document.getElementById('2fa-info').textContent = 'Secret deleted!';
document.getElementById('2fa-info').classList.add('text-green-600');
document.getElementById('2fa-code').textContent = '';
document.getElementById('2fa-progress').classList.add('hidden');
if (intervalId) clearInterval(intervalId);
}
}
// Initialize dashboard and event listeners
function initializeDashboard() {
if (!document.getElementById('2fa-dashboard')) {
createDashboard();
makeDraggable();
initializeDashboard();
return;
}
savedSecrets = getSavedSecrets();
updateSavedUI(savedSecrets);
document.getElementById('2fa-close').addEventListener('click', () => {
const dashboard = document.getElementById('2fa-dashboard');
if (intervalId) clearInterval(intervalId);
dashboard.remove();
});
document.getElementById('2fa-load').addEventListener('click', () => loadSecret());
document.getElementById('2fa-copy').addEventListener('click', copyToClipboard);
document.getElementById('2fa-next').addEventListener('click', nextHOTP);
document.getElementById('2fa-load-saved').addEventListener('click', loadSaved);
document.getElementById('2fa-delete-saved').addEventListener('click', deleteSaved);
document.getElementById('2fa-toggle-saved').addEventListener('click', () => {
const savedContent = document.getElementById('2fa-saved-content');
const toggleBtn = document.getElementById('2fa-toggle-saved');
savedContent.classList.toggle('hidden');
toggleBtn.textContent = savedContent.classList.contains('hidden') ? 'Show Saved Secrets' : 'Hide Saved Secrets';
});
document.getElementById('2fa-theme-toggle').addEventListener('click', () => {
const dashboard = document.getElementById('2fa-dashboard');
const isDark = dashboard.classList.toggle('dark');
GM_setValue('2fa-dark-mode', isDark);
const icon = document.getElementById('2fa-theme-icon');
icon.innerHTML = isDark ?
'<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>' :
'<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/>';
});
document.getElementById('2fa-fullscreen-toggle').addEventListener('click', () => {
const dashboard = document.getElementById('2fa-dashboard');
const isFullScreen = !dashboard.classList.contains('inset-0');
dashboard.classList.toggle('inset-0', isFullScreen);
dashboard.classList.toggle('w-80', !isFullScreen);
dashboard.style.top = isFullScreen ? '0' : GM_getValue('2fa-position', { top: '20px' }).top;
dashboard.style.right = isFullScreen ? '0' : GM_getValue('2fa-position', { right: '20px' }).right;
dashboard.style.left = isFullScreen ? '0' : 'auto';
dashboard.style.bottom = isFullScreen ? '0' : 'auto';
GM_setValue('2fa-full-screen', isFullScreen);
const icon = document.getElementById('2fa-fullscreen-toggle').querySelector('svg');
icon.innerHTML = isFullScreen ?
'<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>' :
'<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm11 1H6v8h8V6z" clip-rule="evenodd"/>';
});
document.getElementById('2fa-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('2fa-load').click();
});
}
// Register Tampermonkey menu command
GM_registerMenuCommand('Open 2FA Dashboard', injectTailwind);
})();
//- User Instructions (English)
//- **Installation**: Install Tampermonkey, create a new script, paste the code, and save.
//- **Opening Dashboard**: Click Tampermonkey icon > "Open 2FA Dashboard".
//- **Drag Function**: Drag the header to move the dashboard (in compact mode only). Position is saved.
//- **Full-Screen**: Toggle with the screen icon.
//- **Light/Dark Mode**: Toggle with the sun/moon icon.
//- **QR Recognition**: Upload a QR code image using the file input; it will auto-scan and fill the secret key.
//- **Input Secret**: Enter otpauth:// link, custom URL, or plain base32 secret, then click "Generate".
//- **Save Secret**: Check "Save secret" to store permanently in Tampermonkey storage.
//- **Saved Secrets**: Toggle "Show Saved Secrets", select, load or delete.
//- **Improved Logic**: Better validation for secret formats, digits (1-10), period (1-60 for TOTP), and error messages.
//- **Join Discord**: Click the link at the bottom.
//- **Close**: Click "✕".
//The drag function is fixed to use offsetLeft/Top for accurate positioning. UI is fully in English. Logic improved with enhanced validation and error handling. QR recognition added using ZXing library via CDN. All in English. Storage is permanent via GM storage unless deleted. If issues, provide details for further fixes.