Files
nebula/_migrate/login.html

562 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>