Refactor: Extract inline JS to external files
New JS files: - auth.js: Password toggle, confirmation validation, copy, regenerate - generate.js: Form controls, credential display, memory story generation Updated templates to use external JS: - login.html, setup.html, account.html - admin/user_new.html, user_created.html, password_reset.html - generate.html (now uses generate.js + minimal Jinja-dependent inline) Core stegasoo.js (943 lines) unchanged - already handles encode/decode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
142
frontends/web/static/js/auth.js
Normal file
142
frontends/web/static/js/auth.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Stegasoo Authentication Pages JavaScript
|
||||||
|
* Handles login, setup, account, and admin user management pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
const StegasooAuth = {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD VISIBILITY TOGGLE
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle password field visibility
|
||||||
|
* @param {string} inputId - ID of the password input
|
||||||
|
* @param {HTMLElement} btn - The toggle button element
|
||||||
|
*/
|
||||||
|
togglePassword(inputId, btn) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
icon?.classList.replace('bi-eye', 'bi-eye-slash');
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
icon?.classList.replace('bi-eye-slash', 'bi-eye');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD CONFIRMATION VALIDATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize password confirmation validation on a form
|
||||||
|
* @param {string} formId - ID of the form
|
||||||
|
* @param {string} passwordId - ID of the password field
|
||||||
|
* @param {string} confirmId - ID of the confirmation field
|
||||||
|
*/
|
||||||
|
initPasswordConfirmation(formId, passwordId, confirmId) {
|
||||||
|
const form = document.getElementById(formId);
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const password = document.getElementById(passwordId)?.value;
|
||||||
|
const confirm = document.getElementById(confirmId)?.value;
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Passwords do not match');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// COPY TO CLIPBOARD
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy field value to clipboard with visual feedback
|
||||||
|
* @param {string} fieldId - ID of the input field to copy
|
||||||
|
*/
|
||||||
|
copyField(fieldId) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
field.select();
|
||||||
|
navigator.clipboard.writeText(field.value).then(() => {
|
||||||
|
const btn = field.nextElementSibling;
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check"></i>';
|
||||||
|
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PASSWORD GENERATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random password
|
||||||
|
* @param {number} length - Password length (default 8)
|
||||||
|
* @returns {string} Generated password
|
||||||
|
*/
|
||||||
|
generatePassword(length = 8) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate password and update input field
|
||||||
|
* @param {string} inputId - ID of the password input
|
||||||
|
* @param {number} length - Password length
|
||||||
|
*/
|
||||||
|
regeneratePassword(inputId = 'passwordInput', length = 8) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.value = this.generatePassword(length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// DELETE CONFIRMATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm deletion with a prompt
|
||||||
|
* @param {string} itemName - Name of item being deleted
|
||||||
|
* @param {string} formId - ID of the form to submit if confirmed
|
||||||
|
* @returns {boolean} True if confirmed
|
||||||
|
*/
|
||||||
|
confirmDelete(itemName, formId = null) {
|
||||||
|
const confirmed = confirm(`Are you sure you want to delete "${itemName}"? This cannot be undone.`);
|
||||||
|
if (confirmed && formId) {
|
||||||
|
const form = document.getElementById(formId);
|
||||||
|
form?.submit();
|
||||||
|
}
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make togglePassword available globally for onclick handlers
|
||||||
|
function togglePassword(inputId, btn) {
|
||||||
|
StegasooAuth.togglePassword(inputId, btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make copyField available globally for onclick handlers
|
||||||
|
function copyField(fieldId) {
|
||||||
|
StegasooAuth.copyField(fieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make regeneratePassword available globally for onclick handlers
|
||||||
|
function regeneratePassword() {
|
||||||
|
StegasooAuth.regeneratePassword();
|
||||||
|
}
|
||||||
285
frontends/web/static/js/generate.js
Normal file
285
frontends/web/static/js/generate.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Stegasoo Generate Page JavaScript
|
||||||
|
* Handles credential generation form and display
|
||||||
|
*/
|
||||||
|
|
||||||
|
const StegasooGenerate = {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// FORM CONTROLS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the words range slider
|
||||||
|
*/
|
||||||
|
initWordsSlider() {
|
||||||
|
const wordsRange = document.getElementById('wordsRange');
|
||||||
|
const wordsValue = document.getElementById('wordsValue');
|
||||||
|
|
||||||
|
wordsRange?.addEventListener('input', function() {
|
||||||
|
const bits = this.value * 11;
|
||||||
|
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize PIN/RSA option toggles
|
||||||
|
*/
|
||||||
|
initOptionToggles() {
|
||||||
|
const usePinCheck = document.getElementById('usePinCheck');
|
||||||
|
const useRsaCheck = document.getElementById('useRsaCheck');
|
||||||
|
const pinOptions = document.getElementById('pinOptions');
|
||||||
|
const rsaOptions = document.getElementById('rsaOptions');
|
||||||
|
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
||||||
|
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
||||||
|
|
||||||
|
usePinCheck?.addEventListener('change', function() {
|
||||||
|
pinOptions?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
useRsaCheck?.addEventListener('change', function() {
|
||||||
|
rsaOptions?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// RSA key size QR warning (>3072 bits)
|
||||||
|
rsaBitsSelect?.addEventListener('change', function() {
|
||||||
|
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CREDENTIAL VISIBILITY
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
pinHidden: false,
|
||||||
|
passphraseHidden: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle PIN visibility
|
||||||
|
*/
|
||||||
|
togglePinVisibility() {
|
||||||
|
const pinDigits = document.getElementById('pinDigits');
|
||||||
|
const icon = document.getElementById('pinToggleIcon');
|
||||||
|
const text = document.getElementById('pinToggleText');
|
||||||
|
|
||||||
|
this.pinHidden = !this.pinHidden;
|
||||||
|
pinDigits?.classList.toggle('blurred', this.pinHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = this.pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||||
|
if (text) text.textContent = this.pinHidden ? 'Show' : 'Hide';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle passphrase visibility
|
||||||
|
*/
|
||||||
|
togglePassphraseVisibility() {
|
||||||
|
const display = document.getElementById('passphraseDisplay');
|
||||||
|
const icon = document.getElementById('passphraseToggleIcon');
|
||||||
|
const text = document.getElementById('passphraseToggleText');
|
||||||
|
|
||||||
|
this.passphraseHidden = !this.passphraseHidden;
|
||||||
|
display?.classList.toggle('blurred', this.passphraseHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = this.passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||||
|
if (text) text.textContent = this.passphraseHidden ? 'Show' : 'Hide';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MEMORY AID STORY GENERATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
currentStoryTemplate: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story templates organized by word count (3-12 words supported)
|
||||||
|
*/
|
||||||
|
storyTemplates: {
|
||||||
|
3: [
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]}.`,
|
||||||
|
w => `${w[0]} loves ${w[1]} and ${w[2]}.`,
|
||||||
|
w => `A ${w[0]} found a ${w[1]} near the ${w[2]}.`,
|
||||||
|
w => `${w[0]}, ${w[1]}, ${w[2]} — never forget.`,
|
||||||
|
w => `The ${w[0]} hid the ${w[1]} under the ${w[2]}.`,
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
w => `${w[0]} and ${w[1]} discovered a ${w[2]} made of ${w[3]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ate ${w[2]} for ${w[3]}.`,
|
||||||
|
w => `In the ${w[0]}, a ${w[1]} met a ${w[2]} carrying ${w[3]}.`,
|
||||||
|
w => `${w[0]} said "${w[1]}" while holding a ${w[2]} ${w[3]}.`,
|
||||||
|
w => `The secret: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}.`,
|
||||||
|
],
|
||||||
|
5: [
|
||||||
|
w => `${w[0]} traveled to ${w[1]} seeking the ${w[2]} of ${w[3]} and ${w[4]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} lived in a ${w[2]} house with ${w[3]} ${w[4]}.`,
|
||||||
|
w => `"${w[0]}!" shouted ${w[1]} as the ${w[2]} ${w[3]} flew toward ${w[4]}.`,
|
||||||
|
w => `Captain ${w[0]} sailed the ${w[1]} ${w[2]} searching for ${w[3]} ${w[4]}.`,
|
||||||
|
w => `In ${w[0]} kingdom, ${w[1]} guards protected the ${w[2]} ${w[3]} ${w[4]}.`,
|
||||||
|
],
|
||||||
|
6: [
|
||||||
|
w => `${w[0]} met ${w[1]} at the ${w[2]}. Together they found ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} wore a ${w[2]} hat while eating ${w[3]} ${w[4]} ${w[5]}.`,
|
||||||
|
w => `Detective ${w[0]} found ${w[1]} ${w[2]} near the ${w[3]} ${w[4]} ${w[5]}.`,
|
||||||
|
w => `In the ${w[0]} ${w[1]}, a ${w[2]} ${w[3]} sang about ${w[4]} ${w[5]}.`,
|
||||||
|
w => `Chef ${w[0]} combined ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, and ${w[5]}.`,
|
||||||
|
],
|
||||||
|
7: [
|
||||||
|
w => `${w[0]} and ${w[1]} walked through the ${w[2]} ${w[3]} to find the ${w[4]} ${w[5]} ${w[6]}.`,
|
||||||
|
w => `The ${w[0]} professor studied ${w[1]} ${w[2]} while drinking ${w[3]} ${w[4]} with ${w[5]} ${w[6]}.`,
|
||||||
|
w => `"${w[0]} ${w[1]}!" yelled ${w[2]} as ${w[3]} ${w[4]} attacked the ${w[5]} ${w[6]}.`,
|
||||||
|
w => `In ${w[0]}, King ${w[1]} decreed that ${w[2]} ${w[3]} must honor ${w[4]} ${w[5]} ${w[6]}.`,
|
||||||
|
],
|
||||||
|
8: [
|
||||||
|
w => `${w[0]} ${w[1]} and ${w[2]} ${w[3]} met at the ${w[4]} ${w[5]} to discuss ${w[6]} ${w[7]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]} traveled from ${w[3]} to ${w[4]} carrying ${w[5]} ${w[6]} ${w[7]}.`,
|
||||||
|
w => `${w[0]} discovered that ${w[1]} ${w[2]} plus ${w[3]} ${w[4]} equals ${w[5]} ${w[6]} ${w[7]}.`,
|
||||||
|
],
|
||||||
|
9: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} watched as ${w[3]} ${w[4]} ${w[5]} danced with ${w[6]} ${w[7]} ${w[8]}.`,
|
||||||
|
w => `In the ${w[0]} ${w[1]} ${w[2]}, three friends — ${w[3]}, ${w[4]}, ${w[5]} — found ${w[6]} ${w[7]} ${w[8]}.`,
|
||||||
|
w => `The recipe: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}.`,
|
||||||
|
],
|
||||||
|
10: [
|
||||||
|
w => `${w[0]} ${w[1]} told ${w[2]} ${w[3]} about the ${w[4]} ${w[5]} ${w[6]} hidden in ${w[7]} ${w[8]} ${w[9]}.`,
|
||||||
|
w => `The ${w[0]} ${w[1]} ${w[2]} ${w[3]} ${w[4]} lived beside ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]}.`,
|
||||||
|
],
|
||||||
|
11: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} and ${w[3]} ${w[4]} ${w[5]} discovered ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||||
|
w => `In ${w[0]} ${w[1]}, the ${w[2]} ${w[3]} ${w[4]} sang of ${w[5]} ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]}.`,
|
||||||
|
],
|
||||||
|
12: [
|
||||||
|
w => `${w[0]} ${w[1]} ${w[2]} met ${w[3]} ${w[4]} ${w[5]} at the ${w[6]} ${w[7]} ${w[8]} ${w[9]} ${w[10]} ${w[11]}.`,
|
||||||
|
w => `The twelve treasures: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}, ${w[4]}, ${w[5]}, ${w[6]}, ${w[7]}, ${w[8]}, ${w[9]}, ${w[10]}, ${w[11]}.`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap word in highlight span
|
||||||
|
*/
|
||||||
|
hl(word) {
|
||||||
|
return `<span class="passphrase-word">${word}</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a memory story for given words
|
||||||
|
* @param {string[]} words - Array of passphrase words
|
||||||
|
* @param {number|null} idx - Template index (null for current)
|
||||||
|
* @returns {string} HTML story
|
||||||
|
*/
|
||||||
|
generateStory(words, idx = null) {
|
||||||
|
const count = words.length;
|
||||||
|
if (count === 0) return '';
|
||||||
|
|
||||||
|
// Clamp to supported range (3-12)
|
||||||
|
const templateKey = Math.max(3, Math.min(12, count));
|
||||||
|
const templates = this.storyTemplates[templateKey];
|
||||||
|
|
||||||
|
if (!templates || templates.length === 0) {
|
||||||
|
// Fallback: just list the words
|
||||||
|
return words.map(w => this.hl(w)).join(' — ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIdx = (idx ?? this.currentStoryTemplate) % templates.length;
|
||||||
|
// Apply highlighting to words
|
||||||
|
const highlighted = words.map(w => this.hl(w));
|
||||||
|
return templates[templateIdx](highlighted);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle memory aid visibility
|
||||||
|
* @param {string[]} words - Passphrase words array
|
||||||
|
*/
|
||||||
|
toggleMemoryAid(words) {
|
||||||
|
const container = document.getElementById('memoryAidContainer');
|
||||||
|
const icon = document.getElementById('memoryAidIcon');
|
||||||
|
const text = document.getElementById('memoryAidText');
|
||||||
|
|
||||||
|
const isHidden = container?.classList.contains('d-none');
|
||||||
|
container?.classList.toggle('d-none', !isHidden);
|
||||||
|
|
||||||
|
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
||||||
|
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
document.getElementById('memoryStory').innerHTML = this.generateStory(words);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate story with next template
|
||||||
|
* @param {string[]} words - Passphrase words array
|
||||||
|
*/
|
||||||
|
regenerateStory(words) {
|
||||||
|
const count = words.length;
|
||||||
|
const templateKey = Math.max(3, Math.min(12, count));
|
||||||
|
const templates = this.storyTemplates[templateKey] || [];
|
||||||
|
this.currentStoryTemplate = (this.currentStoryTemplate + 1) % Math.max(1, templates.length);
|
||||||
|
document.getElementById('memoryStory').innerHTML = this.generateStory(words, this.currentStoryTemplate);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// QR CODE PRINTING
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print QR code in new window
|
||||||
|
*/
|
||||||
|
printQrCode() {
|
||||||
|
const qrImg = document.getElementById('qrCodeImage');
|
||||||
|
if (!qrImg) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
printWindow.document.write(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Stegasoo RSA Key QR Code</title>
|
||||||
|
<style>
|
||||||
|
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
||||||
|
img { max-width: 400px; }
|
||||||
|
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Stegasoo RSA Private Key</h2>
|
||||||
|
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
||||||
|
Store securely and destroy after use.
|
||||||
|
</div>
|
||||||
|
<script>window.onload = function() { window.print(); }<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
printWindow.document.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize generate form page
|
||||||
|
*/
|
||||||
|
initForm() {
|
||||||
|
this.initWordsSlider();
|
||||||
|
this.initOptionToggles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global function wrappers for onclick handlers
|
||||||
|
function togglePinVisibility() {
|
||||||
|
StegasooGenerate.togglePinVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassphraseVisibility() {
|
||||||
|
StegasooGenerate.togglePassphraseVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printQrCode() {
|
||||||
|
StegasooGenerate.printQrCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-init form controls
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (document.querySelector('[data-page="generate"]')) {
|
||||||
|
StegasooGenerate.initForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -90,26 +90,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function togglePassword(inputId, btn) {
|
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
|
|
||||||
const newPass = document.getElementById('newPasswordInput').value;
|
|
||||||
const confirm = document.getElementById('newPasswordConfirmInput').value;
|
|
||||||
if (newPass !== confirm) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('New passwords do not match');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -46,17 +46,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
function copyField(fieldId) {
|
|
||||||
const field = document.getElementById(fieldId);
|
|
||||||
field.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
|
|
||||||
// Show brief feedback
|
|
||||||
const btn = field.nextElementSibling;
|
|
||||||
const originalHTML = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<i class="bi bi-check"></i>';
|
|
||||||
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -56,17 +56,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
function copyField(fieldId) {
|
|
||||||
const field = document.getElementById(fieldId);
|
|
||||||
field.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
|
|
||||||
// Show brief feedback
|
|
||||||
const btn = field.nextElementSibling;
|
|
||||||
const originalHTML = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<i class="bi bi-check"></i>';
|
|
||||||
setTimeout(() => btn.innerHTML = originalHTML, 1000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -60,14 +60,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
function regeneratePassword() {
|
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
let password = '';
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
document.getElementById('passwordInput').value = password;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -509,61 +509,12 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/generate.js') }}"></script>
|
||||||
// ============================================================================
|
|
||||||
// GENERATE PAGE - Form Controls
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Words range slider
|
|
||||||
const wordsRange = document.getElementById('wordsRange');
|
|
||||||
const wordsValue = document.getElementById('wordsValue');
|
|
||||||
|
|
||||||
wordsRange?.addEventListener('input', function() {
|
|
||||||
const bits = this.value * 11;
|
|
||||||
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle PIN/RSA options visibility
|
|
||||||
const usePinCheck = document.getElementById('usePinCheck');
|
|
||||||
const useRsaCheck = document.getElementById('useRsaCheck');
|
|
||||||
const pinOptions = document.getElementById('pinOptions');
|
|
||||||
const rsaOptions = document.getElementById('rsaOptions');
|
|
||||||
const rsaQrWarning = document.getElementById('rsaQrWarning');
|
|
||||||
const rsaBitsSelect = document.getElementById('rsaBitsSelect');
|
|
||||||
|
|
||||||
usePinCheck?.addEventListener('change', function() {
|
|
||||||
pinOptions?.classList.toggle('d-none', !this.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
useRsaCheck?.addEventListener('change', function() {
|
|
||||||
rsaOptions?.classList.toggle('d-none', !this.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
// RSA key size QR warning (>3072 bits)
|
|
||||||
rsaBitsSelect?.addEventListener('change', function() {
|
|
||||||
rsaQrWarning?.classList.toggle('d-none', parseInt(this.value) <= 3072);
|
|
||||||
});
|
|
||||||
|
|
||||||
{% if generated %}
|
{% if generated %}
|
||||||
// ============================================================================
|
<script>
|
||||||
// GENERATE PAGE - Credential Display
|
// Page-specific data from Jinja
|
||||||
// ============================================================================
|
const passphraseWords = '{{ passphrase|default("", true) }}'.split(' ').filter(w => w.length > 0);
|
||||||
|
|
||||||
// PIN visibility toggle
|
|
||||||
let pinHidden = false;
|
|
||||||
function togglePinVisibility() {
|
|
||||||
const pinDigits = document.getElementById('pinDigits');
|
|
||||||
const icon = document.getElementById('pinToggleIcon');
|
|
||||||
const text = document.getElementById('pinToggleText');
|
|
||||||
|
|
||||||
pinHidden = !pinHidden;
|
|
||||||
pinDigits?.classList.toggle('blurred', pinHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = pinHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
|
||||||
if (text) text.textContent = pinHidden ? 'Show' : 'Hide';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy PIN
|
|
||||||
function copyPin() {
|
function copyPin() {
|
||||||
Stegasoo.copyToClipboard(
|
Stegasoo.copyToClipboard(
|
||||||
'{{ pin|default("", true) }}',
|
'{{ pin|default("", true) }}',
|
||||||
@@ -572,21 +523,6 @@ function copyPin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passphrase visibility toggle
|
|
||||||
let passphraseHidden = false;
|
|
||||||
function togglePassphraseVisibility() {
|
|
||||||
const display = document.getElementById('passphraseDisplay');
|
|
||||||
const icon = document.getElementById('passphraseToggleIcon');
|
|
||||||
const text = document.getElementById('passphraseToggleText');
|
|
||||||
|
|
||||||
passphraseHidden = !passphraseHidden;
|
|
||||||
display?.classList.toggle('blurred', passphraseHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = passphraseHidden ? 'bi bi-eye' : 'bi bi-eye-slash';
|
|
||||||
if (text) text.textContent = passphraseHidden ? 'Show' : 'Hide';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy passphrase
|
|
||||||
function copyPassphrase() {
|
function copyPassphrase() {
|
||||||
Stegasoo.copyToClipboard(
|
Stegasoo.copyToClipboard(
|
||||||
'{{ passphrase|default("", true) }}',
|
'{{ passphrase|default("", true) }}',
|
||||||
@@ -595,148 +531,13 @@ function copyPassphrase() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Memory Aid Story Generation - Templates by word count
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const passphrase = '{{ passphrase|default("", true) }}';
|
|
||||||
const passphraseWords = passphrase.split(' ').filter(w => w.length > 0);
|
|
||||||
let currentStoryTemplate = 0;
|
|
||||||
|
|
||||||
// Templates organized by word count (3-12 words supported)
|
|
||||||
const storyTemplatesByLength = {
|
|
||||||
3: [
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}.`,
|
|
||||||
w => `${hl(w[0])} loves ${hl(w[1])} and ${hl(w[2])}.`,
|
|
||||||
w => `A ${hl(w[0])} found a ${hl(w[1])} near the ${hl(w[2])}.`,
|
|
||||||
w => `${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])} — never forget.`,
|
|
||||||
w => `The ${hl(w[0])} hid the ${hl(w[1])} under the ${hl(w[2])}.`,
|
|
||||||
],
|
|
||||||
4: [
|
|
||||||
w => `${hl(w[0])} and ${hl(w[1])} discovered a ${hl(w[2])} made of ${hl(w[3])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ate ${hl(w[2])} for ${hl(w[3])}.`,
|
|
||||||
w => `In the ${hl(w[0])}, a ${hl(w[1])} met a ${hl(w[2])} carrying ${hl(w[3])}.`,
|
|
||||||
w => `${hl(w[0])} said "${hl(w[1])}" while holding a ${hl(w[2])} ${hl(w[3])}.`,
|
|
||||||
w => `The secret: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}.`,
|
|
||||||
],
|
|
||||||
5: [
|
|
||||||
w => `${hl(w[0])} traveled to ${hl(w[1])} seeking the ${hl(w[2])} of ${hl(w[3])} and ${hl(w[4])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} lived in a ${hl(w[2])} house with ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
w => `"${hl(w[0])}!" shouted ${hl(w[1])} as the ${hl(w[2])} ${hl(w[3])} flew toward ${hl(w[4])}.`,
|
|
||||||
w => `Captain ${hl(w[0])} sailed the ${hl(w[1])} ${hl(w[2])} searching for ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
w => `In ${hl(w[0])} kingdom, ${hl(w[1])} guards protected the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])}.`,
|
|
||||||
],
|
|
||||||
6: [
|
|
||||||
w => `${hl(w[0])} met ${hl(w[1])} at the ${hl(w[2])}. Together they found ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} wore a ${hl(w[2])} hat while eating ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `Detective ${hl(w[0])} found ${hl(w[1])} ${hl(w[2])} near the ${hl(w[3])} ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `In the ${hl(w[0])} ${hl(w[1])}, a ${hl(w[2])} ${hl(w[3])} sang about ${hl(w[4])} ${hl(w[5])}.`,
|
|
||||||
w => `Chef ${hl(w[0])} combined ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, and ${hl(w[5])}.`,
|
|
||||||
],
|
|
||||||
7: [
|
|
||||||
w => `${hl(w[0])} and ${hl(w[1])} walked through the ${hl(w[2])} ${hl(w[3])} to find the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `The ${hl(w[0])} professor studied ${hl(w[1])} ${hl(w[2])} while drinking ${hl(w[3])} ${hl(w[4])} with ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `"${hl(w[0])} ${hl(w[1])}!" yelled ${hl(w[2])} as ${hl(w[3])} ${hl(w[4])} attacked the ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
w => `In ${hl(w[0])}, King ${hl(w[1])} decreed that ${hl(w[2])} ${hl(w[3])} must honor ${hl(w[4])} ${hl(w[5])} ${hl(w[6])}.`,
|
|
||||||
],
|
|
||||||
8: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} and ${hl(w[2])} ${hl(w[3])} met at the ${hl(w[4])} ${hl(w[5])} to discuss ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} traveled from ${hl(w[3])} to ${hl(w[4])} carrying ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
w => `${hl(w[0])} discovered that ${hl(w[1])} ${hl(w[2])} plus ${hl(w[3])} ${hl(w[4])} equals ${hl(w[5])} ${hl(w[6])} ${hl(w[7])}.`,
|
|
||||||
],
|
|
||||||
9: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} watched as ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} danced with ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
|
||||||
w => `In the ${hl(w[0])} ${hl(w[1])} ${hl(w[2])}, three friends — ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])} — found ${hl(w[6])} ${hl(w[7])} ${hl(w[8])}.`,
|
|
||||||
w => `The recipe: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}.`,
|
|
||||||
],
|
|
||||||
10: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} told ${hl(w[2])} ${hl(w[3])} about the ${hl(w[4])} ${hl(w[5])} ${hl(w[6])} hidden in ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
|
||||||
w => `The ${hl(w[0])} ${hl(w[1])} ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} lived beside ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])}.`,
|
|
||||||
],
|
|
||||||
11: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} and ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} discovered ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
|
||||||
w => `In ${hl(w[0])} ${hl(w[1])}, the ${hl(w[2])} ${hl(w[3])} ${hl(w[4])} sang of ${hl(w[5])} ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])}.`,
|
|
||||||
],
|
|
||||||
12: [
|
|
||||||
w => `${hl(w[0])} ${hl(w[1])} ${hl(w[2])} met ${hl(w[3])} ${hl(w[4])} ${hl(w[5])} at the ${hl(w[6])} ${hl(w[7])} ${hl(w[8])} ${hl(w[9])} ${hl(w[10])} ${hl(w[11])}.`,
|
|
||||||
w => `The twelve treasures: ${hl(w[0])}, ${hl(w[1])}, ${hl(w[2])}, ${hl(w[3])}, ${hl(w[4])}, ${hl(w[5])}, ${hl(w[6])}, ${hl(w[7])}, ${hl(w[8])}, ${hl(w[9])}, ${hl(w[10])}, ${hl(w[11])}.`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function hl(word) {
|
|
||||||
return `<span class="passphrase-word">${word}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateStory(idx = null) {
|
|
||||||
const count = passphraseWords.length;
|
|
||||||
if (count === 0) return '';
|
|
||||||
|
|
||||||
// Clamp to supported range (3-12)
|
|
||||||
const templateKey = Math.max(3, Math.min(12, count));
|
|
||||||
const templates = storyTemplatesByLength[templateKey];
|
|
||||||
|
|
||||||
if (!templates || templates.length === 0) {
|
|
||||||
// Fallback: just list the words
|
|
||||||
return passphraseWords.map(w => hl(w)).join(' — ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateIdx = (idx ?? currentStoryTemplate) % templates.length;
|
|
||||||
return templates[templateIdx](passphraseWords);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMemoryAid() {
|
function toggleMemoryAid() {
|
||||||
const container = document.getElementById('memoryAidContainer');
|
StegasooGenerate.toggleMemoryAid(passphraseWords);
|
||||||
const icon = document.getElementById('memoryAidIcon');
|
|
||||||
const text = document.getElementById('memoryAidText');
|
|
||||||
|
|
||||||
const isHidden = container?.classList.contains('d-none');
|
|
||||||
container?.classList.toggle('d-none', !isHidden);
|
|
||||||
|
|
||||||
if (icon) icon.className = isHidden ? 'bi bi-lightbulb-fill' : 'bi bi-lightbulb';
|
|
||||||
if (text) text.textContent = isHidden ? 'Hide Aid' : 'Memory Aid';
|
|
||||||
|
|
||||||
if (isHidden) {
|
|
||||||
document.getElementById('memoryStory').innerHTML = generateStory();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateStory() {
|
function regenerateStory() {
|
||||||
const count = passphraseWords.length;
|
StegasooGenerate.regenerateStory(passphraseWords);
|
||||||
const templateKey = Math.max(3, Math.min(12, count));
|
|
||||||
const templates = storyTemplatesByLength[templateKey] || [];
|
|
||||||
currentStoryTemplate = (currentStoryTemplate + 1) % Math.max(1, templates.length);
|
|
||||||
document.getElementById('memoryStory').innerHTML = generateStory(currentStoryTemplate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print QR code
|
|
||||||
function printQrCode() {
|
|
||||||
const qrImg = document.getElementById('qrCodeImage');
|
|
||||||
if (!qrImg) return;
|
|
||||||
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
printWindow.document.write(`<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Stegasoo RSA Key QR Code</title>
|
|
||||||
<style>
|
|
||||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
|
||||||
img { max-width: 400px; }
|
|
||||||
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>Stegasoo RSA Private Key</h2>
|
|
||||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
|
||||||
<div class="warning">
|
|
||||||
<strong>⚠️ SECURITY WARNING</strong><br>
|
|
||||||
This QR code contains your unencrypted RSA private key.<br>
|
|
||||||
Store securely and destroy after use.
|
|
||||||
</div>
|
|
||||||
<script>window.onload = function() { window.print(); }<\/script>
|
|
||||||
</body>
|
|
||||||
</html>`);
|
|
||||||
printWindow.document.close();
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -45,17 +45,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
function togglePassword(inputId, btn) {
|
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -69,26 +69,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function togglePassword(inputId, btn) {
|
StegasooAuth.initPasswordConfirmation('setupForm', 'passwordInput', 'passwordConfirmInput');
|
||||||
const input = document.getElementById(inputId);
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
if (input.type === 'password') {
|
|
||||||
input.type = 'text';
|
|
||||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
|
||||||
} else {
|
|
||||||
input.type = 'password';
|
|
||||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
|
|
||||||
const pass = document.getElementById('passwordInput').value;
|
|
||||||
const confirm = document.getElementById('passwordConfirmInput').value;
|
|
||||||
if (pass !== confirm) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Passwords do not match');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user