mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2026-04-19 21:16:51 -04:00
927 lines
53 KiB
HTML
927 lines
53 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Setup - Huntarr</title>
|
|
<script>
|
|
document.documentElement.classList.add('dark-mode');
|
|
</script>
|
|
<link rel="preload" href="./static/logo/256.png" as="image" fetchpriority="high">
|
|
<link rel="stylesheet" href="./static/css/style.css">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha384-nRgPTkuX86pH8yjPJUAFuASXQSSl2/bBUiNV47vSYpKFxHJhbcrGnmlYpYJMeD7a" crossorigin="anonymous" referrerpolicy="no-referrer">
|
|
<link rel="icon" href="./static/logo/16.png">
|
|
<script src="./static/js/modules/core/theme-preload.js"></script>
|
|
<link rel="stylesheet" href="./static/css/setup.css">
|
|
</head>
|
|
<body class="setup-page dark-mode">
|
|
<!-- Rotating backdrop layers -->
|
|
<div class="setup-backdrop-layer" id="backdropA"></div>
|
|
<div class="setup-backdrop-layer" id="backdropB"></div>
|
|
<div class="setup-backdrop-overlay"></div>
|
|
|
|
<div class="setup-layout">
|
|
<!-- ══════ Left Sidebar ══════ -->
|
|
<aside class="setup-sidebar">
|
|
<div class="setup-sidebar-brand">
|
|
<img src="./static/logo/256.png" alt="Huntarr" class="setup-sidebar-logo">
|
|
<span class="setup-sidebar-title">Huntarr</span>
|
|
<span class="setup-sidebar-subtitle">Setup</span>
|
|
</div>
|
|
<nav class="setup-progress-rail">
|
|
<div class="setup-stepper">
|
|
<div id="step1" class="setup-stepper-item active">
|
|
<div class="setup-step-circle">1</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">Account</span>
|
|
<span class="setup-step-sublabel">Create admin credentials</span>
|
|
</div>
|
|
</div>
|
|
<div id="step2" class="setup-stepper-item">
|
|
<div class="setup-step-circle">2</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">2FA</span>
|
|
<span class="setup-step-sublabel">Two-factor security</span>
|
|
</div>
|
|
</div>
|
|
<div id="step3" class="setup-stepper-item">
|
|
<div class="setup-step-circle">3</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">Plex</span>
|
|
<span class="setup-step-sublabel">Optional integration</span>
|
|
</div>
|
|
</div>
|
|
<div id="step4" class="setup-stepper-item">
|
|
<div class="setup-step-circle">4</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">Auth</span>
|
|
<span class="setup-step-sublabel">Access settings</span>
|
|
</div>
|
|
</div>
|
|
<div id="step5" class="setup-stepper-item">
|
|
<div class="setup-step-circle">5</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">Recovery</span>
|
|
<span class="setup-step-sublabel">Backup key</span>
|
|
</div>
|
|
</div>
|
|
<div id="step6" class="setup-stepper-item">
|
|
<div class="setup-step-circle">6</div>
|
|
<div class="setup-step-info">
|
|
<span class="setup-step-label">Finish</span>
|
|
<span class="setup-step-sublabel">Ready to go</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
<div class="setup-sidebar-footer">
|
|
<span>Powered by Huntarr</span>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- ══════ Right Content ══════ -->
|
|
<main class="setup-content">
|
|
<!-- Mobile header (visible only on small screens) -->
|
|
<div class="setup-mobile-header">
|
|
<img src="./static/logo/256.png" alt="Huntarr" class="setup-mobile-logo">
|
|
<span>Huntarr Setup</span>
|
|
</div>
|
|
<div class="setup-content-inner">
|
|
|
|
<!-- Step 1: Create Account -->
|
|
<div id="accountSetup" class="setup-section active">
|
|
<h2>Welcome to Huntarr</h2>
|
|
<p class="setup-step-desc">Let's create your admin account to get started.</p>
|
|
<div class="form-group">
|
|
<label for="username">
|
|
<i class="fas fa-user"></i>
|
|
<span>Username</span>
|
|
</label>
|
|
<input type="text" id="username" name="username" placeholder="Choose a username" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="password">
|
|
<i class="fas fa-lock"></i>
|
|
<span>Password</span>
|
|
</label>
|
|
<input type="password" id="password" name="password" placeholder="Minimum 8 characters" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="confirm_password">
|
|
<i class="fas fa-check-circle"></i>
|
|
<span>Confirm Password</span>
|
|
</label>
|
|
<input type="password" id="confirm_password" name="confirm_password" placeholder="Re-enter your password" required>
|
|
</div>
|
|
<div class="error-message" id="errorMessage" style="display: none;"></div>
|
|
<div class="setup-step-actions">
|
|
<div></div>
|
|
<button type="button" id="accountNextButton" class="setup-btn-primary">
|
|
Continue <i class="fas fa-arrow-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Setup 2FA -->
|
|
<div id="twoFactorSetup" class="setup-section">
|
|
<h2>Two-Factor Authentication</h2>
|
|
<p class="setup-step-desc">Secure your account with an authenticator app.</p>
|
|
<div class="setup-2fa-layout">
|
|
<div class="setup-2fa-qr-area">
|
|
<p class="setup-2fa-instruction">Scan with your authenticator app</p>
|
|
<div class="qr-code" id="qrCode" title="Click to enlarge">
|
|
<img src="" alt="QR Code" style="display: none;">
|
|
</div>
|
|
<span class="qr-hint">Click to enlarge</span>
|
|
</div>
|
|
<div class="setup-2fa-manual-area">
|
|
<p class="setup-2fa-instruction">Or enter this code manually</p>
|
|
<div class="secret-key-container">
|
|
<div class="secret-key" id="secretKey">Generating...</div>
|
|
<button class="copy-button" onclick="copySecretKeySetup()">
|
|
<i class="fas fa-copy"></i> Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="setup-2fa-verify">
|
|
<div class="verify-input-group">
|
|
<label for="verificationCode">
|
|
<i class="fas fa-shield-alt"></i>
|
|
<span>Verification Code</span>
|
|
</label>
|
|
<input type="text" id="verificationCode" placeholder="Enter 6-digit code" maxlength="6" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<div class="error-message" id="twoFactorErrorMessage" style="display: none;"></div>
|
|
<div class="setup-step-actions">
|
|
<button id="skip2FALink" class="setup-btn-ghost">
|
|
Skip for now
|
|
</button>
|
|
<button type="button" id="twoFactorNextButton" class="setup-btn-primary">
|
|
Verify & Continue <i class="fas fa-arrow-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Plex Setup -->
|
|
<div id="plexSetup" class="setup-section">
|
|
<h2>Link Plex Account</h2>
|
|
<p class="setup-step-desc">Optional — connect your Plex account for seamless sign-in.</p>
|
|
<div class="setup-plex-cards">
|
|
<div class="setup-plex-card setup-plex-card-recommended" id="plexCardLink">
|
|
<span class="setup-plex-badge">Strongly Recommended</span>
|
|
<div class="setup-plex-card-icon"><i class="fas fa-link"></i></div>
|
|
<div class="setup-plex-card-body">
|
|
<strong>Link Plex Account</strong>
|
|
<p>Use Plex as a secondary backup login — sign in even if you forget your password.</p>
|
|
</div>
|
|
</div>
|
|
<div class="setup-plex-card setup-plex-card-skip" id="plexCardSkip">
|
|
<div class="setup-plex-card-icon"><i class="fas fa-arrow-right"></i></div>
|
|
<div class="setup-plex-card-body">
|
|
<strong>Skip for Now</strong>
|
|
<p>You can always link your Plex account later in user settings.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Hidden buttons that JS references -->
|
|
<button type="button" id="linkPlexButton" style="display:none;"></button>
|
|
<button type="button" id="skipPlexButton" style="display:none;"></button>
|
|
<div id="plexLinkStatus" class="error-message" style="display: none;"></div>
|
|
</div>
|
|
|
|
<!-- Step 4: Auth Mode -->
|
|
<div id="authModeSetup" class="setup-section">
|
|
<h2>Authentication Mode</h2>
|
|
<p class="setup-step-desc">Choose how users will access Huntarr.</p>
|
|
|
|
<select id="auth_mode" style="display:none;">
|
|
<option value="login" selected>Login Mode</option>
|
|
<option value="local_bypass">Local Bypass Mode</option>
|
|
<option value="no_login">No Login Mode</option>
|
|
</select>
|
|
|
|
<div class="radio-cards" id="authModeCards">
|
|
<div class="radio-card selected" data-value="login">
|
|
<div class="radio-card-indicator"></div>
|
|
<i class="fas fa-shield-alt radio-card-icon"></i>
|
|
<div class="radio-card-body">
|
|
<div class="radio-card-title">Login Mode</div>
|
|
<div class="radio-card-desc">Standard login required for all connections. Recommended for most users.</div>
|
|
</div>
|
|
</div>
|
|
<div class="radio-card" data-value="local_bypass">
|
|
<div class="radio-card-indicator"></div>
|
|
<i class="fas fa-house-signal radio-card-icon"></i>
|
|
<div class="radio-card-body">
|
|
<div class="radio-card-title">Local Bypass Mode</div>
|
|
<div class="radio-card-desc">Local network connections bypass login. External access still requires authentication.</div>
|
|
</div>
|
|
</div>
|
|
<div class="radio-card" data-value="no_login">
|
|
<div class="radio-card-indicator"></div>
|
|
<i class="fas fa-lock-open radio-card-icon"></i>
|
|
<div class="radio-card-body">
|
|
<div class="radio-card-title">No Login Mode</div>
|
|
<div class="radio-card-desc">Disable authentication. Only recommended behind a secured reverse proxy.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="auth-warning" id="authWarning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<strong>Warning:</strong> Only use No Login Mode if your reverse proxy is properly securing access!
|
|
</div>
|
|
|
|
<div class="error-message" id="authModeErrorMessage" style="display: none;"></div>
|
|
<div class="setup-step-actions">
|
|
<div></div>
|
|
<button type="button" id="authModeNextButton" class="setup-btn-primary">
|
|
Continue <i class="fas fa-arrow-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 5: Recovery Key -->
|
|
<div id="recoveryKeySetup" class="setup-section">
|
|
<h2>Recovery Key</h2>
|
|
<p class="setup-step-desc">Save this key — it's the only way to recover your account.</p>
|
|
|
|
<div id="recoveryKeyDisplay">
|
|
<div class="setup-key-display">
|
|
<div class="setup-key-top-row">
|
|
<i class="fas fa-shield-alt"></i>
|
|
<div class="setup-key-info">
|
|
This is the <strong>only way</strong> to recover your account if you lose your password or 2FA device. Huntarr cannot reset your credentials or assist with account recovery — your security is fully self-sovereign to harden against cybersecurity attacks.
|
|
</div>
|
|
</div>
|
|
<div class="setup-key-value">
|
|
<span id="recoveryKeyValue">Generating...</span>
|
|
</div>
|
|
<div class="setup-key-actions-row">
|
|
<button type="button" id="copyRecoveryKeyButton" class="copy-button">
|
|
<i class="fas fa-copy"></i> Copy Key
|
|
</button>
|
|
<span class="setup-key-warning-inline">
|
|
<i class="fas fa-exclamation-triangle"></i> Will not be shown again
|
|
</span>
|
|
</div>
|
|
|
|
<div id="countdownSection" style="display: none;">
|
|
<div class="setup-countdown">
|
|
<i class="fas fa-clock"></i>
|
|
<span>Copy/save your key</span>
|
|
<span class="countdown-number" id="countdownTimer">10</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="confirmationSection" style="display: none;">
|
|
<div class="setup-confirm-prompt">
|
|
<span><i class="fas fa-question-circle"></i> Have you copied your recovery key?</span>
|
|
<div class="setup-confirm-buttons">
|
|
<button type="button" id="confirmCopiedButton" class="setup-btn-primary">
|
|
<i class="fas fa-check"></i> Yes, I've copied it
|
|
</button>
|
|
<button type="button" id="needMoreTimeButton" class="setup-btn-ghost">
|
|
<i class="fas fa-clock"></i> More time
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="recoveryKeyStatus" class="error-message" style="display: none;"></div>
|
|
</div>
|
|
|
|
<!-- Step 6: Finish -->
|
|
<div id="setupComplete" class="setup-section">
|
|
<div class="setup-finish-content">
|
|
<div class="setup-finish-icon">
|
|
<i class="fas fa-check-circle"></i>
|
|
</div>
|
|
<h2>You're All Set</h2>
|
|
<p class="setup-step-desc">Your Huntarr account is ready to go.</p>
|
|
|
|
<div class="setup-finish-card">
|
|
<i class="fas fa-heart thankyou-heart"></i>
|
|
<p class="finish-title">Thank You For Using Huntarr</p>
|
|
<p class="finish-body">
|
|
If you love the program, any future donation to my daughter's college fund would make her day! Regardless, enjoy Huntarr and please join us on Discord and Reddit for community support.
|
|
</p>
|
|
<p class="finish-sig">- Admin9705 & Huntarr Crew</p>
|
|
</div>
|
|
|
|
<div class="setup-special-thanks">
|
|
<p class="special-thanks-title">Special Thanks</p>
|
|
<div class="special-thanks-list">
|
|
<a href="https://github.com/nwithan8" target="_blank" rel="noopener noreferrer" class="special-thanks-item">
|
|
<i class="fab fa-github"></i>
|
|
<span>Nwithan8</span>
|
|
</a>
|
|
<a href="https://www.reddit.com/r/unRAID/" target="_blank" rel="noopener noreferrer" class="special-thanks-item">
|
|
<i class="fab fa-reddit-alien"></i>
|
|
<span>r/unRAID Community</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setup-step-actions" style="justify-content: center;">
|
|
<button type="button" id="finishSetupButton" class="setup-btn-primary setup-btn-lg">
|
|
<i class="fas fa-home"></i> Go to Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- QR Code Modal -->
|
|
<div id="qrModal" class="qr-modal">
|
|
<div class="qr-modal-content">
|
|
<button class="qr-modal-close" onclick="closeQRModal()">×</button>
|
|
<img id="qrModalImage" src="" alt="Enlarged QR Code">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plex linking now uses dynamic popup overlay (no static modal needed) -->
|
|
|
|
<!-- Pass base URL configuration to JavaScript -->
|
|
<script>window.HUNTARR_BASE_URL = '{{ base_url|default("", true) }}';</script>
|
|
{% include 'components/scripts.html' %}
|
|
<script>
|
|
// Global variables
|
|
let currentPlexPinId = null;
|
|
let plexPinCheckInterval = null;
|
|
let userData = { username: '', password: '' };
|
|
|
|
// Wire up Plex card clicks to hidden buttons
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var plexCardLink = document.getElementById('plexCardLink');
|
|
var plexCardSkip = document.getElementById('plexCardSkip');
|
|
if (plexCardLink) plexCardLink.addEventListener('click', function() {
|
|
document.getElementById('linkPlexButton').click();
|
|
});
|
|
if (plexCardSkip) plexCardSkip.addEventListener('click', function() {
|
|
document.getElementById('skipPlexButton').click();
|
|
});
|
|
});
|
|
|
|
// Global Plex linking functions — popup-based flow (no redirect)
|
|
function startPlexLinking() {
|
|
// Create popup overlay (same style as requestarr-users)
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'setup-plex-link-overlay';
|
|
overlay.style.cssText = 'position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.7);backdrop-filter:blur(5px);display:flex;align-items:center;justify-content:center;';
|
|
overlay.innerHTML = '<div style="background:linear-gradient(180deg,rgba(22,26,34,0.98),rgba(18,22,30,0.95));border-radius:15px;padding:30px;width:400px;max-width:90%;box-shadow:0 8px 30px rgba(0,0,0,0.5);border:1px solid rgba(90,109,137,0.15);color:#f8f9fa;text-align:center;">'
|
|
+ '<div style="font-size:40px;color:#e69500;margin-bottom:10px;"><i class="fas fa-tv"></i></div>'
|
|
+ '<h2 style="margin:0 0 15px;">Link Plex Account</h2>'
|
|
+ '<div id="setup-plex-link-status" class="plex-status waiting" style="margin:15px 0;padding:10px;border-radius:8px;background:rgba(255,193,7,0.2);border:1px solid rgba(255,193,7,0.3);color:#ffc107;">'
|
|
+ '<i class="fas fa-spinner fa-spin"></i> Preparing Plex authentication...</div>'
|
|
+ '<button id="setup-plex-link-cancel" class="action-button secondary-button" style="margin-top:10px;">Cancel</button>'
|
|
+ '</div>';
|
|
document.body.appendChild(overlay);
|
|
|
|
const statusEl = document.getElementById('setup-plex-link-status');
|
|
const cancelBtn = document.getElementById('setup-plex-link-cancel');
|
|
let plexPopup = null;
|
|
let pollInterval = null;
|
|
|
|
function cleanup() {
|
|
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
|
if (plexPopup && !plexPopup.closed) plexPopup.close();
|
|
plexPopup = null;
|
|
const el = document.getElementById('setup-plex-link-overlay');
|
|
if (el) el.remove();
|
|
currentPlexPinId = null;
|
|
}
|
|
|
|
cancelBtn.addEventListener('click', cleanup);
|
|
overlay.addEventListener('click', function(e) { if (e.target === overlay) cleanup(); });
|
|
|
|
HuntarrUtils.fetchWithTimeout('./api/auth/plex/pin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ user_mode: true, popup_mode: true, setup_mode: true })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + (data.error || 'Failed to create PIN');
|
|
return;
|
|
}
|
|
currentPlexPinId = data.pin_id;
|
|
statusEl.innerHTML = '<i class="fas fa-external-link-alt"></i> A Plex window has opened. Please sign in there.';
|
|
|
|
// Open popup window
|
|
var w = 600, h = 700;
|
|
var left = Math.max(0, Math.round(window.screenX + (window.outerWidth - w) / 2));
|
|
var top = Math.max(0, Math.round(window.screenY + (window.outerHeight - h) / 2));
|
|
plexPopup = window.open(data.auth_url, 'PlexAuth', 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top + ',toolbar=no,menubar=no,scrollbars=yes');
|
|
|
|
// Poll for claim
|
|
pollInterval = setInterval(function() {
|
|
fetch('./api/auth/plex/check/' + currentPlexPinId)
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.success && d.claimed) {
|
|
clearInterval(pollInterval); pollInterval = null;
|
|
if (plexPopup && !plexPopup.closed) plexPopup.close();
|
|
statusEl.className = 'plex-status success';
|
|
statusEl.style.background = 'rgba(40,167,69,0.2)';
|
|
statusEl.style.borderColor = 'rgba(40,167,69,0.3)';
|
|
statusEl.style.color = '#2ecc71';
|
|
statusEl.innerHTML = '<i class="fas fa-check"></i> Plex authenticated! Linking account...';
|
|
|
|
// Resolve username
|
|
var username = '';
|
|
var usernameInput = document.getElementById('username');
|
|
if (usernameInput && usernameInput.value) username = usernameInput.value.trim();
|
|
else if (userData && userData.username) username = userData.username;
|
|
else { var s = localStorage.getItem('huntarr-setup-username'); if (s) username = s; }
|
|
|
|
fetch('./api/auth/plex/link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ username: username, token: d.token, setup_mode: true })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(linkResult) {
|
|
if (linkResult.success) {
|
|
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> Plex account linked!';
|
|
setTimeout(function() {
|
|
cleanup();
|
|
if (window.saveSetupProgress) window.saveSetupProgress({ current_step: 4, plex_setup_done: true, completed_steps: [1, 2, 3] });
|
|
if (window.showStep) window.showStep(4);
|
|
}, 1500);
|
|
} else {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.style.background = 'rgba(220,53,69,0.2)';
|
|
statusEl.style.borderColor = 'rgba(220,53,69,0.3)';
|
|
statusEl.style.color = '#ff6b6b';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + (linkResult.error || 'Linking failed');
|
|
}
|
|
})
|
|
.catch(function() {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Network error linking account';
|
|
});
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}, 2000);
|
|
|
|
// 10 min timeout
|
|
setTimeout(function() {
|
|
if (pollInterval) { cleanup(); }
|
|
}, 600000);
|
|
})
|
|
.catch(function() {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Network error: Unable to connect to Plex.';
|
|
});
|
|
}
|
|
|
|
function openQRModal(imageSrc) {
|
|
const modal = document.getElementById('qrModal');
|
|
document.getElementById('qrModalImage').src = imageSrc;
|
|
modal.style.display = 'block';
|
|
}
|
|
function closeQRModal() { document.getElementById('qrModal').style.display = 'none'; }
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const steps = document.querySelectorAll('.setup-stepper-item');
|
|
const screens = document.querySelectorAll('.setup-section');
|
|
const errorMessage = document.getElementById('errorMessage');
|
|
const usernameInput = document.getElementById('username');
|
|
const passwordInput = document.getElementById('password');
|
|
const confirmPasswordInput = document.getElementById('confirm_password');
|
|
const accountNextButton = document.getElementById('accountNextButton');
|
|
const qrCodeElement = document.getElementById('qrCode');
|
|
const secretKeyElement = document.getElementById('secretKey');
|
|
const verificationCodeInput = document.getElementById('verificationCode');
|
|
const skip2FALink = document.getElementById('skip2FALink');
|
|
const twoFactorNextButton = document.getElementById('twoFactorNextButton');
|
|
const authModeSelect = document.getElementById('auth_mode');
|
|
const authModeNextButton = document.getElementById('authModeNextButton');
|
|
const authModeErrorMessage = document.getElementById('authModeErrorMessage');
|
|
const authModeCards = document.getElementById('authModeCards');
|
|
const authWarning = document.getElementById('authWarning');
|
|
const linkPlexButton = document.getElementById('linkPlexButton');
|
|
const skipPlexButton = document.getElementById('skipPlexButton');
|
|
const finishSetupButton = document.getElementById('finishSetupButton');
|
|
|
|
if (verificationCodeInput) verificationCodeInput.value = '';
|
|
|
|
let currentStep = 1;
|
|
let accountCreated = false;
|
|
let twoFactorEnabled = false;
|
|
|
|
if (authModeCards) {
|
|
authModeCards.addEventListener('click', function(e) {
|
|
const card = e.target.closest('.radio-card');
|
|
if (!card) return;
|
|
authModeCards.querySelectorAll('.radio-card').forEach(c => c.classList.remove('selected'));
|
|
card.classList.add('selected');
|
|
authModeSelect.value = card.dataset.value;
|
|
if (authWarning) authWarning.classList.toggle('visible', card.dataset.value === 'no_login');
|
|
});
|
|
}
|
|
|
|
function generate2FA() {
|
|
secretKeyElement.textContent = 'Generating...';
|
|
const qrCodeImg = qrCodeElement.querySelector('img');
|
|
if (qrCodeImg) qrCodeImg.style.display = 'none';
|
|
HuntarrUtils.fetchWithTimeout('./api/user/2fa/setup', { method: 'POST' })
|
|
.then(response => {
|
|
if (response.status === 401) throw new Error('Unauthorized');
|
|
if (!response.ok) return response.json().then(d => { throw new Error(d.error || 'Server error: ' + response.status); }).catch(() => { throw new Error('Server error: ' + response.status); });
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.success) {
|
|
const qrCodeImg = qrCodeElement.querySelector('img');
|
|
if (qrCodeImg) {
|
|
qrCodeImg.src = data.qr_code_url;
|
|
qrCodeImg.style.display = 'block';
|
|
qrCodeElement.addEventListener('click', function() { openQRModal(data.qr_code_url); });
|
|
} else {
|
|
qrCodeElement.innerHTML = '<img src="' + data.qr_code_url + '" alt="QR Code" style="display:block;max-width:100%;height:auto;">';
|
|
qrCodeElement.addEventListener('click', function() { openQRModal(data.qr_code_url); });
|
|
}
|
|
secretKeyElement.textContent = data.secret;
|
|
} else {
|
|
secretKeyElement.textContent = 'Failed to generate';
|
|
}
|
|
})
|
|
.catch(error => { secretKeyElement.textContent = 'Failed to generate'; });
|
|
}
|
|
|
|
window.showStep = function(step) {
|
|
steps.forEach((s, index) => {
|
|
if (index + 1 < step) {
|
|
s.classList.remove('active'); s.classList.add('completed');
|
|
const circle = s.querySelector('.setup-step-circle');
|
|
if (circle) circle.innerHTML = '<i class="fas fa-check" style="font-size: 14px;"></i>';
|
|
} else if (index + 1 === step) {
|
|
s.classList.add('active'); s.classList.remove('completed');
|
|
const circle = s.querySelector('.setup-step-circle');
|
|
if (circle) circle.textContent = String(index + 1);
|
|
} else {
|
|
s.classList.remove('active'); s.classList.remove('completed');
|
|
const circle = s.querySelector('.setup-step-circle');
|
|
if (circle) circle.textContent = String(index + 1);
|
|
}
|
|
});
|
|
var sectionIds = ['accountSetup','twoFactorSetup','plexSetup','authModeSetup','recoveryKeySetup','setupComplete'];
|
|
sectionIds.forEach(function(id, i) {
|
|
var el = document.getElementById(id);
|
|
if (el) { if (i + 1 === step) el.classList.add('active'); else el.classList.remove('active'); }
|
|
});
|
|
if (step === 2) {
|
|
if (verificationCodeInput) verificationCodeInput.value = '';
|
|
setTimeout(() => generate2FA(), 500);
|
|
}
|
|
if (step === 5) setTimeout(() => autoGenerateRecoveryKey(), 500);
|
|
currentStep = step;
|
|
};
|
|
|
|
function showError(message) {
|
|
errorMessage.textContent = message;
|
|
errorMessage.style.display = 'block';
|
|
setTimeout(() => { errorMessage.style.display = 'none'; }, 5000);
|
|
}
|
|
function show2FAError(message) {
|
|
const el = document.getElementById('twoFactorErrorMessage');
|
|
if (el) { el.textContent = message; el.style.display = 'block'; setTimeout(() => { el.style.display = 'none'; }, 5000); }
|
|
}
|
|
function validatePassword(password) {
|
|
if (password.length < 8) return 'Password must be at least 8 characters long.';
|
|
return null;
|
|
}
|
|
|
|
accountNextButton.addEventListener('click', function() {
|
|
const username = usernameInput.value.trim();
|
|
const password = passwordInput.value;
|
|
const confirmPassword = confirmPasswordInput.value;
|
|
if (!username || !password || !confirmPassword) { showError('All fields are required'); return; }
|
|
if (username.length < 3) { showError('Username must be at least 3 characters long'); return; }
|
|
if (password !== confirmPassword) { showError('Passwords do not match'); return; }
|
|
const passwordError = validatePassword(password);
|
|
if (passwordError) { showError(passwordError); return; }
|
|
userData.username = username;
|
|
userData.password = password;
|
|
if (accountCreated) { showStep(2); return; }
|
|
const setupUrl = (window.HUNTARR_BASE_URL || '') + '/setup';
|
|
const setupFullUrl = setupUrl.startsWith('http') ? setupUrl : (window.location.origin + (setupUrl.startsWith('/') ? setupUrl : '/' + setupUrl));
|
|
HuntarrUtils.fetchWithTimeout(setupFullUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: username, password: password, confirm_password: confirmPassword })
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
const ct = response.headers.get('content-type');
|
|
if (ct && ct.indexOf('application/json') !== -1) return response.json().then(d => { throw new Error(d.error || d.message || 'Server error: ' + response.status); });
|
|
else throw new Error('HTTP error! status: ' + response.status);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.success) {
|
|
accountCreated = true;
|
|
if (usernameInput && usernameInput.value) localStorage.setItem('huntarr-setup-username', usernameInput.value.trim());
|
|
showStep(2);
|
|
} else { showError(data.error || 'Account creation failed.'); }
|
|
})
|
|
.catch(error => { showError('Error: ' + error.message); });
|
|
});
|
|
|
|
twoFactorNextButton.addEventListener('click', function() {
|
|
const code = verificationCodeInput.value;
|
|
if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) { show2FAError('Please enter a valid 6-digit verification code'); return; }
|
|
HuntarrUtils.fetchWithTimeout('./api/user/2fa/verify', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code })
|
|
})
|
|
.then(response => response.json().then(data => {
|
|
if (response.ok && data.success) {
|
|
twoFactorEnabled = true;
|
|
saveSetupProgress({ current_step: 3, two_factor_enabled: true, completed_steps: [1, 2] });
|
|
showStep(3);
|
|
} else { show2FAError(data.error || data.message || 'Invalid verification code'); }
|
|
}))
|
|
.catch(error => { show2FAError('Failed to verify code'); });
|
|
});
|
|
|
|
skip2FALink.addEventListener('click', function() {
|
|
saveSetupProgress({ current_step: 3, completed_steps: [1, 2] });
|
|
showStep(3);
|
|
});
|
|
|
|
authModeNextButton.addEventListener('click', function() {
|
|
HuntarrUtils.fetchWithTimeout('./api/settings/general', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ base_url: window.HUNTARR_BASE_URL, auth_mode: authModeSelect.value, local_access_bypass: authModeSelect.value === 'local_bypass', proxy_auth_bypass: authModeSelect.value === 'no_login' })
|
|
})
|
|
.then(response => { if (!response.ok) return response.json().then(d => { throw new Error(d.error || 'Error: ' + response.status); }); return response.json(); })
|
|
.then(data => { saveSetupProgress({ current_step: 5, auth_mode_selected: true, completed_steps: [1, 2, 3, 4] }); showStep(5); })
|
|
.catch(error => { authModeErrorMessage.textContent = error.message || 'Error saving settings'; authModeErrorMessage.style.display = 'block'; setTimeout(() => { authModeErrorMessage.style.display = 'none'; }, 5000); });
|
|
});
|
|
|
|
// Recovery key
|
|
const copyRecoveryKeyButton = document.getElementById('copyRecoveryKeyButton');
|
|
const confirmCopiedButton = document.getElementById('confirmCopiedButton');
|
|
const needMoreTimeButton = document.getElementById('needMoreTimeButton');
|
|
let countdownInterval = null;
|
|
let recoveryKeyGenerated = false;
|
|
|
|
function autoGenerateRecoveryKey() {
|
|
if (recoveryKeyGenerated) return;
|
|
const url = (window.HUNTARR_BASE_URL || '') + '/auth/recovery-key/generate';
|
|
const fullUrl = url.startsWith('http') ? url : (window.location.origin + (url.startsWith('/') ? url : '/' + url));
|
|
HuntarrUtils.fetchWithTimeout(fullUrl, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: userData.password, setup_mode: true })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) { document.getElementById('recoveryKeyValue').textContent = data.recovery_key; recoveryKeyGenerated = true; startCountdown(); }
|
|
else { showRecoveryKeyStatus(data.error || 'Failed to generate recovery key', 'error'); }
|
|
})
|
|
.catch(error => { showRecoveryKeyStatus('Error: ' + error.message, 'error'); });
|
|
}
|
|
|
|
function startCountdown() {
|
|
let timeLeft = 10;
|
|
document.getElementById('countdownSection').style.display = 'block';
|
|
document.getElementById('confirmationSection').style.display = 'none';
|
|
countdownInterval = setInterval(() => {
|
|
document.getElementById('countdownTimer').textContent = timeLeft;
|
|
timeLeft--;
|
|
if (timeLeft < 0) { clearInterval(countdownInterval); document.getElementById('countdownSection').style.display = 'none'; document.getElementById('confirmationSection').style.display = 'block'; }
|
|
}, 1000);
|
|
}
|
|
|
|
copyRecoveryKeyButton.addEventListener('click', function() {
|
|
const key = document.getElementById('recoveryKeyValue').textContent;
|
|
if (!key || key === 'Generating...') return;
|
|
const btn = this;
|
|
function showCopied() { const o = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check"></i> Copied!'; btn.style.backgroundColor = '#28a745'; setTimeout(() => { btn.innerHTML = o; btn.style.backgroundColor = ''; }, 2000); }
|
|
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(key).then(showCopied).catch(() => fallbackCopy(key, showCopied));
|
|
else fallbackCopy(key, showCopied);
|
|
});
|
|
|
|
function fallbackCopy(text, onSuccess) {
|
|
const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
document.body.appendChild(ta); ta.select();
|
|
try { document.execCommand('copy'); if (onSuccess) onSuccess(); } catch (e) {}
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
confirmCopiedButton.addEventListener('click', function() {
|
|
if (countdownInterval) clearInterval(countdownInterval);
|
|
saveSetupProgress({ current_step: 6, recovery_key_generated: true, completed_steps: [1, 2, 3, 4, 5] });
|
|
showStep(6);
|
|
});
|
|
needMoreTimeButton.addEventListener('click', function() { if (countdownInterval) clearInterval(countdownInterval); startCountdown(); });
|
|
|
|
function showRecoveryKeyStatus(message, type) {
|
|
const el = document.getElementById('recoveryKeyStatus');
|
|
el.textContent = message; el.className = type === 'success' ? 'error-message success' : 'error-message';
|
|
el.style.display = 'block'; setTimeout(() => { el.style.display = 'none'; }, 5000);
|
|
}
|
|
|
|
linkPlexButton.addEventListener('click', function() { startPlexLinking(); });
|
|
skipPlexButton.addEventListener('click', function() {
|
|
saveSetupProgress({ current_step: 4, plex_setup_done: true, completed_steps: [1, 2, 3] });
|
|
showStep(4);
|
|
});
|
|
|
|
finishSetupButton.addEventListener('click', async function() {
|
|
localStorage.removeItem('huntarr-setup-username');
|
|
localStorage.removeItem('huntarr-plex-linking');
|
|
localStorage.removeItem('huntarr-plex-pin-id');
|
|
await clearSetupProgress();
|
|
window.location.href = './';
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (currentStep === 1) accountNextButton.click();
|
|
else if (currentStep === 2) twoFactorNextButton.click();
|
|
else if (currentStep === 3) linkPlexButton.click();
|
|
else if (currentStep === 4) authModeNextButton.click();
|
|
else if (currentStep === 5 && document.getElementById('confirmationSection').style.display !== 'none') confirmCopiedButton.click();
|
|
else if (currentStep === 6) finishSetupButton.click();
|
|
}
|
|
});
|
|
|
|
document.body.classList.add('dark-mode');
|
|
localStorage.setItem('huntarr-dark-mode', 'true');
|
|
|
|
// Progress persistence
|
|
async function loadSetupProgress() {
|
|
try {
|
|
const response = await fetch('./api/setup/progress');
|
|
if (response.ok) { const data = await response.json(); if (data.success && data.progress) { restoreSetupState(data.progress); return; } }
|
|
const local = localStorage.getItem('huntarr-setup-progress');
|
|
if (local) { try { restoreSetupState(JSON.parse(local)); } catch (e) {} }
|
|
} catch (error) {}
|
|
}
|
|
function restoreSetupState(progress) {
|
|
if (progress.account_created) accountCreated = true;
|
|
if (progress.two_factor_enabled) twoFactorEnabled = true;
|
|
if (progress.username && usernameInput) usernameInput.value = progress.username;
|
|
if (progress.current_step) showStep(progress.current_step);
|
|
else showStep(1);
|
|
}
|
|
window.saveSetupProgress = async function saveSetupProgress(stepData) {
|
|
stepData = stepData || {};
|
|
const progress = { current_step: currentStep, completed_steps: getCompletedSteps(), account_created: accountCreated, two_factor_enabled: twoFactorEnabled, username: usernameInput ? usernameInput.value : null, timestamp: new Date().toISOString(), ...stepData };
|
|
localStorage.setItem('huntarr-setup-progress', JSON.stringify(progress));
|
|
try { await fetch('./api/setup/progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ progress }) }); } catch (e) {}
|
|
}
|
|
function getCompletedSteps() {
|
|
const completed = [];
|
|
document.querySelectorAll('.setup-stepper-item.completed').forEach((s, i) => { completed.push(i + 1); });
|
|
return completed;
|
|
}
|
|
async function clearSetupProgress() {
|
|
localStorage.removeItem('huntarr-setup-progress');
|
|
try { await fetch('./api/setup/clear', { method: 'POST' }); } catch (e) {}
|
|
}
|
|
|
|
loadSetupProgress();
|
|
|
|
// Plex linking now uses popup flow — no redirect return handling needed
|
|
// Clean up any stale localStorage from old redirect flow
|
|
localStorage.removeItem('huntarr-plex-linking');
|
|
localStorage.removeItem('huntarr-plex-pin-id');
|
|
localStorage.removeItem('huntarr-plex-setup-mode');
|
|
|
|
document.addEventListener('click', function(e) { if (e.target === document.getElementById('qrModal')) closeQRModal(); });
|
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeQRModal(); });
|
|
});
|
|
|
|
function copySecretKeySetup() {
|
|
const secretKey = document.getElementById('secretKey').textContent;
|
|
if (!secretKey || secretKey === 'Generating...') return;
|
|
function showCopied() {
|
|
const btn = document.querySelector('#twoFactorSetup .copy-button');
|
|
if (!btn) return; const o = btn.innerHTML;
|
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!'; btn.style.color = '#28a745';
|
|
setTimeout(() => { btn.innerHTML = o; btn.style.color = ''; }, 2000);
|
|
}
|
|
function fallback(text, cb) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); if (cb) cb(); } catch (e) {} document.body.removeChild(ta); }
|
|
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(secretKey).then(showCopied).catch(() => fallback(secretKey, showCopied));
|
|
else fallback(secretKey, showCopied);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
// Rotating Backdrop — TMDB trending images
|
|
// ════════════════════════════════════════════════════════
|
|
(function() {
|
|
var layerA = document.getElementById('backdropA');
|
|
var layerB = document.getElementById('backdropB');
|
|
if (!layerA || !layerB) return;
|
|
|
|
var images = [];
|
|
var preloaded = {};
|
|
var currentIndex = -1;
|
|
var activeLayer = 'A'; // which layer is currently visible
|
|
var INTERVAL = 10000; // 10 seconds between transitions
|
|
var rotationTimer = null;
|
|
|
|
// Shuffle array (Fisher-Yates)
|
|
function shuffle(arr) {
|
|
for (var i = arr.length - 1; i > 0; i--) {
|
|
var j = Math.floor(Math.random() * (i + 1));
|
|
var t = arr[i]; arr[i] = arr[j]; arr[j] = t;
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Preload an image and return a promise
|
|
function preload(url) {
|
|
if (preloaded[url]) return Promise.resolve(url);
|
|
return new Promise(function(resolve) {
|
|
var img = new Image();
|
|
img.onload = function() { preloaded[url] = true; resolve(url); };
|
|
img.onerror = function() { resolve(null); };
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
// Preload the next N images ahead
|
|
function preloadAhead(fromIndex, count) {
|
|
for (var i = 0; i < count; i++) {
|
|
var idx = (fromIndex + i) % images.length;
|
|
preload(images[idx]);
|
|
}
|
|
}
|
|
|
|
// Crossfade to the next image
|
|
function showNext() {
|
|
if (images.length === 0) return;
|
|
currentIndex = (currentIndex + 1) % images.length;
|
|
var url = images[currentIndex];
|
|
|
|
// Determine which layer to fade IN (the one currently hidden)
|
|
var showLayer, hideLayer;
|
|
if (activeLayer === 'A') {
|
|
showLayer = layerB; hideLayer = layerA;
|
|
activeLayer = 'B';
|
|
} else {
|
|
showLayer = layerA; hideLayer = layerB;
|
|
activeLayer = 'A';
|
|
}
|
|
|
|
// Set the image on the incoming layer (still invisible)
|
|
showLayer.style.backgroundImage = 'url(' + url + ')';
|
|
|
|
// Trigger reflow so the transition works
|
|
void showLayer.offsetWidth;
|
|
|
|
// Crossfade: bring the new layer in, fade the old one out
|
|
showLayer.classList.add('visible');
|
|
hideLayer.classList.remove('visible');
|
|
|
|
// Preload next few
|
|
preloadAhead(currentIndex + 1, 3);
|
|
}
|
|
|
|
// Fetch backdrop URLs and start rotation
|
|
function init() {
|
|
fetch('./api/setup/backdrops')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success || !data.images || data.images.length === 0) return;
|
|
images = shuffle(data.images.slice());
|
|
|
|
// Preload first 3 images, then start
|
|
Promise.all([preload(images[0]), preload(images[1] || images[0]), preload(images[2] || images[0])])
|
|
.then(function() {
|
|
showNext(); // show first image immediately
|
|
rotationTimer = setInterval(showNext, INTERVAL);
|
|
});
|
|
})
|
|
.catch(function() { /* fail silently — no backdrops is fine */ });
|
|
}
|
|
|
|
// Start when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|