chore: remove deprecated templ files and update auth routes
BREAKING: migrates to templ from html templates in login, register and welcome pages
This commit is contained in:
@@ -1,561 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,680 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,536 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
================================================================================
|
||||
TEMPL MIGRATION GUIDE: welcome.html → views/welcome.templ
|
||||
================================================================================
|
||||
|
||||
PAGE OVERVIEW:
|
||||
- Onboarding landing page with 3-step stepper (Welcome → Learn → Get Started)
|
||||
- Routes users to register.html or login.html
|
||||
- Network status indicator in footer
|
||||
- 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: 48ch (~384px) fits all viewport sizes
|
||||
- Padding scales down on smaller viewports via media queries
|
||||
- Touch targets minimum 44×44px for mobile accessibility
|
||||
- Footer should remain visible without scrolling on step 1/3
|
||||
- Scrollable content area for step 2 (feature list)
|
||||
|
||||
MAIN TEMPL COMPONENT:
|
||||
templ WelcomePage() {
|
||||
@layouts.CenteredCard("Welcome - Sonr Motr Wallet") {
|
||||
@OnboardingStepper(1)
|
||||
@WelcomeStep1()
|
||||
}
|
||||
}
|
||||
|
||||
HTMX INTEGRATION:
|
||||
- Replace onclick="goToStep(N)" with hx-get="/welcome/step/N" hx-target="#step-content"
|
||||
- Stepper state managed server-side via URL params or session
|
||||
- Navigation buttons trigger HTMX partial updates
|
||||
|
||||
STATE MANAGEMENT:
|
||||
- Current step passed as prop: currentStep int
|
||||
- Step completion status tracked server-side
|
||||
================================================================================
|
||||
-->
|
||||
<html lang="en" class="wa-cloak">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Welcome - Sonr Motr Wallet</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: 48ch;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.onboarding-stepper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--wa-space-s);
|
||||
padding: var(--wa-space-m) 0;
|
||||
}
|
||||
|
||||
.stepper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wa-space-xs);
|
||||
}
|
||||
|
||||
.stepper-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--wa-font-size-s);
|
||||
font-weight: 600;
|
||||
background: var(--wa-color-neutral-200);
|
||||
color: var(--wa-color-neutral-600);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-number {
|
||||
background: var(--wa-color-primary);
|
||||
color: white;
|
||||
box-shadow: 0 0 0 4px var(--wa-color-primary-subtle);
|
||||
}
|
||||
|
||||
.stepper-item.completed .stepper-number {
|
||||
background: var(--wa-color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stepper-label {
|
||||
font-size: var(--wa-font-size-xs);
|
||||
color: var(--wa-color-neutral-500);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.stepper-label {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
.hero-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.hero-icon wa-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
.feature-item {
|
||||
padding: var(--wa-space-s);
|
||||
}
|
||||
.feature-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.action-card {
|
||||
padding: var(--wa-space-m);
|
||||
}
|
||||
.action-card wa-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.hero-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: var(--wa-space-m);
|
||||
}
|
||||
.hero-icon wa-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
.onboarding-stepper {
|
||||
padding: var(--wa-space-s) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-label {
|
||||
color: var(--wa-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stepper-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--wa-color-neutral-200);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.stepper-line.completed {
|
||||
background: var(--wa-color-success);
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--wa-radius-l);
|
||||
background: linear-gradient(135deg, var(--wa-color-primary), #0090ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--wa-space-l);
|
||||
box-shadow: 0 8px 32px rgba(23, 194, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-icon wa-icon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wa-space-m);
|
||||
padding: var(--wa-space-m);
|
||||
background: var(--wa-color-surface-alt);
|
||||
border-radius: var(--wa-radius-m);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--wa-radius-s);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-icon.security {
|
||||
background: var(--wa-color-success-subtle);
|
||||
color: var(--wa-color-success);
|
||||
}
|
||||
|
||||
.feature-icon.speed {
|
||||
background: var(--wa-color-primary-subtle);
|
||||
color: var(--wa-color-primary);
|
||||
}
|
||||
|
||||
.feature-icon.privacy {
|
||||
background: var(--wa-color-warning-subtle);
|
||||
color: var(--wa-color-warning);
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--wa-space-m);
|
||||
padding: var(--wa-space-xl);
|
||||
background: var(--wa-color-surface-alt);
|
||||
border-radius: var(--wa-radius-l);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--wa-color-primary);
|
||||
background: var(--wa-color-primary-subtle);
|
||||
}
|
||||
|
||||
.action-card wa-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--wa-color-primary);
|
||||
}
|
||||
|
||||
.network-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--wa-space-xs);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--wa-color-success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<wa-page>
|
||||
<main class="main-centered">
|
||||
<wa-card>
|
||||
<div slot="header">
|
||||
<div class="onboarding-stepper">
|
||||
<div class="stepper-item active" data-step="1">
|
||||
<div class="stepper-number">1</div>
|
||||
<span class="stepper-label">Welcome</span>
|
||||
</div>
|
||||
<div class="stepper-line"></div>
|
||||
<div class="stepper-item" data-step="2">
|
||||
<div class="stepper-number">2</div>
|
||||
<span class="stepper-label">Learn</span>
|
||||
</div>
|
||||
<div class="stepper-line"></div>
|
||||
<div class="stepper-item" data-step="3">
|
||||
<div class="stepper-number">3</div>
|
||||
<span class="stepper-label">Get Started</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step active" data-step="1">
|
||||
<div class="wa-stack wa-gap-l">
|
||||
<div class="hero-icon">
|
||||
<wa-icon name="wallet" family="duotone"></wa-icon>
|
||||
</div>
|
||||
|
||||
<div class="wa-stack wa-gap-xs" style="text-align: center;">
|
||||
<h1 class="wa-heading-xl">Welcome to Sonr</h1>
|
||||
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
|
||||
Your self-sovereign identity wallet powered by WebAssembly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<div class="wa-stack wa-gap-s">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon security">
|
||||
<wa-icon name="shield-check"></wa-icon>
|
||||
</div>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">Passwordless Security</span>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
|
||||
Authenticate with biometrics or hardware keys - no passwords to remember or steal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon speed">
|
||||
<wa-icon name="bolt"></wa-icon>
|
||||
</div>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">WASM-Powered</span>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
|
||||
Wallet runs entirely in your browser - fast, secure, and always available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon privacy">
|
||||
<wa-icon name="user-shield"></wa-icon>
|
||||
</div>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">You Own Your Data</span>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
|
||||
Self-sovereign identity means your keys never leave your device
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<wa-button variant="brand" size="large" style="width: 100%;" onclick="goToStep(2)">
|
||||
Learn More
|
||||
<wa-icon slot="end" name="arrow-right"></wa-icon>
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step" data-step="2">
|
||||
<div class="wa-stack wa-gap-l">
|
||||
<div class="wa-stack wa-gap-xs" style="text-align: center;">
|
||||
<h2 class="wa-heading-l">How Motr Works</h2>
|
||||
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
|
||||
A next-generation wallet built on WebAuthn and WASM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<div class="wa-stack wa-gap-m">
|
||||
<wa-callout variant="neutral">
|
||||
<wa-icon slot="icon" name="microchip"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">WebAssembly Runtime</span>
|
||||
<span class="wa-caption-s">Your wallet logic runs as compiled Go code in a secure WASM sandbox, directly in your browser.</span>
|
||||
</div>
|
||||
</wa-callout>
|
||||
|
||||
<wa-callout variant="neutral">
|
||||
<wa-icon slot="icon" name="fingerprint"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">Passkey Authentication</span>
|
||||
<span class="wa-caption-s">Use Face ID, Touch ID, or hardware security keys. Your biometrics stay on your device.</span>
|
||||
</div>
|
||||
</wa-callout>
|
||||
|
||||
<wa-callout variant="neutral">
|
||||
<wa-icon slot="icon" name="network-wired"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">Decentralized Identity</span>
|
||||
<span class="wa-caption-s">Connect to any app with OpenID Connect - you control what data to share.</span>
|
||||
</div>
|
||||
</wa-callout>
|
||||
|
||||
<wa-callout variant="neutral">
|
||||
<wa-icon slot="icon" name="key"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-xs">Multi-Chain Support</span>
|
||||
<span class="wa-caption-s">One wallet for Sonr, Ethereum, Cosmos, Bitcoin and more via IBC.</span>
|
||||
</div>
|
||||
</wa-callout>
|
||||
</div>
|
||||
|
||||
<div class="wa-cluster wa-gap-s" style="justify-content: space-between;">
|
||||
<wa-button variant="neutral" appearance="outlined" onclick="goToStep(1)">
|
||||
<wa-icon slot="start" name="arrow-left"></wa-icon>
|
||||
Back
|
||||
</wa-button>
|
||||
<wa-button variant="brand" onclick="goToStep(3)">
|
||||
Get Started
|
||||
<wa-icon slot="end" name="arrow-right"></wa-icon>
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step" data-step="3">
|
||||
<div class="wa-stack wa-gap-l">
|
||||
<div class="wa-stack wa-gap-xs" style="text-align: center;">
|
||||
<h2 class="wa-heading-l">Ready to Begin?</h2>
|
||||
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
|
||||
Create a new wallet or sign in to an existing one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<div class="wa-grid" style="--min-column-size: 180px; gap: var(--wa-space-m);">
|
||||
<div class="action-card" onclick="window.location.href='register.html'">
|
||||
<wa-icon name="user-plus" family="duotone"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-m">Create Wallet</span>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
|
||||
New to Sonr? Set up your wallet in minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card" onclick="window.location.href='login.html'">
|
||||
<wa-icon name="right-to-bracket" family="duotone"></wa-icon>
|
||||
<div class="wa-stack wa-gap-2xs">
|
||||
<span class="wa-heading-m">Sign In</span>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
|
||||
Access your existing wallet
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<wa-divider>or</wa-divider>
|
||||
|
||||
<wa-button variant="neutral" appearance="outlined" style="width: 100%;" onclick="scanQR()">
|
||||
<wa-icon slot="start" name="qrcode"></wa-icon>
|
||||
Scan QR Code
|
||||
</wa-button>
|
||||
|
||||
<wa-callout variant="brand" appearance="filled">
|
||||
<wa-icon slot="icon" name="circle-info"></wa-icon>
|
||||
<span class="wa-caption-s">
|
||||
Already have a passkey registered on another device? Use QR code to sync your wallet.
|
||||
</span>
|
||||
</wa-callout>
|
||||
|
||||
<wa-button variant="neutral" appearance="plain" onclick="goToStep(2)" style="width: 100%;">
|
||||
<wa-icon slot="start" name="arrow-left"></wa-icon>
|
||||
Back to How It Works
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer slot="footer">
|
||||
<div class="wa-stack wa-gap-m wa-align-items-center">
|
||||
<div class="network-status">
|
||||
<div class="status-dot"></div>
|
||||
<span class="wa-caption-s" style="color: var(--wa-color-neutral-600);">
|
||||
Sonr Network: <strong style="color: var(--wa-color-success);">Operational</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wa-cluster wa-justify-content-center wa-gap-m">
|
||||
<wa-button appearance="plain" size="small" onclick="window.open('https://sonr.io', '_blank')">
|
||||
Learn about Sonr
|
||||
</wa-button>
|
||||
<span style="color: var(--wa-color-neutral-300);">|</span>
|
||||
<wa-button appearance="plain" size="small" onclick="window.open('https://docs.sonr.io', '_blank')">
|
||||
Documentation
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</wa-card>
|
||||
</main>
|
||||
</wa-page>
|
||||
|
||||
<script>
|
||||
let currentStep = 1;
|
||||
|
||||
function goToStep(step) {
|
||||
document.querySelectorAll('.step').forEach(el => el.classList.remove('active'));
|
||||
|
||||
document.querySelectorAll('.stepper-item').forEach(item => {
|
||||
const itemStep = parseInt(item.dataset.step);
|
||||
item.classList.remove('active', 'completed');
|
||||
if (itemStep < step) {
|
||||
item.classList.add('completed');
|
||||
} else if (itemStep === step) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.stepper-line').forEach((line, index) => {
|
||||
line.classList.toggle('completed', index < step - 1);
|
||||
});
|
||||
|
||||
const targetStep = document.querySelector(`.step[data-step="${step}"]`);
|
||||
if (targetStep) {
|
||||
targetStep.classList.add('active');
|
||||
}
|
||||
|
||||
currentStep = step;
|
||||
}
|
||||
|
||||
function scanQR() {
|
||||
alert('QR Scanner would open here - scan a code from another device to sync your wallet.');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user