feature/5-mfa-support #8
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@ appdata
|
|||||||
.idea
|
.idea
|
||||||
*.exe
|
*.exe
|
||||||
*.cmd
|
*.cmd
|
||||||
.run
|
.run
|
||||||
|
*.out
|
||||||
@@ -39,6 +39,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (document.getElementById('items-table-body')) loadItems();
|
if (document.getElementById('items-table-body')) loadItems();
|
||||||
if (document.getElementById('locations-table-body')) loadLocations();
|
if (document.getElementById('locations-table-body')) loadLocations();
|
||||||
if (document.getElementById('projects-table-body')) loadProjects();
|
if (document.getElementById('projects-table-body')) loadProjects();
|
||||||
|
if (document.getElementById('account-settings-content')) loadAccountSettings();
|
||||||
|
|
||||||
loadProfile();
|
loadProfile();
|
||||||
});
|
});
|
||||||
@@ -481,4 +482,247 @@ async function loadProfile() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ACCOUNT SETTINGS ----
|
||||||
|
let latestRecoveryCodes = [];
|
||||||
|
|
||||||
|
function showAccountSettingsMessage(message, type = 'success') {
|
||||||
|
const box = document.getElementById('account-settings-message');
|
||||||
|
if (!box) return;
|
||||||
|
box.textContent = message;
|
||||||
|
box.className = `message ${type}`;
|
||||||
|
box.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTwoFactorPanels(enabled) {
|
||||||
|
const badge = document.getElementById('two-factor-badge');
|
||||||
|
const status = document.getElementById('two-factor-status');
|
||||||
|
const disabledPanel = document.getElementById('two-factor-disabled-panel');
|
||||||
|
const enabledPanel = document.getElementById('two-factor-enabled-panel');
|
||||||
|
|
||||||
|
if (!badge || !status || !disabledPanel || !enabledPanel) return;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
badge.textContent = 'Enabled';
|
||||||
|
badge.classList.add('success');
|
||||||
|
status.textContent = '2FA is enabled for your account.';
|
||||||
|
disabledPanel.style.display = 'none';
|
||||||
|
enabledPanel.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Disabled';
|
||||||
|
badge.classList.remove('success');
|
||||||
|
status.textContent = '2FA is disabled. Enable it to protect your account with an authenticator app.';
|
||||||
|
disabledPanel.style.display = 'block';
|
||||||
|
enabledPanel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecoveryCodes(codes) {
|
||||||
|
latestRecoveryCodes = codes || [];
|
||||||
|
const panel = document.getElementById('recovery-codes-panel');
|
||||||
|
const list = document.getElementById('recovery-codes-list');
|
||||||
|
if (!panel || !list) return;
|
||||||
|
|
||||||
|
if (latestRecoveryCodes.length === 0) {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
list.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.textContent = latestRecoveryCodes.join('\n');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccountSettings() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/profile');
|
||||||
|
|
||||||
|
const usernameInput = document.getElementById('settings-username');
|
||||||
|
const avatarPreview = document.getElementById('settings-avatar-preview');
|
||||||
|
const remaining = document.getElementById('recovery-codes-remaining');
|
||||||
|
|
||||||
|
if (usernameInput) usernameInput.value = data.username || '';
|
||||||
|
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
|
||||||
|
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
|
||||||
|
|
||||||
|
setTwoFactorPanels(!!data.two_factor_enabled);
|
||||||
|
renderRecoveryCodes([]);
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccountUsername(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/account/username', 'POST', {
|
||||||
|
username: document.getElementById('settings-username').value.trim(),
|
||||||
|
password: document.getElementById('settings-username-password').value
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-username-password').value = '';
|
||||||
|
showAccountSettingsMessage('Username updated.');
|
||||||
|
|
||||||
|
const username = document.getElementById('username');
|
||||||
|
const avatar = document.getElementById('avatar');
|
||||||
|
const avatarPreview = document.getElementById('settings-avatar-preview');
|
||||||
|
if (username) username.innerText = data.username;
|
||||||
|
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
|
||||||
|
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccountPassword(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const currentPassword = document.getElementById('settings-current-password').value;
|
||||||
|
const newPassword = document.getElementById('settings-new-password').value;
|
||||||
|
const confirmPassword = document.getElementById('settings-confirm-password').value;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
showAccountSettingsMessage('New passwords do not match.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/account/password', 'POST', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && data.access_token && data.refresh_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('password-form').reset();
|
||||||
|
showAccountSettingsMessage('Password updated. Your session was refreshed.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not update password.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTwoFactorSetup() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/2fa/setup', 'POST');
|
||||||
|
const panel = document.getElementById('two-factor-setup-panel');
|
||||||
|
const qr = document.getElementById('two-factor-qr');
|
||||||
|
const secret = document.getElementById('two-factor-secret');
|
||||||
|
const otpauth = document.getElementById('two-factor-otpauth');
|
||||||
|
|
||||||
|
if (panel) panel.style.display = 'block';
|
||||||
|
if (qr) {
|
||||||
|
qr.src = data.qr_code;
|
||||||
|
qr.style.display = data.qr_code ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
if (secret) secret.textContent = data.secret || '';
|
||||||
|
if (otpauth) {
|
||||||
|
otpauth.href = data.otpauth_url || '#';
|
||||||
|
otpauth.textContent = data.otpauth_url || 'No otpauth URL available';
|
||||||
|
}
|
||||||
|
|
||||||
|
showAccountSettingsMessage('Scan the QR code or enter the setup key manually, then confirm the 6-digit code.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not start 2FA setup.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableTwoFactor(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/2fa/enable', 'POST', {
|
||||||
|
code: document.getElementById('two-factor-enable-code').value.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('two-factor-enable-form').reset();
|
||||||
|
document.getElementById('two-factor-setup-panel').style.display = 'none';
|
||||||
|
setTwoFactorPanels(true);
|
||||||
|
renderRecoveryCodes(data.recovery_codes || []);
|
||||||
|
|
||||||
|
const remaining = document.getElementById('recovery-codes-remaining');
|
||||||
|
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
|
||||||
|
|
||||||
|
showAccountSettingsMessage('2FA enabled. Download your recovery codes now.');
|
||||||
|
loadProfile();
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not enable 2FA.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableTwoFactor(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('Disable 2FA for your account?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiRequest('/api/2fa/disable', 'POST', {
|
||||||
|
password: document.getElementById('two-factor-disable-password').value,
|
||||||
|
code: document.getElementById('two-factor-disable-code').value.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('two-factor-disable-form').reset();
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
setTwoFactorPanels(false);
|
||||||
|
renderRecoveryCodes([]);
|
||||||
|
showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not disable 2FA.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateRecoveryCodes(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
|
||||||
|
password: document.getElementById('recovery-password').value,
|
||||||
|
code: document.getElementById('recovery-code').value.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recovery-regenerate-form').reset();
|
||||||
|
renderRecoveryCodes(data.recovery_codes || []);
|
||||||
|
|
||||||
|
const remaining = document.getElementById('recovery-codes-remaining');
|
||||||
|
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
|
||||||
|
|
||||||
|
showAccountSettingsMessage('New recovery codes generated. Download them now.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not regenerate recovery codes.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadRecoveryCodes() {
|
||||||
|
if (!latestRecoveryCodes || latestRecoveryCodes.length === 0) {
|
||||||
|
showAccountSettingsMessage('No recovery codes available to download.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
'MiauInv recovery codes',
|
||||||
|
'Save these somewhere safe. Each code can be used once.',
|
||||||
|
'',
|
||||||
|
...latestRecoveryCodes,
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'miauinv-recovery-codes.txt';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ var projects = template.Must(template.ParseFiles(
|
|||||||
"frontend/htmx/contents/dash/base.html",
|
"frontend/htmx/contents/dash/base.html",
|
||||||
"frontend/htmx/contents/dash/projects.html"))
|
"frontend/htmx/contents/dash/projects.html"))
|
||||||
|
|
||||||
|
var accountSettings = template.Must(template.ParseFiles(
|
||||||
|
"frontend/htmx/contents/dash/base.html",
|
||||||
|
"frontend/htmx/contents/dash/account_settings.html"))
|
||||||
|
|
||||||
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
||||||
|
|
||||||
func Home(w http.ResponseWriter, r *http.Request) {
|
func Home(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -51,7 +55,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
@@ -142,6 +145,17 @@ func Projects(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
err := accountSettings.ExecuteTemplate(w, "base.html", struct {
|
||||||
|
Title string
|
||||||
|
}{
|
||||||
|
Title: "Account Settings",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var minifier *minify.M
|
var minifier *minify.M
|
||||||
|
|
||||||
@@ -151,7 +165,6 @@ func init() {
|
|||||||
minifier.AddFunc("text/css", css.Minify)
|
minifier.AddFunc("text/css", css.Minify)
|
||||||
minifier.AddFunc("text/javascript", js.Minify)
|
minifier.AddFunc("text/javascript", js.Minify)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/assets/")
|
path := strings.TrimPrefix(r.URL.Path, "/assets/")
|
||||||
fullPath := filepath.Join("frontend/assets", path)
|
fullPath := filepath.Join("frontend/assets", path)
|
||||||
|
|||||||
139
frontend/htmx/contents/dash/account_settings.html
Normal file
139
frontend/htmx/contents/dash/account_settings.html
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Account Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="account-settings-content">
|
||||||
|
<div id="account-settings-message" class="message" style="display: none; margin-bottom: 1.5rem;"></div>
|
||||||
|
|
||||||
|
<div class="modal-split" style="align-items: start;">
|
||||||
|
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
|
||||||
|
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username. Avatar upload is planned for later.</p>
|
||||||
|
|
||||||
|
<form id="username-form" onsubmit="saveAccountUsername(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-username" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Username</label>
|
||||||
|
<input type="text" id="settings-username" placeholder="Username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-username-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
|
||||||
|
<input type="password" id="settings-username-password" placeholder="Confirm with current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save username</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Avatar</h3>
|
||||||
|
<div style="display:flex; align-items:center; gap:1rem; color: var(--text-muted);">
|
||||||
|
<div id="settings-avatar-preview" class="avatar">M</div>
|
||||||
|
<div>
|
||||||
|
<div>Avatar upload is not implemented yet.</div>
|
||||||
|
<div style="font-size:0.85rem; margin-top:0.25rem;">This placeholder keeps the settings layout ready for it.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
|
||||||
|
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Password</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your password. You will receive a fresh session afterwards.</p>
|
||||||
|
|
||||||
|
<form id="password-form" onsubmit="saveAccountPassword(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-current-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
|
||||||
|
<input type="password" id="settings-current-password" placeholder="Current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-new-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">New password</label>
|
||||||
|
<input type="password" id="settings-new-password" placeholder="New password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="settings-confirm-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Confirm new password</label>
|
||||||
|
<input type="password" id="settings-confirm-password" placeholder="Confirm new password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Change password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem; margin-top: 1.5rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Two-factor authentication</h2>
|
||||||
|
<p id="two-factor-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading 2FA status...</p>
|
||||||
|
</div>
|
||||||
|
<span id="two-factor-badge" class="badge">Unknown</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="two-factor-disabled-panel" style="display: none;">
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use an authenticator app. You can scan the QR code or enter the setup key manually.</p>
|
||||||
|
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="startTwoFactorSetup()">Start 2FA setup</button>
|
||||||
|
|
||||||
|
<div id="two-factor-setup-panel" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
|
||||||
|
<div class="modal-split" style="align-items: start;">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Scan QR code</h3>
|
||||||
|
<img id="two-factor-qr" alt="2FA QR code" style="display: none; width: 220px; height: 220px; background: white; padding: 0.5rem; border-radius: 12px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Manual setup</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">If you do not want to scan the QR code, enter this key manually in your authenticator app.</p>
|
||||||
|
<code id="two-factor-secret" style="display:block; word-break: break-all; background:#111827; border:1px solid var(--border); border-radius:10px; padding:0.85rem; color:var(--text);"></code>
|
||||||
|
<a id="two-factor-otpauth" href="#" style="display:block; color: var(--accent); margin-top:0.75rem; word-break: break-all;">Open otpauth URL</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="two-factor-enable-form" onsubmit="enableTwoFactor(event)" style="margin-top: 1.5rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="two-factor-enable-code" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Authenticator code</label>
|
||||||
|
<input type="text" id="two-factor-enable-code" inputmode="numeric" placeholder="123456" required autocomplete="one-time-code">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Enable 2FA</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="two-factor-enabled-panel" style="display: none;">
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p>
|
||||||
|
|
||||||
|
<div id="recovery-codes-panel" style="display: none; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--border); border-radius: 12px; background: #111827;">
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Save these now. They are shown only once.</p>
|
||||||
|
<pre id="recovery-codes-list" style="white-space: pre-wrap; word-break: break-word; color: var(--text); background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; margin-bottom: 1rem;"></pre>
|
||||||
|
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="downloadRecoveryCodes()">Download recovery codes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-split" style="align-items: start;">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Regenerate recovery codes</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">This invalidates all existing recovery codes.</p>
|
||||||
|
<form id="recovery-regenerate-form" onsubmit="regenerateRecoveryCodes(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="recovery-password" placeholder="Current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="recovery-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary">Generate new recovery codes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable 2FA</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Disabling 2FA revokes your active refresh sessions.</p>
|
||||||
|
<form id="two-factor-disable-form" onsubmit="disableTwoFactor(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="two-factor-disable-password" placeholder="Current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="two-factor-disable-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary danger-btn">Disable 2FA</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -59,7 +59,6 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func APILogin(w http.ResponseWriter, r *http.Request) {
|
func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var creds struct {
|
var creds struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -110,7 +109,6 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
||||||
}
|
}
|
||||||
|
|
||||||
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
TwoFactorToken string `json:"two_factor_token"`
|
TwoFactorToken string `json:"two_factor_token"`
|
||||||
@@ -165,6 +163,117 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AccountUpdateUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(req.Username)
|
||||||
|
if username == "" || req.Password == "" {
|
||||||
|
http.Error(w, "Username and password required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
user, err := storage.GetUserById(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.CheckPasswordHash(req.Password, user.Password) {
|
||||||
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.UpdateUserUsername(user.ID, username); err != nil {
|
||||||
|
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Username already exists or could not be saved", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"username": strings.ToLower(username),
|
||||||
|
})
|
||||||
|
log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username")
|
||||||
|
}
|
||||||
|
func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||||
|
http.Error(w, "Current and new password required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) > 72 {
|
||||||
|
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CurrentPassword == req.NewPassword {
|
||||||
|
http.Error(w, "New password must be different", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
user, err := storage.GetUserById(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.CheckPasswordHash(req.CurrentPassword, user.Password) {
|
||||||
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed, err := auth.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not hash password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.UpdateUserPassword(user.ID, hashed); err != nil {
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not update password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = hashed
|
||||||
|
issueLoginSession(w, r, user)
|
||||||
|
log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
|
||||||
|
}
|
||||||
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -222,7 +331,6 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
|
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -275,7 +383,6 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
|
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -326,7 +433,6 @@ func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
|
||||||
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
|
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
|
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -396,7 +502,6 @@ func Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
clearAuthCookies(w)
|
clearAuthCookies(w)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler(w http.ResponseWriter, r *http.Request) {
|
func TestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, _ := utils.IsLoggedIn(w, r)
|
claims, _ := utils.IsLoggedIn(w, r)
|
||||||
|
|
||||||
@@ -412,7 +517,6 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
|
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
@@ -456,7 +560,6 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserInfo(w http.ResponseWriter, r *http.Request) {
|
func UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
|
||||||
@@ -525,7 +628,6 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
|
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
|
||||||
secret := []byte(os.Getenv("JWT_SECRET"))
|
secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
if len(secret) == 0 {
|
if len(secret) == 0 {
|
||||||
@@ -599,7 +701,6 @@ func generateRecoveryCodes(count int) ([]string, []string, error) {
|
|||||||
|
|
||||||
return codes, hashes, nil
|
return codes, hashes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRecoveryCode() (string, error) {
|
func generateRecoveryCode() (string, error) {
|
||||||
bytes := make([]byte, 10)
|
bytes := make([]byte, 10)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
@@ -609,7 +710,6 @@ func generateRecoveryCode() (string, error) {
|
|||||||
raw := hex.EncodeToString(bytes)
|
raw := hex.EncodeToString(bytes)
|
||||||
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
|
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRecoveryCode(code string) string {
|
func normalizeRecoveryCode(code string) string {
|
||||||
code = strings.TrimSpace(code)
|
code = strings.TrimSpace(code)
|
||||||
code = strings.ReplaceAll(code, "-", "")
|
code = strings.ReplaceAll(code, "-", "")
|
||||||
@@ -637,7 +737,6 @@ func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearAuthCookies(w http.ResponseWriter) {
|
func clearAuthCookies(w http.ResponseWriter) {
|
||||||
for _, name := range []string{"access_token", "refresh_token"} {
|
for _, name := range []string{"access_token", "refresh_token"} {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func (this *Server) Run() {
|
|||||||
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
|
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
|
||||||
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
|
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
|
||||||
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
|
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
|
||||||
|
mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
|
||||||
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
||||||
@@ -91,6 +92,8 @@ func (this *Server) Run() {
|
|||||||
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
|
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
|
||||||
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
|
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
|
||||||
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
|
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
|
||||||
|
mux.Handle("/api/account/username", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername)))
|
||||||
|
mux.Handle("/api/account/password", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword)))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/api/register", handlers.APIRegister)
|
mux.HandleFunc("/api/register", handlers.APIRegister)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,36 @@ func scanUser(row *sql.Row) (models.User, error) {
|
|||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpdateUserUsername(userID, username string) error {
|
||||||
|
res, err := DB.Exec("UPDATE users SET username = ? WHERE id = ?", strings.ToLower(username), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUserPassword(userID, passwordHash string) error {
|
||||||
|
res, err := DB.Exec("UPDATE users SET password = ? WHERE id = ?", passwordHash, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func SetUserTwoFactorSecret(userID, secret string) error {
|
func SetUserTwoFactorSecret(userID, secret string) error {
|
||||||
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
|
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user