Files
nebula/_migrate/register.html

681 lines
23 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: register.html → views/register.templ
================================================================================
PAGE OVERVIEW:
- WebAuthn registration flow with 3-step wizard
- Step 1: Device capability detection + auth method selection
- Step 2: Method-specific registration (passkey, security key, or QR)
- Step 3: Success with recovery key display and confirmation
- 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
- Capability list items stack vertically with compact padding
- QR code centered with fallback text input below
- Recovery key inputs scrollable if needed on step 3
MAIN TEMPL COMPONENT:
templ RegisterPage(step int, method string, caps DeviceCapabilities) {
@layouts.CenteredCard("Register - Sonr Motr Wallet") {
@ProgressDots(step, 3)
switch step {
case 1:
@DeviceDetectionStep(caps)
case 2:
@RegistrationMethodStep(method)
case 3:
@RegistrationSuccessStep()
}
}
}
HTMX INTEGRATION:
- Device detection: hx-get="/api/device/capabilities" hx-trigger="load" hx-target="#capabilities"
- Method selection: hx-post="/register/select-method" hx-vals='{"method":"passkey"}'
- Step navigation: hx-get="/register?step=2&method=passkey" hx-target="#step-content"
- WebAuthn trigger: hx-post="/api/auth/register" hx-trigger="click" (JS bridge needed)
- QR verification: hx-post="/api/auth/verify-code" hx-include="[name='code']"
SUB-COMPONENTS TO EXTRACT:
- ProgressDots(currentStep int, totalSteps int)
- CapabilityItem(id string, label string, supported bool, loading bool)
- RegistrationForm(method string) // passkey, security-key, or qr-code
- RecoveryKeyDisplay(publicKey string, recoveryKey string)
- QRCodeRegistration(qrValue string, sessionToken string)
STATE/PROPS:
type DeviceCapabilities struct {
PlatformAuth bool // Biometrics available
CrossPlatform bool // Security key support
ConditionalUI bool // Passkey autofill
}
type RegisterState struct {
Step int
SelectedMethod string
Username string
DisplayName string
PublicKey string
RecoveryKey string
Error string
}
HTMX PATTERNS:
// Capability detection on page load
<div id="capabilities" hx-get="/api/device/capabilities" hx-trigger="load delay:500ms">
@CapabilityItem("platform", "Biometric Authentication", false, true)
@CapabilityItem("cross-platform", "Security Key", false, true)
@CapabilityItem("conditional", "Passkey Autofill", false, true)
</div>
// Radio group with HTMX method selection
<wa-radio-group hx-on:wa-change="htmx.ajax('POST', '/register/select-method', {values: {method: event.target.value}})">
// Registration form submission (requires JS bridge for WebAuthn)
<form hx-post="/api/auth/register"
hx-target="#step-content"
hx-indicator="#register-spinner">
// Recovery key confirmation checkbox enabling finish button
<wa-checkbox hx-on:wa-change="document.getElementById('btn-finish').disabled = !this.checked">
================================================================================
-->
<html lang="en" class="wa-cloak">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create Account - 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;
}
/* Pagination steps */
.step {
display: none;
}
.step.active {
display: block;
}
/* Device capability badges */
.capability-list {
display: flex;
flex-direction: column;
gap: var(--wa-space-s);
}
.capability-item {
display: flex;
align-items: center;
gap: var(--wa-space-s);
padding: var(--wa-space-s);
border-radius: var(--wa-radius-m);
background: var(--wa-color-surface-alt);
}
.capability-item.supported {
border-left: 3px solid var(--wa-color-success);
}
.capability-item.unsupported {
border-left: 3px solid var(--wa-color-danger);
opacity: 0.6;
}
/* Progress indicator */
.progress-steps {
display: flex;
justify-content: center;
gap: var(--wa-space-xs);
margin-bottom: var(--wa-space-l);
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wa-color-neutral-300);
transition: background 0.2s;
}
.progress-dot.active {
background: var(--wa-color-primary);
}
.progress-dot.completed {
background: var(--wa-color-success);
}
/* 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%;
}
.capability-item {
padding: var(--wa-space-xs);
gap: var(--wa-space-xs);
font-size: var(--wa-font-size-s);
}
}
@media (max-height: 600px) {
.progress-steps {
margin-bottom: var(--wa-space-s);
}
.capability-item {
padding: var(--wa-space-xs);
}
}
</style>
</head>
<body>
<wa-page>
<main class="main-centered">
<wa-card>
<div slot="header">
<div class="progress-steps">
<div class="progress-dot active" data-step="1"></div>
<div class="progress-dot" data-step="2"></div>
<div class="progress-dot" data-step="3"></div>
</div>
</div>
<!-- Step 1: Device Detection -->
<div class="step active" data-step="1">
<div class="wa-stack wa-gap-l">
<h2 class="wa-heading-l">Create Your Account</h2>
<p class="wa-caption-s">
Setting up secure sign-in...
</p>
<div class="capability-list" id="capabilities">
<div class="capability-item" id="cap-platform">
<wa-spinner style="--size: 1.5rem;"></wa-spinner>
<span>Face or Fingerprint</span>
</div>
<div class="capability-item" id="cap-cross-platform">
<wa-spinner style="--size: 1.5rem;"></wa-spinner>
<span>Hardware Security Key</span>
</div>
<div class="capability-item" id="cap-conditional">
<wa-spinner style="--size: 1.5rem;"></wa-spinner>
<span>Quick Sign-in</span>
</div>
</div>
<wa-divider></wa-divider>
<wa-radio-group
label="How do you want to sign in?"
orientation="vertical"
name="auth-method"
id="auth-method-group"
>
<wa-radio appearance="button" value="passkey" id="radio-passkey" disabled>
Use Face or Fingerprint (Recommended)
</wa-radio>
<wa-radio appearance="button" value="security-key" id="radio-security-key" disabled>
Use a Hardware Key
</wa-radio>
<wa-radio appearance="button" value="qr-code" id="radio-qr">
Use Another Device
</wa-radio>
</wa-radio-group>
<div class="wa-cluster wa-justify-content-end wa-gap-s">
<wa-button id="btn-next-1" variant="brand" disabled>
Continue
<wa-icon slot="end" variant="regular" name="arrow-right"></wa-icon>
</wa-button>
</div>
</div>
</div>
<!-- Step 2a: Passkey Registration -->
<div class="step" data-step="2" data-method="passkey">
<div class="wa-stack wa-gap-l">
<h2 class="wa-heading-l">Set Up Face or Fingerprint</h2>
<p class="wa-caption-s">
Sign in securely using your face or fingerprint.
</p>
<wa-input
name="username"
type="text"
label="Username"
placeholder="Choose a username"
required
>
<wa-icon slot="end" variant="regular" name="user"></wa-icon>
</wa-input>
<wa-input
name="display-name"
type="text"
label="Display Name"
placeholder="Your name (optional)"
>
<wa-icon slot="end" variant="regular" name="id-card"></wa-icon>
</wa-input>
<wa-alert variant="info" open>
<wa-icon slot="icon" name="fingerprint"></wa-icon>
<strong>Next: Verify your identity</strong><br>
Your device will ask for your face, fingerprint, or PIN.
</wa-alert>
<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" onclick="registerPasskey()">
Continue
<wa-icon slot="end" variant="regular" name="fingerprint"></wa-icon>
</wa-button>
</div>
</div>
</div>
<!-- Step 2b: Security Key Registration -->
<div class="step" data-step="2" data-method="security-key">
<div class="wa-stack wa-gap-l">
<h2 class="wa-heading-l">Set Up Hardware Key</h2>
<p class="wa-caption-s">
Use a physical security key like YubiKey to sign in.
</p>
<wa-input
name="username"
type="text"
label="Username"
placeholder="Choose a username"
required
>
<wa-icon slot="end" variant="regular" name="user"></wa-icon>
</wa-input>
<wa-alert variant="info" open>
<wa-icon slot="icon" name="key"></wa-icon>
<strong>Plug in your security key</strong><br>
After clicking Continue, tap the button on your key when it blinks.
</wa-alert>
<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" onclick="registerSecurityKey()">
Continue
<wa-icon slot="end" variant="regular" name="key"></wa-icon>
</wa-button>
</div>
</div>
</div>
<!-- Step 2c: QR Code Registration (Fallback) -->
<div class="step" data-step="2" data-method="qr-code">
<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 to continue setup</p>
<wa-qr-code
value="https://sonr.id/register?token=abc123xyz789"
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/r/abc123xyz789"
disabled
style="width: 100%;"
>
<wa-copy-button slot="end" value="sonr.id/r/abc123xyz789"></wa-copy-button>
</wa-input>
<wa-divider></wa-divider>
<h3 class="wa-heading-m">Enter Code from Phone</h3>
<p class="wa-caption-s">After scanning, enter the 6-digit code shown on your phone.</p>
<wa-input
type="text"
placeholder="000000"
maxlength="6"
style="text-align: center; font-size: 2rem; letter-spacing: 0.5em; max-width: 200px;"
id="verification-code"
></wa-input>
<div class="wa-cluster wa-justify-content-center wa-gap-s" style="width: 100%;">
<wa-button appearance="outlined" variant="neutral" onclick="goToStep(1)">
Back
</wa-button>
<wa-button variant="brand" onclick="verifyCode()">
Verify
</wa-button>
</div>
</div>
</div>
<!-- Step 3: Success -->
<div class="step" data-step="3">
<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 All Set</h2>
<p class="wa-caption-s">
Your account is ready. Save your backup codes before continuing.
</p>
<wa-alert variant="warning" open>
<wa-icon slot="icon" name="shield-exclamation"></wa-icon>
<strong>Important: Save these codes now</strong><br>
If you lose your device, these codes are the only way to recover your account.
</wa-alert>
<div class="wa-stack" style="width: 100%;">
<wa-input label="Account ID" value="pk_live_a1b2c3d4e5f6g7h8i9j0" disabled>
<wa-copy-button slot="end" value="pk_live_a1b2c3d4e5f6g7h8i9j0"></wa-copy-button>
</wa-input>
<wa-input label="Backup Code" value="rk_sec_z9y8x7w6v5u4t3s2r1q0" disabled>
<wa-copy-button slot="end" value="rk_sec_z9y8x7w6v5u4t3s2r1q0"></wa-copy-button>
</wa-input>
</div>
<wa-checkbox id="confirm-saved" required>
I saved these codes somewhere safe
</wa-checkbox>
<wa-button variant="brand" id="btn-finish" disabled style="width: 100%;">
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">Already have an account?</span>
<wa-button appearance="plain" size="small" onclick="window.location.href='login.html'">Sign In</wa-button>
</footer>
</wa-card>
</main>
</wa-page>
<script>
// State
let currentStep = 1;
let selectedMethod = null;
let capabilities = {
platform: false,
crossPlatform: false,
conditional: false
};
// Check WebAuthn support
async function checkCapabilities() {
const capPlatform = document.getElementById('cap-platform');
const capCrossPlatform = document.getElementById('cap-cross-platform');
const capConditional = document.getElementById('cap-conditional');
// Check if WebAuthn is available at all
if (!window.PublicKeyCredential) {
markCapability(capPlatform, false);
markCapability(capCrossPlatform, false);
markCapability(capConditional, false);
enableMethodSelection();
return;
}
// Check platform authenticator (biometrics)
try {
capabilities.platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
markCapability(capPlatform, capabilities.platform);
} catch (e) {
markCapability(capPlatform, false);
}
// Cross-platform (security keys) - generally available if WebAuthn exists
capabilities.crossPlatform = true;
markCapability(capCrossPlatform, true);
// Check conditional UI (passkey autofill)
try {
if (PublicKeyCredential.isConditionalMediationAvailable) {
capabilities.conditional = await PublicKeyCredential.isConditionalMediationAvailable();
}
markCapability(capConditional, capabilities.conditional);
} catch (e) {
markCapability(capConditional, false);
}
enableMethodSelection();
}
function markCapability(element, supported) {
const spinner = element.querySelector('wa-spinner');
if (spinner) {
const icon = document.createElement('wa-icon');
icon.setAttribute('name', supported ? 'circle-check' : 'circle-xmark');
icon.style.color = supported ? 'var(--wa-color-success)' : 'var(--wa-color-danger)';
spinner.replaceWith(icon);
}
element.classList.add(supported ? 'supported' : 'unsupported');
}
function enableMethodSelection() {
const radioPasskey = document.getElementById('radio-passkey');
const radioSecurityKey = document.getElementById('radio-security-key');
const radioQr = document.getElementById('radio-qr');
const btnNext = document.getElementById('btn-next-1');
const methodGroup = document.getElementById('auth-method-group');
// Enable options based on capabilities
if (capabilities.platform) {
radioPasskey.removeAttribute('disabled');
}
if (capabilities.crossPlatform) {
radioSecurityKey.removeAttribute('disabled');
}
// QR is always available as fallback
// Auto-select best available option
if (capabilities.platform) {
methodGroup.value = 'passkey';
selectedMethod = 'passkey';
} else if (capabilities.crossPlatform) {
methodGroup.value = 'security-key';
selectedMethod = 'security-key';
} else {
methodGroup.value = 'qr-code';
selectedMethod = 'qr-code';
}
btnNext.removeAttribute('disabled');
// Listen for method changes
methodGroup.addEventListener('wa-change', (e) => {
selectedMethod = e.target.value;
});
}
function goToStep(step) {
// Hide all steps
document.querySelectorAll('.step').forEach(el => el.classList.remove('active'));
// Update progress dots
document.querySelectorAll('.progress-dot').forEach(dot => {
const dotStep = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (dotStep < step) {
dot.classList.add('completed');
} else if (dotStep === step) {
dot.classList.add('active');
}
});
// Show appropriate step
if (step === 2 && selectedMethod) {
const methodStep = document.querySelector(`.step[data-step="2"][data-method="${selectedMethod}"]`);
if (methodStep) {
methodStep.classList.add('active');
}
} else {
const targetStep = document.querySelector(`.step[data-step="${step}"]:not([data-method])`);
if (targetStep) {
targetStep.classList.add('active');
}
}
currentStep = step;
}
// Registration functions (simulated)
async function registerPasskey() {
try {
// In production, get challenge from server
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const createOptions = {
publicKey: {
challenge: challenge,
rp: {
name: "Sonr",
id: window.location.hostname
},
user: {
id: new Uint8Array(16),
name: document.querySelector('[name="username"]').value || "user@example.com",
displayName: document.querySelector('[name="display-name"]').value || "User"
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required",
residentKey: "required"
},
timeout: 60000
}
};
const credential = await navigator.credentials.create(createOptions);
console.log("Passkey created:", credential);
goToStep(3);
} catch (error) {
console.error("Passkey registration failed:", error);
alert("Registration failed. Please try again.");
}
}
async function registerSecurityKey() {
try {
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const createOptions = {
publicKey: {
challenge: challenge,
rp: {
name: "Sonr",
id: window.location.hostname
},
user: {
id: new Uint8Array(16),
name: document.querySelector('.step[data-method="security-key"] [name="username"]').value || "user@example.com",
displayName: "User"
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -257 }
],
authenticatorSelection: {
authenticatorAttachment: "cross-platform",
userVerification: "preferred"
},
timeout: 60000
}
};
const credential = await navigator.credentials.create(createOptions);
console.log("Security key registered:", credential);
goToStep(3);
} catch (error) {
console.error("Security key registration failed:", error);
alert("Registration failed. Please try again.");
}
}
function verifyCode() {
const code = document.getElementById('verification-code').value;
if (code.length === 6) {
// In production, verify with server
console.log("Verifying code:", code);
goToStep(3);
} else {
alert("Please enter a valid 6-digit code");
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Check capabilities after a short delay to show loading state
setTimeout(checkCapabilities, 1000);
// Next button handler
document.getElementById('btn-next-1').addEventListener('click', () => {
if (selectedMethod) {
goToStep(2);
}
});
// Finish button handler
const confirmCheckbox = document.getElementById('confirm-saved');
const finishBtn = document.getElementById('btn-finish');
confirmCheckbox.addEventListener('wa-change', (e) => {
if (e.target.checked) {
finishBtn.removeAttribute('disabled');
} else {
finishBtn.setAttribute('disabled', '');
}
});
finishBtn.addEventListener('click', () => {
window.location.href = 'dashboard.html';
});
});
</script>
</body>
</html>