142 lines
6.9 KiB
HTML
142 lines
6.9 KiB
HTML
{{define "title"}}Account Settings{{end}}
|
|
{{define "page_title"}}Account Settings{{end}}
|
|
|
|
{{define "content"}}
|
|
{{if .mfa_required}}
|
|
<div class="alert alert-warning">
|
|
<i class="bi bi-shield-exclamation me-2"></i>
|
|
Your administrator requires two-factor authentication for all admin accounts. Set up an authenticator app or a passkey below to continue using the dashboard.
|
|
</div>
|
|
{{end}}
|
|
<div class="row">
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card">
|
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-person-circle me-2"></i>Profile</h5></div>
|
|
<div class="card-body">
|
|
<p><strong>Username:</strong> {{.user.Username}}</p>
|
|
<hr>
|
|
<h6>Change password</h6>
|
|
<form method="POST" action="/pymta-manager/account/password">
|
|
<div class="mb-3">
|
|
<label class="form-label">Current password</label>
|
|
<input type="password" class="form-control" name="current_password" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">New password</label>
|
|
<input type="password" class="form-control" name="new_password" required minlength="10">
|
|
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Confirm new password</label>
|
|
<input type="password" class="form-control" name="new_password_confirm" required minlength="10">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update password</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card mb-4">
|
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-phone me-2"></i>Authenticator App (TOTP)</h5></div>
|
|
<div class="card-body">
|
|
{{if .user.TOTPEnabled}}
|
|
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
|
|
<form method="POST" action="/pymta-manager/account/totp/disable">
|
|
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Disable authenticator app MFA?">Disable</button>
|
|
</form>
|
|
{{else}}
|
|
<p class="text-muted">Not enabled. Add an authenticator app (Google Authenticator, 1Password, etc.) as an optional second factor.</p>
|
|
<form method="POST" action="/pymta-manager/account/totp/setup">
|
|
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-qr-code me-1"></i>Set up</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-fingerprint me-2"></i>Passkeys / Security Keys</h5></div>
|
|
<div class="card-body">
|
|
{{if .passkeys}}
|
|
<ul class="list-group mb-3">
|
|
{{range .passkeys}}
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
<span><i class="bi bi-key me-2"></i>{{.Name}} <small class="text-muted">added {{strftime "%Y-%m-%d" .CreatedAt}}</small></span>
|
|
<form method="POST" action="/pymta-manager/account/passkey/{{.ID}}/remove">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger" data-confirm="Remove this passkey?">Remove</button>
|
|
</form>
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}
|
|
<p class="text-muted">No passkeys registered yet.</p>
|
|
{{end}}
|
|
<div id="passkey-error" class="alert alert-danger d-none"></div>
|
|
<button type="button" class="btn btn-outline-primary btn-sm" id="add-passkey-btn"><i class="bi bi-plus-circle me-1"></i>Add a passkey</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{define "extra_js"}}
|
|
<script>
|
|
function b64urlToBuf(s) {
|
|
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
while (s.length % 4) s += '=';
|
|
const bin = atob(s);
|
|
const buf = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
return buf.buffer;
|
|
}
|
|
function bufToB64url(buf) {
|
|
const bytes = new Uint8Array(buf);
|
|
let bin = '';
|
|
bytes.forEach(b => bin += String.fromCharCode(b));
|
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
document.getElementById('add-passkey-btn').addEventListener('click', async function() {
|
|
const errEl = document.getElementById('passkey-error');
|
|
errEl.classList.add('d-none');
|
|
try {
|
|
const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey') || 'Passkey';
|
|
|
|
const beginResp = await fetch('/pymta-manager/account/passkey/begin', { method: 'POST' });
|
|
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
|
const options = await beginResp.json();
|
|
|
|
const publicKey = options.publicKey;
|
|
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
|
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
|
if (publicKey.excludeCredentials) {
|
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
|
}
|
|
|
|
const credential = await navigator.credentials.create({ publicKey });
|
|
|
|
const body = {
|
|
id: credential.id,
|
|
rawId: bufToB64url(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
attestationObject: bufToB64url(credential.response.attestationObject),
|
|
clientDataJSON: bufToB64url(credential.response.clientDataJSON),
|
|
},
|
|
};
|
|
|
|
const finishResp = await fetch('/pymta-manager/account/passkey/finish?name=' + encodeURIComponent(name), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
});
|
|
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
|
|
|
showToast('Passkey added', 'success');
|
|
setTimeout(() => location.reload(), 800);
|
|
} catch (e) {
|
|
errEl.textContent = e.message || 'Adding the passkey failed';
|
|
errEl.classList.remove('d-none');
|
|
}
|
|
});
|
|
</script>
|
|
{{end}}
|