562 lines
18 KiB
HTML
562 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<!--
|
||
================================================================================
|
||
TEMPL MIGRATION GUIDE: login.html → views/login.templ
|
||
================================================================================
|
||
|
||
PAGE OVERVIEW:
|
||
- WebAuthn sign-in page with multiple authentication methods
|
||
- Supports passkey (biometrics), security key, and QR code authentication
|
||
- Includes conditional UI for passkey autofill in username field
|
||
- Account recovery flow with recovery key or QR code verification
|
||
- VIEWPORT: Optimized for popup/webview (360×540 to 480×680)
|
||
|
||
VIEWPORT CONSTRAINTS (Auth Popup/Webview):
|
||
- Target sizes: 360×540 (small), 420×600 (medium), 480×680 (large)
|
||
- Card max-width: 45ch (~360px) fits all viewport sizes
|
||
- Auth method cards stack vertically, touch-friendly (min 44px height)
|
||
- QR code step should center and fit within 360px width
|
||
- Recovery flow inputs sized for constrained width
|
||
|
||
MAIN TEMPL COMPONENT:
|
||
templ LoginPage(step string, capabilities DeviceCapabilities) {
|
||
@layouts.CenteredCard("Sign In - Sonr Motr Wallet") {
|
||
switch step {
|
||
case "1":
|
||
@LoginStep1(capabilities)
|
||
case "qr":
|
||
@QRAuthStep()
|
||
case "recovery":
|
||
@RecoveryStep()
|
||
case "success":
|
||
@SuccessStep("Welcome Back!", "/dashboard")
|
||
}
|
||
}
|
||
}
|
||
|
||
HTMX INTEGRATION:
|
||
- Replace onclick="goToStep('recovery')" with hx-get="/login?step=recovery" hx-target="#step-content"
|
||
- Replace onclick="signIn()" with hx-post="/api/auth/signin" hx-target="#step-content"
|
||
- Auth method selection: hx-post="/login/select-method" hx-vals='{"method":"passkey"}'
|
||
- QR polling: hx-get="/api/auth/qr-status" hx-trigger="every 2s" hx-target="#qr-status"
|
||
- Conditional UI: autocomplete="username webauthn" triggers browser passkey autofill
|
||
|
||
SUB-COMPONENTS TO EXTRACT:
|
||
- AuthMethodCard(method string, icon string, title string, desc string, disabled bool)
|
||
- QRCodeSection(value string, label string, fallbackText string)
|
||
- RecoveryForm(method string) // "recovery-key" or "qr-code"
|
||
- SuccessStep(title string, redirectUrl string)
|
||
|
||
STATE/PROPS:
|
||
type DeviceCapabilities struct {
|
||
PlatformAuth bool // Face ID, Touch ID, Windows Hello
|
||
CrossPlatform bool // Security keys (YubiKey)
|
||
ConditionalUI bool // Passkey autofill support
|
||
}
|
||
|
||
type LoginState struct {
|
||
Step string
|
||
SelectedMethod string // "passkey", "security-key", "qr-code"
|
||
Username string
|
||
Error string
|
||
}
|
||
|
||
HTMX PATTERNS:
|
||
// Auth method card with HTMX selection
|
||
<div class="auth-method-card"
|
||
hx-post="/login/select-method"
|
||
hx-vals='{"method":"passkey"}'
|
||
hx-target="#btn-signin"
|
||
hx-swap="outerHTML">
|
||
|
||
// Sign in button enabled via HTMX swap
|
||
<wa-button id="btn-signin" variant="brand"
|
||
hx-post="/api/auth/signin"
|
||
hx-include="[name='username'],[name='method']"
|
||
hx-target="#step-content">
|
||
|
||
// QR code polling for cross-device auth
|
||
<div id="qr-status"
|
||
hx-get="/api/auth/qr-status?session=xyz789"
|
||
hx-trigger="every 2s"
|
||
hx-target="this">
|
||
================================================================================
|
||
-->
|
||
<html lang="en" class="wa-cloak">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Sign In - Sonr</title>
|
||
<script src="https://cdn.sonr.org/wa/autoloader.js"></script>
|
||
<style>
|
||
:root {
|
||
--wa-color-primary: #17c2ff;
|
||
}
|
||
|
||
html, body {
|
||
min-height: 100%;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.main-centered {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100%;
|
||
padding: var(--wa-space-l);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.main-centered wa-card {
|
||
width: 100%;
|
||
max-width: 45ch;
|
||
}
|
||
|
||
.step {
|
||
display: none;
|
||
}
|
||
|
||
.step.active {
|
||
display: block;
|
||
}
|
||
|
||
.auth-method-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--wa-space-m);
|
||
padding: var(--wa-space-m);
|
||
border-radius: var(--wa-radius-m);
|
||
background: var(--wa-color-surface-alt);
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: border-color 0.2s, background 0.2s;
|
||
}
|
||
|
||
.auth-method-card:hover {
|
||
background: var(--wa-color-surface-alt-hover);
|
||
}
|
||
|
||
.auth-method-card.selected {
|
||
border-color: var(--wa-color-primary);
|
||
}
|
||
|
||
.auth-method-card.disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.auth-method-card wa-icon {
|
||
font-size: 2rem;
|
||
color: var(--wa-color-primary);
|
||
}
|
||
|
||
.auth-method-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.auth-method-info h4 {
|
||
margin: 0 0 var(--wa-space-2xs) 0;
|
||
}
|
||
|
||
.auth-method-info p {
|
||
margin: 0;
|
||
color: var(--wa-color-neutral-600);
|
||
}
|
||
|
||
/* Viewport constraints for popup/webview (360-480px width) */
|
||
@media (max-width: 400px) {
|
||
.main-centered {
|
||
padding: var(--wa-space-m);
|
||
}
|
||
.main-centered wa-card {
|
||
max-width: 100%;
|
||
}
|
||
.auth-method-card {
|
||
padding: var(--wa-space-s);
|
||
gap: var(--wa-space-s);
|
||
}
|
||
.auth-method-card wa-icon {
|
||
font-size: 1.5rem;
|
||
}
|
||
}
|
||
|
||
@media (max-height: 600px) {
|
||
.auth-method-card {
|
||
padding: var(--wa-space-s);
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<wa-page>
|
||
<main class="main-centered">
|
||
<wa-card>
|
||
<!-- Step 1: Sign In Options -->
|
||
<div class="step active" data-step="1">
|
||
<div class="wa-stack wa-gap-l">
|
||
<div class="wa-stack wa-gap-2xs">
|
||
<h2 class="wa-heading-l">Welcome Back</h2>
|
||
<p class="wa-caption-s">How would you like to sign in?</p>
|
||
</div>
|
||
|
||
<wa-input
|
||
name="username"
|
||
type="text"
|
||
label="Username"
|
||
placeholder="Enter your username"
|
||
id="login-username"
|
||
autocomplete="username webauthn"
|
||
>
|
||
<wa-icon slot="end" variant="regular" name="user"></wa-icon>
|
||
</wa-input>
|
||
|
||
<div class="wa-stack wa-gap-s" id="auth-methods">
|
||
<div class="auth-method-card" data-method="passkey" id="method-passkey">
|
||
<wa-icon family="duotone" name="fingerprint"></wa-icon>
|
||
<div class="auth-method-info">
|
||
<h4 class="wa-label-m">Face or Fingerprint</h4>
|
||
<p class="wa-caption-s">Use your face, fingerprint, or device PIN</p>
|
||
</div>
|
||
<wa-spinner style="--size: 1.25rem; display: none;" id="passkey-spinner"></wa-spinner>
|
||
</div>
|
||
|
||
<div class="auth-method-card" data-method="security-key" id="method-security-key">
|
||
<wa-icon family="duotone" name="key"></wa-icon>
|
||
<div class="auth-method-info">
|
||
<h4 class="wa-label-m">Hardware Key</h4>
|
||
<p class="wa-caption-s">Use a physical security key</p>
|
||
</div>
|
||
<wa-spinner style="--size: 1.25rem; display: none;" id="security-key-spinner"></wa-spinner>
|
||
</div>
|
||
|
||
<div class="auth-method-card" data-method="qr-code" id="method-qr">
|
||
<wa-icon family="duotone" name="qrcode"></wa-icon>
|
||
<div class="auth-method-info">
|
||
<h4 class="wa-label-m">Use Another Device</h4>
|
||
<p class="wa-caption-s">Scan a QR code with your phone</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<wa-divider></wa-divider>
|
||
|
||
<div class="wa-cluster wa-justify-content-between wa-gap-s">
|
||
<wa-button appearance="plain" size="small" onclick="goToStep('recovery')">
|
||
Can't sign in?
|
||
</wa-button>
|
||
<wa-button variant="brand" id="btn-signin" disabled>
|
||
Sign In
|
||
<wa-icon slot="end" variant="regular" name="arrow-right"></wa-icon>
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: QR Code Authentication -->
|
||
<div class="step" data-step="qr">
|
||
<div class="wa-stack wa-gap-l wa-align-items-center">
|
||
<h2 class="wa-heading-l">Use Your Phone</h2>
|
||
<p class="wa-caption-s">Scan this code with your phone camera</p>
|
||
|
||
<wa-qr-code
|
||
value="https://sonr.id/auth?session=xyz789abc123"
|
||
label="Scan with your phone camera"
|
||
></wa-qr-code>
|
||
|
||
<p class="wa-caption-s">Or copy this link to your phone</p>
|
||
<wa-input
|
||
value="sonr.id/a/xyz789abc123"
|
||
disabled
|
||
style="width: 100%;"
|
||
>
|
||
<wa-copy-button slot="end" value="sonr.id/a/xyz789abc123"></wa-copy-button>
|
||
</wa-input>
|
||
|
||
<wa-divider></wa-divider>
|
||
|
||
<div class="wa-stack wa-gap-s wa-align-items-center" style="width: 100%;">
|
||
<wa-spinner id="qr-waiting-spinner"></wa-spinner>
|
||
<p class="wa-caption-s">Waiting for your phone...</p>
|
||
</div>
|
||
|
||
<wa-button appearance="outlined" variant="neutral" onclick="goToStep(1)" style="width: 100%;">
|
||
Back
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Recovery -->
|
||
<div class="step" data-step="recovery">
|
||
<div class="wa-stack wa-gap-l">
|
||
<h2 class="wa-heading-l">Recover Your Account</h2>
|
||
|
||
<wa-radio-group
|
||
label="How would you like to recover?"
|
||
orientation="horizontal"
|
||
name="recovery-method"
|
||
value="recovery-key"
|
||
id="recovery-method-group"
|
||
>
|
||
<wa-radio appearance="button" value="recovery-key">Backup Code</wa-radio>
|
||
<wa-radio appearance="button" value="qr-code">Phone</wa-radio>
|
||
</wa-radio-group>
|
||
|
||
<div id="recovery-key-form">
|
||
<div class="wa-stack wa-gap-m">
|
||
<p class="wa-caption-s">
|
||
Enter the backup code you saved when you created your account.
|
||
</p>
|
||
<wa-input
|
||
name="recovery-key"
|
||
type="text"
|
||
label="Backup Code"
|
||
placeholder="rk_sec_..."
|
||
id="recovery-key-input"
|
||
>
|
||
<wa-icon slot="end" variant="regular" name="key"></wa-icon>
|
||
</wa-input>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="recovery-qr-form" style="display: none;">
|
||
<div class="wa-stack wa-gap-m wa-align-items-center">
|
||
<p class="wa-caption-s">
|
||
Scan this code with your phone, then enter the code shown.
|
||
</p>
|
||
<wa-qr-code
|
||
value="https://sonr.id/recover?token=rec123xyz"
|
||
label="Scan with your phone"
|
||
></wa-qr-code>
|
||
<wa-input
|
||
type="text"
|
||
placeholder="000000"
|
||
maxlength="6"
|
||
style="text-align: center; font-size: 2rem; letter-spacing: 0.5em; max-width: 200px;"
|
||
id="recovery-code"
|
||
></wa-input>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wa-cluster wa-justify-content-end wa-gap-s">
|
||
<wa-button appearance="outlined" variant="neutral" onclick="goToStep(1)">
|
||
Back
|
||
</wa-button>
|
||
<wa-button variant="brand" id="btn-recover">
|
||
Recover
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 4: Success -->
|
||
<div class="step" data-step="success">
|
||
<div class="wa-stack wa-gap-l wa-align-items-center">
|
||
<wa-icon name="circle-check" family="duotone" style="font-size: 4rem; color: var(--wa-color-success);"></wa-icon>
|
||
<h2 class="wa-heading-l">You're In</h2>
|
||
<p class="wa-caption-s">
|
||
Signed in successfully.
|
||
</p>
|
||
|
||
<wa-button variant="brand" style="width: 100%;" onclick="window.location.href='dashboard.html'">
|
||
Go to Dashboard
|
||
<wa-icon slot="end" variant="regular" name="arrow-right"></wa-icon>
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
|
||
<footer slot="footer" class="wa-cluster wa-justify-content-center wa-gap-m">
|
||
<span class="wa-caption-s">Don't have an account?</span>
|
||
<wa-button appearance="plain" size="small" onclick="window.location.href='register.html'">
|
||
Create Account
|
||
</wa-button>
|
||
</footer>
|
||
</wa-card>
|
||
</main>
|
||
</wa-page>
|
||
|
||
<script>
|
||
let selectedMethod = null;
|
||
let capabilities = {
|
||
platform: false,
|
||
crossPlatform: false,
|
||
conditional: false
|
||
};
|
||
|
||
async function checkCapabilities() {
|
||
if (!window.PublicKeyCredential) {
|
||
document.getElementById('method-passkey').classList.add('disabled');
|
||
document.getElementById('method-security-key').classList.add('disabled');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
capabilities.platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||
if (!capabilities.platform) {
|
||
document.getElementById('method-passkey').classList.add('disabled');
|
||
}
|
||
} catch (e) {
|
||
document.getElementById('method-passkey').classList.add('disabled');
|
||
}
|
||
|
||
capabilities.crossPlatform = true;
|
||
|
||
try {
|
||
if (PublicKeyCredential.isConditionalMediationAvailable) {
|
||
capabilities.conditional = await PublicKeyCredential.isConditionalMediationAvailable();
|
||
if (capabilities.conditional) {
|
||
startConditionalUI();
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function startConditionalUI() {
|
||
try {
|
||
const challenge = new Uint8Array(32);
|
||
crypto.getRandomValues(challenge);
|
||
|
||
const credential = await navigator.credentials.get({
|
||
publicKey: {
|
||
challenge: challenge,
|
||
rpId: window.location.hostname,
|
||
userVerification: "preferred",
|
||
timeout: 300000
|
||
},
|
||
mediation: "conditional"
|
||
});
|
||
|
||
if (credential) {
|
||
console.log("Conditional UI authentication successful:", credential);
|
||
goToStep('success');
|
||
}
|
||
} catch (error) {
|
||
console.log("Conditional UI not used or failed:", error);
|
||
}
|
||
}
|
||
|
||
function selectMethod(method) {
|
||
if (method === 'passkey' && !capabilities.platform) return;
|
||
if (method === 'security-key' && !capabilities.crossPlatform) return;
|
||
|
||
document.querySelectorAll('.auth-method-card').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
|
||
const card = document.querySelector(`[data-method="${method}"]`);
|
||
if (card && !card.classList.contains('disabled')) {
|
||
card.classList.add('selected');
|
||
selectedMethod = method;
|
||
document.getElementById('btn-signin').removeAttribute('disabled');
|
||
}
|
||
}
|
||
|
||
async function signIn() {
|
||
if (!selectedMethod) return;
|
||
|
||
if (selectedMethod === 'qr-code') {
|
||
goToStep('qr');
|
||
simulateQRPolling();
|
||
return;
|
||
}
|
||
|
||
const spinner = document.getElementById(
|
||
selectedMethod === 'passkey' ? 'passkey-spinner' : 'security-key-spinner'
|
||
);
|
||
spinner.style.display = 'block';
|
||
|
||
try {
|
||
const challenge = new Uint8Array(32);
|
||
crypto.getRandomValues(challenge);
|
||
|
||
const getOptions = {
|
||
publicKey: {
|
||
challenge: challenge,
|
||
rpId: window.location.hostname,
|
||
userVerification: selectedMethod === 'passkey' ? "required" : "preferred",
|
||
timeout: 60000
|
||
}
|
||
};
|
||
|
||
if (selectedMethod === 'security-key') {
|
||
getOptions.publicKey.allowCredentials = [];
|
||
}
|
||
|
||
const credential = await navigator.credentials.get(getOptions);
|
||
console.log("Authentication successful:", credential);
|
||
goToStep('success');
|
||
} catch (error) {
|
||
console.error("Authentication failed:", error);
|
||
alert("Authentication failed. Please try again.");
|
||
} finally {
|
||
spinner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function simulateQRPolling() {
|
||
setTimeout(() => {
|
||
const step = document.querySelector('.step[data-step="qr"]');
|
||
if (step.classList.contains('active')) {
|
||
goToStep('success');
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function goToStep(step) {
|
||
document.querySelectorAll('.step').forEach(el => el.classList.remove('active'));
|
||
const targetStep = document.querySelector(`.step[data-step="${step}"]`);
|
||
if (targetStep) {
|
||
targetStep.classList.add('active');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
checkCapabilities();
|
||
|
||
document.querySelectorAll('.auth-method-card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
selectMethod(card.dataset.method);
|
||
});
|
||
});
|
||
|
||
document.getElementById('btn-signin').addEventListener('click', signIn);
|
||
|
||
const recoveryMethodGroup = document.getElementById('recovery-method-group');
|
||
recoveryMethodGroup.addEventListener('wa-change', (e) => {
|
||
const keyForm = document.getElementById('recovery-key-form');
|
||
const qrForm = document.getElementById('recovery-qr-form');
|
||
|
||
if (e.target.value === 'recovery-key') {
|
||
keyForm.style.display = 'block';
|
||
qrForm.style.display = 'none';
|
||
} else {
|
||
keyForm.style.display = 'none';
|
||
qrForm.style.display = 'block';
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-recover').addEventListener('click', () => {
|
||
const method = recoveryMethodGroup.value;
|
||
if (method === 'recovery-key') {
|
||
const key = document.getElementById('recovery-key-input').value;
|
||
if (key.startsWith('rk_')) {
|
||
console.log("Recovery key validated:", key);
|
||
goToStep('success');
|
||
} else {
|
||
alert("Please enter a valid recovery key");
|
||
}
|
||
} else {
|
||
const code = document.getElementById('recovery-code').value;
|
||
if (code.length === 6) {
|
||
console.log("Recovery code verified:", code);
|
||
goToStep('success');
|
||
} else {
|
||
alert("Please enter a valid 6-digit code");
|
||
}
|
||
}
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|