128 lines
5.9 KiB
HTML
128 lines
5.9 KiB
HTML
{{define "webmail_login_mfa.html"}}
|
|
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Verify it's you - Webmail</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
<style>
|
|
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
|
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
|
|
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container login-card">
|
|
<div class="text-center mb-4">
|
|
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
|
<h4 class="mt-2">Verify it's you</h4>
|
|
<p class="text-muted">One more step to finish signing in</p>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-4">
|
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
|
<div id="passkey-error" class="alert alert-danger d-none"></div>
|
|
|
|
{{if .has_passkeys}}
|
|
<div class="d-grid mb-3">
|
|
<button type="button" class="btn btn-primary" id="passkey-btn">
|
|
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
|
|
</button>
|
|
</div>
|
|
{{if .totp_enabled}}
|
|
<div class="text-center text-muted mb-3">
|
|
or <a href="#" id="show-totp-link">use an authenticator app code instead</a>
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{if .totp_enabled}}
|
|
<form method="POST" action="/webmail/login/mfa" id="totp-form" {{if .has_passkeys}}class="d-none"{{end}}>
|
|
<div class="mb-3">
|
|
<label for="code" class="form-label">6-digit authenticator code</label>
|
|
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required {{if not .has_passkeys}}autofocus{{end}}>
|
|
</div>
|
|
<div class="d-grid">
|
|
<button type="submit" class="btn {{if .has_passkeys}}btn-outline-primary{{else}}btn-primary{{end}}"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
|
</div>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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(/=+$/, '');
|
|
}
|
|
|
|
const showTotpLink = document.getElementById('show-totp-link');
|
|
if (showTotpLink) {
|
|
showTotpLink.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
document.getElementById('totp-form').classList.remove('d-none');
|
|
document.getElementById('code').focus();
|
|
this.parentElement.classList.add('d-none');
|
|
});
|
|
}
|
|
|
|
const passkeyBtn = document.getElementById('passkey-btn');
|
|
if (passkeyBtn) {
|
|
passkeyBtn.addEventListener('click', async function() {
|
|
const errEl = document.getElementById('passkey-error');
|
|
errEl.classList.add('d-none');
|
|
try {
|
|
const beginResp = await fetch('/webmail/login/passkey/begin');
|
|
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey login');
|
|
const options = await beginResp.json();
|
|
|
|
const publicKey = options.publicKey;
|
|
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
|
if (publicKey.allowCredentials) {
|
|
publicKey.allowCredentials = publicKey.allowCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
|
}
|
|
|
|
const assertion = await navigator.credentials.get({ publicKey });
|
|
|
|
const body = {
|
|
id: assertion.id,
|
|
rawId: bufToB64url(assertion.rawId),
|
|
type: assertion.type,
|
|
response: {
|
|
authenticatorData: bufToB64url(assertion.response.authenticatorData),
|
|
clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
|
|
signature: bufToB64url(assertion.response.signature),
|
|
userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null,
|
|
},
|
|
};
|
|
|
|
const finishResp = await fetch('/webmail/login/passkey/finish', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
});
|
|
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Passkey verification failed');
|
|
|
|
window.location.href = '/webmail/';
|
|
} catch (e) {
|
|
errEl.textContent = e.message || 'Passkey login failed';
|
|
errEl.classList.remove('d-none');
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|