From 5a8f0431c3814cf88dfd4dc2a08dfbab1434a28f Mon Sep 17 00:00:00 2001 From: master3395 Date: Sat, 7 Mar 2026 02:44:54 +0100 Subject: [PATCH 1/3] Plugins: add Upgrades Available tab for easier plugin updates - New tab between Table View and CyberPanel Plugin Store with badge count - Dedicated view listing only installed plugins with a newer store version - Table columns: Plugin Name, New Version, Your Version, Date, Action (Upgrade) - Notice to read release info and backup before upgrading - Badge and list populated from existing store API (update_available) --- .../templates/pluginHolder/plugins.html | 188 +++++++++++++++++- 1 file changed, 183 insertions(+), 5 deletions(-) diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index 9477ac53c..0c1aefb25 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -1035,6 +1035,38 @@ cursor: not-allowed; } + .upgrades-badge { + display: inline-block; + min-width: 20px; + height: 20px; + padding: 0 6px; + margin-left: 6px; + background: #f59e0b; + color: white; + border-radius: 10px; + font-size: 12px; + font-weight: 700; + line-height: 20px; + text-align: center; + } + + .upgrades-view-notice { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 20px; + display: flex; + align-items: flex-start; + gap: 12px; + } + + .upgrades-view-notice i { + color: #2563eb; + font-size: 20px; + margin-top: 2px; + } + .btn-link { padding: 6px 12px; background: var(--bg-secondary, #f8f9ff); @@ -1298,6 +1330,11 @@ {% trans "Table View" %} + + '; + html += '' + name + '' + newVer + '' + yourVer + '' + date + '' + actionHtml + ''; + }); + tbody.innerHTML = html; +} + +function loadPluginStore(forUpgradesView) { const storeLoading = document.getElementById('storeLoading'); const storeError = document.getElementById('storeError'); const storeErrorText = document.getElementById('storeErrorText'); const storeContent = document.getElementById('storeContent'); const storeTableBody = document.getElementById('storeTableBody'); + const upgradesLoading = document.getElementById('upgradesLoading'); if (!storeLoading || !storeError || !storeErrorText || !storeContent) return; storeLoading.style.display = 'block'; storeError.style.display = 'none'; storeContent.style.display = 'block'; + if (upgradesLoading) upgradesLoading.style.display = forUpgradesView ? 'block' : 'none'; fetch('/plugins/api/store/plugins/', { method: 'GET', @@ -1903,21 +2058,43 @@ function loadPluginStore() { }) .then(data => { storeLoading.style.display = 'none'; + const upgradesLoadingEl = document.getElementById('upgradesLoading'); + if (upgradesLoadingEl) upgradesLoadingEl.style.display = 'none'; if (data.success) { storePlugins = data.plugins; - displayStorePlugins(); + if (typeof updateUpgradesBadge === 'function') updateUpgradesBadge(); + const upgradesViewEl = document.getElementById('upgradesView'); + if (upgradesViewEl && upgradesViewEl.style.display === 'block' && typeof displayUpgradesAvailable === 'function') { + displayUpgradesAvailable(); + } else { + displayStorePlugins(); + } storeContent.style.display = 'block'; } else { storeErrorText.textContent = data.error || 'Failed to load plugins from store'; storeError.style.display = 'block'; + const upgradesError = document.getElementById('upgradesError'); + const upgradesErrorText = document.getElementById('upgradesErrorText'); + if (upgradesError && upgradesErrorText) { + upgradesErrorText.textContent = data.error || 'Failed to load plugin store'; + upgradesError.style.display = 'block'; + } } }) .catch(error => { storeLoading.style.display = 'none'; + const upgradesLoadingEl = document.getElementById('upgradesLoading'); + if (upgradesLoadingEl) upgradesLoadingEl.style.display = 'none'; storeErrorText.textContent = error.message || 'Error loading plugin store. Please refresh the page and try again.'; storeError.style.display = 'block'; storeContent.style.display = 'block'; + const upgradesError = document.getElementById('upgradesError'); + const upgradesErrorText = document.getElementById('upgradesErrorText'); + if (upgradesError && upgradesErrorText) { + upgradesErrorText.textContent = error.message || 'Error checking for upgrades.'; + upgradesError.style.display = 'block'; + } if (storeTableBody && storeTableBody.innerHTML === '') { storeTableBody.innerHTML = 'Unable to load plugins. Please check your connection and try again.'; } @@ -3158,12 +3335,13 @@ document.addEventListener('DOMContentLoaded', function() { // Check URL hash for view preference const hash = window.location.hash.substring(1); // Remove # - const validViews = ['grid', 'table', 'store']; + const validViews = ['grid', 'table', 'upgrades', 'store']; // Check if view elements exist before calling toggleView const gridView = document.getElementById('gridView'); const tableView = document.getElementById('tableView'); const storeView = document.getElementById('storeView'); + const upgradesViewEl = document.getElementById('upgradesView'); // Only proceed if all view elements exist (plugins are installed) if (gridView && tableView && storeView) { From 4be0bfd5aa76f204a7892dde4d7b2fa8c9efb285 Mon Sep 17 00:00:00 2001 From: master3395 Date: Sat, 7 Mar 2026 02:46:15 +0100 Subject: [PATCH 2/3] 2FA/WebAuthn, user management, deploy and fix scripts - loginSystem: WebAuthn (webauthn backend, models, urls, views), login template and webauthn.js - baseTemplate: index.html updates - docs: 2FA_AUTHENTICATION_GUIDE.md - userManagment: createUser/modifyUser templates, userManagment.js, views, tests; check_modify_users_page.py - requirments.txt: add webauthn>=2.0.0 - deploy-templates.sh: deploy templates/static to live CyberCP - fix-cyberpanel-500.sh: script for common HTTP 500 login fixes (MariaDB, configservercsf, cache, restart) --- .../templates/baseTemplate/index.html | 1 + deploy-templates.sh | 52 ++ docs/2FA_AUTHENTICATION_GUIDE.md | 2 + fix-cyberpanel-500.sh | 45 ++ loginSystem/static/loginSystem/webauthn.js | 156 ++-- loginSystem/templates/loginSystem/login.html | 24 +- loginSystem/views.py | 30 +- loginSystem/webauthn_backend.py | 689 ++++++++---------- loginSystem/webauthn_models.py | 6 +- loginSystem/webauthn_urls.py | 1 + loginSystem/webauthn_views.py | 99 ++- requirments.txt | 1 + userManagment/check_modify_users_page.py | 57 ++ .../static/userManagment/userManagment.js | 105 ++- .../templates/userManagment/createUser.html | 5 + .../templates/userManagment/modifyUser.html | 183 ++++- userManagment/tests.py | 13 + userManagment/views.py | 3 +- 18 files changed, 940 insertions(+), 532 deletions(-) create mode 100755 deploy-templates.sh create mode 100644 fix-cyberpanel-500.sh create mode 100644 userManagment/check_modify_users_page.py diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index b075692b2..a3a2f71b4 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -2221,6 +2221,7 @@ + diff --git a/deploy-templates.sh b/deploy-templates.sh new file mode 100755 index 000000000..544b8d995 --- /dev/null +++ b/deploy-templates.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Deploy updated templates (and related static) from this repo to live CyberPanel. +# Use after pulling template changes so the panel at /usr/local/CyberCP shows the new UI. +# Usage: sudo bash deploy-templates.sh + +set -e + +CYBERCP_ROOT="${CYBERCP_ROOT:-/usr/local/CyberCP}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "[$(date +%Y-%m-%d\ %H:%M:%S)] Deploying templates to $CYBERCP_ROOT..." + +# userManagment templates +for name in modifyUser createUser; do + SRC="$SCRIPT_DIR/userManagment/templates/userManagment/${name}.html" + DST="$CYBERCP_ROOT/userManagment/templates/userManagment/${name}.html" + if [ -f "$SRC" ]; then + cp -f "$SRC" "$DST" + echo " Copied userManagment ${name}.html" + fi +done + +# loginSystem login template +if [ -f "$SCRIPT_DIR/loginSystem/templates/loginSystem/login.html" ]; then + cp -f "$SCRIPT_DIR/loginSystem/templates/loginSystem/login.html" \ + "$CYBERCP_ROOT/loginSystem/templates/loginSystem/login.html" + echo " Copied loginSystem login.html" +fi + +# Optional: userManagment static (if you change JS) +if [ -f "$SCRIPT_DIR/userManagment/static/userManagment/userManagment.js" ]; then + mkdir -p "$CYBERCP_ROOT/userManagment/static/userManagment" + cp -f "$SCRIPT_DIR/userManagment/static/userManagment/userManagment.js" \ + "$CYBERCP_ROOT/userManagment/static/userManagment/userManagment.js" + echo " Copied userManagment.js" +fi +if [ -f "$SCRIPT_DIR/loginSystem/static/loginSystem/webauthn.js" ]; then + mkdir -p "$CYBERCP_ROOT/loginSystem/static/loginSystem" + cp -f "$SCRIPT_DIR/loginSystem/static/loginSystem/webauthn.js" \ + "$CYBERCP_ROOT/loginSystem/static/loginSystem/webauthn.js" + echo " Copied webauthn.js" +fi + +# Run collectstatic if manage.py exists (so static changes are served) +if [ -f "$CYBERCP_ROOT/manage.py" ]; then + PYTHON="${CYBERCP_ROOT}/bin/python" + [ -x "$PYTHON" ] || PYTHON="python3" + echo " Running collectstatic..." + (cd "$CYBERCP_ROOT" && "$PYTHON" manage.py collectstatic --noinput --clear 2>&1) | tail -5 +fi + +echo "[$(date +%Y-%m-%d\ %H:%M:%S)] Templates deployed. Hard-refresh (Ctrl+F5) on Modify User / Create User / Login if needed." diff --git a/docs/2FA_AUTHENTICATION_GUIDE.md b/docs/2FA_AUTHENTICATION_GUIDE.md index f240c1465..cdf71aaac 100644 --- a/docs/2FA_AUTHENTICATION_GUIDE.md +++ b/docs/2FA_AUTHENTICATION_GUIDE.md @@ -97,6 +97,8 @@ TOTP generates time-based codes that change every 30 seconds. Users scan a QR co ### What is WebAuthn? WebAuthn is a web standard that enables secure, passwordless authentication using public-key cryptography. It supports biometric authentication, security keys, and device passkeys. +**Login behaviour**: The login page supports **passkey-first** sign-in: users can click "Login with Passkey" without entering a username. Passkeys are managed under **User Management → Modify User**. The relying party ID (`rp_id`) and origin are derived from the current request host only (never hardcoded), so WebAuthn works on any domain or IP (e.g. `https://207.180.193.210:2087`). + ### Setting Up WebAuthn #### Prerequisites diff --git a/fix-cyberpanel-500.sh b/fix-cyberpanel-500.sh new file mode 100644 index 000000000..2e9a672d1 --- /dev/null +++ b/fix-cyberpanel-500.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# fix-cyberpanel-500.sh – Apply common fixes for CyberPanel HTTP 500 on login. +# Run on the server: sudo bash fix-cyberpanel-500.sh +# See: to-do/CYBERPANEL-HTTP-500-LOGIN-FIX.md + +set -e +LOG="/var/log/cyberpanel_500_fix.log" +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; } + +if [[ $EUID -ne 0 ]]; then + echo "Run as root: sudo bash $0" + exit 1 +fi + +log "=== CyberPanel 500 fix script started ===" + +# 0. Ensure MariaDB is running (common cause: DB down -> 500) +log "Step 0: Ensuring MariaDB is running..." +systemctl start mariadb 2>/dev/null || systemctl start mysql 2>/dev/null || true +systemctl enable mariadb 2>/dev/null || systemctl enable mysql 2>/dev/null || true +log "Step 0 done." + +# 1. Remove or neutralize configservercsf (common cause of 500) +log "Step 1: Cleaning configservercsf references..." +rm -rf /usr/local/CyberCP/configservercsf 2>/dev/null || true +rm -f /home/cyberpanel/plugins/configservercsf 2>/dev/null || true +rm -rf /usr/local/CyberCP/public/static/configservercsf 2>/dev/null || true +sed -i '/configservercsf/d' /usr/local/CyberCP/CyberCP/settings.py 2>/dev/null || true +sed -i '/configservercsf/d' /usr/local/CyberCP/CyberCP/urls.py 2>/dev/null || true +log "Step 1 done." + +# 2. Clear Python cache +log "Step 2: Clearing __pycache__..." +find /usr/local/CyberCP -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +log "Step 2 done." + +# 3. Restart panel and web server +log "Step 3: Restarting lscpd and lsws..." +systemctl restart lscpd +systemctl restart lsws +killall lsphp 2>/dev/null || true +log "Step 3 done." + +log "=== Fix script finished. Try https://YOUR_IP:2087 or :8090 ===" +log "If 500 persists, enable DEBUG in /usr/local/CyberCP/CyberCP/settings.py and check logs (see to-do/CYBERPANEL-HTTP-500-LOGIN-FIX.md)." diff --git a/loginSystem/static/loginSystem/webauthn.js b/loginSystem/static/loginSystem/webauthn.js index a9d4eddc5..9cc4ea5a4 100644 --- a/loginSystem/static/loginSystem/webauthn.js +++ b/loginSystem/static/loginSystem/webauthn.js @@ -10,6 +10,7 @@ class CyberPanelWebAuthn { this.apiEndpoints = { registrationStart: '/webauthn/registration/start/', registrationComplete: '/webauthn/registration/complete/', + authenticationOptions: '/webauthn/authentication/options/', authenticationStart: '/webauthn/authentication/start/', authenticationComplete: '/webauthn/authentication/complete/', credentialsList: '/webauthn/credentials/', @@ -60,18 +61,10 @@ class CyberPanelWebAuthn { addLoginButtons() { const loginForm = document.querySelector('#loginForm'); if (!loginForm) return; - - // Add WebAuthn login button - const webauthnButton = document.createElement('button'); - webauthnButton.type = 'button'; - webauthnButton.className = 'btn btn-primary btn-block'; - webauthnButton.innerHTML = ' Login with Passkey'; - webauthnButton.onclick = () => this.startPasswordlessLogin(); - - // Insert after password field - const passwordField = loginForm.querySelector('input[type="password"]'); - if (passwordField) { - passwordField.parentNode.insertBefore(webauthnButton, passwordField.parentNode.nextSibling); + const existingBtn = document.getElementById('webauthn-login-btn'); + if (existingBtn && !existingBtn.dataset.bound) { + existingBtn.dataset.bound = '1'; + existingBtn.onclick = () => this.startPasskeyFirstLogin(); } } @@ -80,6 +73,76 @@ class CyberPanelWebAuthn { // Implementation depends on the specific UI structure } + arrayBufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + base64urlToArrayBuffer(str) { + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (base64.length % 4)) % 4; + for (let i = 0; i < pad; i++) base64 += '='; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } + + async startPasskeyFirstLogin() { + try { + this.showLoading('Signing in with passkey...'); + const optsUrl = this.apiEndpoints.authenticationOptions + '?return=' + encodeURIComponent(window.location.pathname || '/'); + const optsResponse = await fetch(optsUrl, { method: 'GET', credentials: 'same-origin' }); + const optsData = await optsResponse.json(); + if (!optsData.publicKey) { + throw new Error(optsData.error || 'Failed to get options'); + } + const publicKey = optsData.publicKey; + publicKey.challenge = this.base64urlToArrayBuffer(publicKey.challenge); + if (publicKey.allowCredentials && publicKey.allowCredentials.length) { + publicKey.allowCredentials = publicKey.allowCredentials.map(function(c) { + return { + type: c.type || 'public-key', + id: typeof c.id === 'string' ? this.base64urlToArrayBuffer(c.id) : c.id, + transports: c.transports + }; + }.bind(this)); + } + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) throw new Error('No credential'); + const credentialJson = { + id: credential.id, + rawId: this.arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: this.arrayBufferToBase64url(credential.response.clientDataJSON), + authenticatorData: this.arrayBufferToBase64url(credential.response.authenticatorData), + signature: this.arrayBufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle ? this.arrayBufferToBase64url(credential.response.userHandle) : null + } + }; + const authResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationComplete, { + credential: credentialJson + }); + if (authResponse.success && authResponse.redirect) { + window.location.href = authResponse.redirect; + return; + } + throw new Error(authResponse.error || 'Verification failed'); + } catch (error) { + if (error.name === 'NotAllowedError' || (error.message && (error.message.indexOf('cancel') !== -1 || error.message.indexOf('timed out') !== -1))) { + this.hideLoading(); + return; + } + console.error('WebAuthn passkey-first error:', error); + this.showError(error.message || 'Passkey sign-in failed'); + } finally { + this.hideLoading(); + } + } + async startPasswordlessLogin() { try { const username = document.querySelector('input[name="username"]').value; @@ -87,27 +150,13 @@ class CyberPanelWebAuthn { this.showError('Please enter your username first'); return; } - this.showLoading('Starting passkey authentication...'); - - // Get authentication challenge - const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationStart, { - username: username - }); - + const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationStart, { username: username }); if (!challengeResponse.success) { throw new Error(challengeResponse.error || 'Failed to start authentication'); } - - // Convert challenge to proper format const challenge = this.convertChallenge(challengeResponse.challenge); - - // Get credential - const credential = await navigator.credentials.get({ - publicKey: challenge - }); - - // Complete authentication + const credential = await navigator.credentials.get({ publicKey: challenge }); const authResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationComplete, { challenge_id: challengeResponse.challenge_id, credential: { @@ -117,19 +166,14 @@ class CyberPanelWebAuthn { client_data_json: this.arrayBufferToBase64(credential.response.clientDataJSON), authenticator_data: this.arrayBufferToBase64(credential.response.authenticatorData), signature: this.arrayBufferToBase64(credential.response.signature), - user_handle: credential.response.userHandle ? - this.arrayBufferToBase64(credential.response.userHandle) : null + user_handle: credential.response.userHandle ? this.arrayBufferToBase64(credential.response.userHandle) : null }); - if (authResponse.success) { this.showSuccess('Authentication successful! Redirecting...'); - setTimeout(() => { - window.location.href = '/'; - }, 1000); + setTimeout(() => { window.location.href = authResponse.redirect || '/'; }, 1000); } else { throw new Error(authResponse.error || 'Authentication failed'); } - } catch (error) { console.error('WebAuthn authentication error:', error); this.showError(error.message || 'Authentication failed'); @@ -138,9 +182,10 @@ class CyberPanelWebAuthn { } } - async registerPasskey(username, credentialName = '') { + async registerPasskey(username, credentialName = '', options = {}) { + const silent = options && options.silent === true; try { - this.showLoading('Starting passkey registration...'); + if (!silent) this.showLoading('Starting passkey registration...'); // Get registration challenge const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.registrationStart, { @@ -172,7 +217,7 @@ class CyberPanelWebAuthn { }); if (regResponse.success) { - this.showSuccess('Passkey registered successfully!'); + if (!silent) this.showSuccess('Passkey registered successfully!'); return regResponse; } else { throw new Error(regResponse.error || 'Registration failed'); @@ -180,10 +225,10 @@ class CyberPanelWebAuthn { } catch (error) { console.error('WebAuthn registration error:', error); - this.showError(error.message || 'Registration failed'); + if (!silent) this.showError(error.message || 'Registration failed'); throw error; } finally { - this.hideLoading(); + if (!silent) this.hideLoading(); } } @@ -265,23 +310,25 @@ class CyberPanelWebAuthn { } convertChallenge(challenge) { - // Convert base64 challenge to ArrayBuffer - const challengeBytes = this.base64ToArrayBuffer(challenge.challenge); - + const ch = challenge.challenge; + const challengeBytes = (typeof ch === 'string' && (ch.indexOf('-') !== -1 || ch.indexOf('_') !== -1)) + ? this.base64urlToArrayBuffer(ch) : this.base64ToArrayBuffer(ch); + const userId = challenge.user && challenge.user.id; + const userIdBuf = !userId ? undefined : (typeof userId === 'string' && (userId.indexOf('-') !== -1 || userId.indexOf('_') !== -1) + ? this.base64urlToArrayBuffer(userId) : this.base64ToArrayBuffer(userId)); return { ...challenge, challenge: challengeBytes, - user: { - ...challenge.user, - id: this.base64ToArrayBuffer(challenge.user.id) - }, + user: challenge.user ? { ...challenge.user, id: userIdBuf } : undefined, excludeCredentials: challenge.excludeCredentials?.map(cred => ({ ...cred, - id: this.base64ToArrayBuffer(cred.id) + id: typeof cred.id === 'string' && (cred.id.indexOf('-') !== -1 || cred.id.indexOf('_') !== -1) + ? this.base64urlToArrayBuffer(cred.id) : this.base64ToArrayBuffer(cred.id) })) || [], allowCredentials: challenge.allowCredentials?.map(cred => ({ ...cred, - id: this.base64ToArrayBuffer(cred.id) + id: typeof cred.id === 'string' && (cred.id.indexOf('-') !== -1 || cred.id.indexOf('_') !== -1) + ? this.base64urlToArrayBuffer(cred.id) : this.base64ToArrayBuffer(cred.id) })) || [] }; } @@ -383,12 +430,17 @@ class CyberPanelWebAuthn { } } -// Initialize WebAuthn when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { +// Initialize WebAuthn - run now if DOM ready, else on DOMContentLoaded (script often loads after DOM is ready) +function initCyberPanelWebAuthn() { if (CyberPanelWebAuthn.isSupported()) { window.cyberPanelWebAuthn = new CyberPanelWebAuthn(); } -}); +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCyberPanelWebAuthn); +} else { + initCyberPanelWebAuthn(); +} // Export for use in other scripts if (typeof module !== 'undefined' && module.exports) { diff --git a/loginSystem/templates/loginSystem/login.html b/loginSystem/templates/loginSystem/login.html index 14d373aff..39127b45c 100644 --- a/loginSystem/templates/loginSystem/login.html +++ b/loginSystem/templates/loginSystem/login.html @@ -352,7 +352,8 @@ class="btn btn-success btn-block btn-login">Sign In - + {% if passkey_login_available %} + + {% endif %} @@ -414,29 +416,17 @@ } {% get_current_language as LANGUAGE_CODE %} @@ -234,14 +235,30 @@
- +

{% trans "Select the user account you want to modify" %}

+
@@ -294,6 +311,11 @@ class="form-control">

{% trans "Choose the security level for this account" %}

+
+ {% trans "What's the difference?" %} + {% trans "High" %}: {% trans "Session is tied to the IP address used at login. If the user logs in from one IP and later visits from another (e.g. different network, VPN, or mobile), the session is invalidated and they must log in again." %}

+ {% trans "Low" %}: {% trans "Session is not tied to IP. The user can stay logged in when their IP changes (e.g. switching networks or using VPN). More convenient but slightly less strict against session reuse from another location." %} +
@@ -352,27 +374,7 @@

{% trans "When enabled, users must use passkeys to login (password becomes optional)" %}

-
- -

{% trans "Allow users to register multiple passkeys for backup access" %}

-
- -
- - -

{% trans "Maximum number of passkeys allowed per user" %}

-
- -
- - -

{% trans "How long to wait for passkey interaction" %}

-
- - +
{% trans "Manage Passkeys" %}
@@ -407,21 +409,136 @@
-
-
- -
- {% trans "Maximum number of passkeys reached" %} -
+ +