Files
nebula/views/register.templ

676 lines
19 KiB
Plaintext

package views
import (
"nebula/components"
"nebula/layouts"
"nebula/models"
)
var registerSteps = []string{"Detect", "Register", "Complete"}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
templ RegisterPage(state models.RegisterState) {
@layouts.CenteredCard("Register - Sonr") {
<div slot="header" id="stepper-container" hx-swap-oob:inherited="true">
@components.OnboardingStepper(state.Step, registerSteps)
</div>
<div id="step-content" class="step-content">
@RegisterStepContent(state)
</div>
<div id="htmx-indicator" class="htmx-indicator">
<wa-spinner></wa-spinner>
</div>
<footer slot="footer">
@RegisterFooter()
</footer>
}
}
templ RegisterStepContent(state models.RegisterState) {
switch state.Step {
case 1:
@RegisterStep1()
case 2:
switch state.Method {
case "passkey":
@RegisterStep2Passkey()
case "security-key":
@RegisterStep2SecurityKey()
case "qr-code":
@RegisterStep2QRCode()
default:
@RegisterStep2Passkey()
}
case 3:
@RegisterStep3()
}
}
templ RegisterStepWithStepper(state models.RegisterState) {
@RegisterStepContent(state)
<div id="stepper-container" hx-swap-oob="innerHTML">
@components.OnboardingStepper(state.Step, registerSteps)
</div>
}
// RegisterStep1 - Device Detection and Method Selection
templ RegisterStep1() {
@registerStyles()
<div class="wa-stack wa-gap-l">
<div class="wa-stack wa-gap-xs" style="text-align: center;">
<h2 class="wa-heading-l">Create Your Account</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Setting up secure sign-in...
</p>
</div>
<div
id="capabilities"
class="capability-list"
hx-get="/register/capabilities"
hx-trigger="load delay:500ms"
hx-swap="innerHTML"
>
@CapabilityItem("cap-platform", "fingerprint", "Face or Fingerprint", false, true)
@CapabilityItem("cap-cross-platform", "key", "Hardware Security Key", false, true)
@CapabilityItem("cap-conditional", "bolt", "Quick Sign-in", false, true)
</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-continue"
variant="brand"
disabled
hx-get="/register/step/2"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
hx-include="#auth-method-group"
>
Continue
<wa-icon slot="end" variant="regular" name="arrow-right"></wa-icon>
</wa-button>
</div>
</div>
@registerStep1Scripts()
}
// CapabilityItem renders a single capability detection item
templ CapabilityItem(id string, icon string, label string, supported bool, loading bool) {
<div
class={ "capability-item", templ.KV("supported", supported && !loading), templ.KV("unsupported", !supported && !loading) }
id={ id }
>
if loading {
<wa-spinner style="--size: 1.5rem;"></wa-spinner>
} else if supported {
<wa-icon name="circle-check" style="color: var(--wa-color-success); font-size: 1.5rem;"></wa-icon>
} else {
<wa-icon name="circle-xmark" style="color: var(--wa-color-danger); font-size: 1.5rem;"></wa-icon>
}
<span>{ label }</span>
</div>
}
templ CapabilitiesResult(caps models.DeviceCapabilities) {
@CapabilityItem("cap-platform", "fingerprint", "Face or Fingerprint", caps.Platform, false)
@CapabilityItem("cap-cross-platform", "key", "Hardware Security Key", caps.CrossPlatform, false)
@CapabilityItem("cap-conditional", "bolt", "Quick Sign-in", caps.Conditional, false)
@capabilitiesScript(caps)
}
templ capabilitiesScript(caps models.DeviceCapabilities) {
<div
id="caps-data"
data-platform={ boolStr(caps.Platform) }
data-cross-platform={ boolStr(caps.CrossPlatform) }
data-conditional={ boolStr(caps.Conditional) }
style="display:none;"
></div>
<script>
(function() {
const capsData = document.getElementById('caps-data');
const hasPlatform = capsData?.dataset.platform === 'true';
const hasCrossPlatform = capsData?.dataset.crossPlatform === 'true';
const radioPasskey = document.getElementById('radio-passkey');
const radioSecurityKey = document.getElementById('radio-security-key');
const btnContinue = document.getElementById('btn-continue');
const methodGroup = document.getElementById('auth-method-group');
if (radioPasskey && hasPlatform) {
radioPasskey.removeAttribute('disabled');
}
if (radioSecurityKey && hasCrossPlatform) {
radioSecurityKey.removeAttribute('disabled');
}
if (methodGroup) {
if (hasPlatform) {
methodGroup.value = 'passkey';
} else if (hasCrossPlatform) {
methodGroup.value = 'security-key';
} else {
methodGroup.value = 'qr-code';
}
if (btnContinue) {
btnContinue.removeAttribute('disabled');
}
}
})();
</script>
}
// RegisterStep2Passkey - Passkey Registration Form
templ RegisterStep2Passkey() {
@registerStyles()
<div class="wa-stack wa-gap-l">
<div class="wa-stack wa-gap-xs" style="text-align: center;">
<h2 class="wa-heading-l">Set Up Face or Fingerprint</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Sign in securely using your face or fingerprint.
</p>
</div>
<form id="passkey-form" class="wa-stack wa-gap-m">
<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>
</form>
<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"
hx-get="/register/step/1"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
Back
</wa-button>
<wa-button
variant="brand"
id="btn-register-passkey"
>
Continue
<wa-icon slot="end" variant="regular" name="fingerprint"></wa-icon>
</wa-button>
</div>
</div>
@passkeyRegistrationScript()
}
// RegisterStep2SecurityKey - Security Key Registration Form
templ RegisterStep2SecurityKey() {
@registerStyles()
<div class="wa-stack wa-gap-l">
<div class="wa-stack wa-gap-xs" style="text-align: center;">
<h2 class="wa-heading-l">Set Up Hardware Key</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Use a physical security key like YubiKey to sign in.
</p>
</div>
<form id="security-key-form" class="wa-stack wa-gap-m">
<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>
</form>
<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"
hx-get="/register/step/1"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
Back
</wa-button>
<wa-button
variant="brand"
id="btn-register-security-key"
>
Continue
<wa-icon slot="end" variant="regular" name="key"></wa-icon>
</wa-button>
</div>
</div>
@securityKeyRegistrationScript()
}
// RegisterStep2QRCode - QR Code Fallback Registration
templ RegisterStep2QRCode() {
@registerStyles()
<div class="wa-stack wa-gap-l wa-align-items-center">
<div class="wa-stack wa-gap-xs" style="text-align: center;">
<h2 class="wa-heading-l">Use Your Phone</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Scan this code with your phone camera to continue setup
</p>
</div>
<wa-qr-code
value="https://sonr.id/register?token=abc123xyz789"
label="Scan with your phone camera"
></wa-qr-code>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500);">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>
<div class="wa-stack wa-gap-s wa-align-items-center" style="width: 100%;">
<h3 class="wa-heading-s">Enter Code from Phone</h3>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
After scanning, enter the 6-digit code shown on your phone.
</p>
<wa-input
type="text"
placeholder="000000"
maxlength="6"
id="verification-code"
name="code"
class="verification-code-input"
></wa-input>
</div>
<div class="wa-cluster wa-justify-content-center wa-gap-s" style="width: 100%;">
<wa-button
appearance="outlined"
variant="neutral"
hx-get="/register/step/1"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
Back
</wa-button>
<wa-button
variant="brand"
id="btn-verify-code"
hx-post="/register/verify-code"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
hx-include="#verification-code"
>
Verify
</wa-button>
</div>
</div>
}
// RegisterStep3 - Success with Recovery Keys
templ RegisterStep3() {
@registerStyles()
<div class="wa-stack wa-gap-l wa-align-items-center">
<wa-icon name="circle-check" family="duotone" class="success-icon"></wa-icon>
<div class="wa-stack wa-gap-xs" style="text-align: center;">
<h2 class="wa-heading-l">You're All Set</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Your account is ready. Save your backup codes before continuing.
</p>
</div>
<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 wa-gap-m" 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%;"
onclick="window.location.href='/dashboard'"
>
Go to Dashboard
<wa-icon slot="end" variant="regular" name="arrow-right"></wa-icon>
</wa-button>
</div>
@step3Scripts()
}
// RegisterFooter renders the footer with login link
templ RegisterFooter() {
<div class="wa-cluster wa-justify-content-center wa-gap-m">
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Already have an account?</span>
<wa-button appearance="plain" size="small" onclick="window.location.href='/login'">
Sign In
</wa-button>
</div>
}
// registerStyles contains the CSS specific to the register page
templ registerStyles() {
<style>
/* HTMX 4 indicator and transition styles */
.htmx-indicator {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
.htmx-request .htmx-indicator {
display: flex;
}
.htmx-request.step-content {
opacity: 0.5;
pointer-events: none;
transition: opacity 0.2s ease;
}
/* View transition support for HTMX 4 */
@view-transition {
navigation: auto;
}
::view-transition-old(step-content),
::view-transition-new(step-content) {
animation-duration: 0.25s;
}
.step-content {
view-transition-name: step-content;
position: relative;
}
/* 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) var(--wa-space-m);
border-radius: var(--wa-radius-m);
background: var(--wa-color-surface-alt);
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.capability-item.supported {
border-left-color: var(--wa-color-success);
}
.capability-item.unsupported {
border-left-color: var(--wa-color-danger);
opacity: 0.6;
}
/* Success icon */
.success-icon {
font-size: 4rem;
color: var(--wa-color-success);
}
/* Verification code input styling */
.verification-code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.3em;
max-width: 180px;
}
/* Radio group styling */
wa-radio-group[orientation="vertical"] {
display: flex;
flex-direction: column;
gap: var(--wa-space-s);
}
wa-radio[appearance="button"] {
cursor: pointer;
}
wa-radio[appearance="button"][disabled] {
cursor: not-allowed;
opacity: 0.5;
}
/* Viewport constraints for popup/webview */
@media (max-width: 400px) {
.capability-item {
padding: var(--wa-space-xs) var(--wa-space-s);
font-size: var(--wa-font-size-s);
}
.verification-code-input {
font-size: 1.25rem;
max-width: 160px;
}
.success-icon {
font-size: 3rem;
}
}
@media (max-height: 600px) {
.capability-item {
padding: var(--wa-space-xs) var(--wa-space-s);
}
}
</style>
}
// registerStep1Scripts handles method selection and continue button enabling
templ registerStep1Scripts() {
<script>
(function() {
const methodGroup = document.getElementById('auth-method-group');
const btnContinue = document.getElementById('btn-continue');
if (methodGroup && btnContinue) {
// Listen for method selection changes
methodGroup.addEventListener('wa-change', (e) => {
const selectedMethod = e.target.value;
if (selectedMethod) {
btnContinue.removeAttribute('disabled');
// Update the hx-get URL with selected method
btnContinue.setAttribute('hx-get', '/register/step/2?method=' + selectedMethod);
htmx.process(btnContinue);
}
});
}
})();
</script>
}
// passkeyRegistrationScript handles WebAuthn passkey registration
templ passkeyRegistrationScript() {
<script>
(function() {
const btn = document.getElementById('btn-register-passkey');
if (!btn) return;
btn.addEventListener('click', async () => {
try {
const username = document.querySelector('[name="username"]')?.value;
const displayName = document.querySelector('[name="display-name"]')?.value || username;
if (!username) {
alert('Please enter a username');
return;
}
// Get challenge from server (in production)
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: username,
displayName: displayName
},
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);
// Navigate to success step
htmx.ajax('GET', '/register/step/3', {
target: '#step-content',
swap: 'innerHTML transition:true'
});
} catch (error) {
console.error("Passkey registration failed:", error);
alert("Registration failed: " + error.message);
}
});
})();
</script>
}
// securityKeyRegistrationScript handles WebAuthn security key registration
templ securityKeyRegistrationScript() {
<script>
(function() {
const btn = document.getElementById('btn-register-security-key');
if (!btn) return;
btn.addEventListener('click', async () => {
try {
const username = document.querySelector('[name="username"]')?.value;
if (!username) {
alert('Please enter a username');
return;
}
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: username,
displayName: username
},
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);
// Navigate to success step
htmx.ajax('GET', '/register/step/3', {
target: '#step-content',
swap: 'innerHTML transition:true'
});
} catch (error) {
console.error("Security key registration failed:", error);
alert("Registration failed: " + error.message);
}
});
})();
</script>
}
// step3Scripts handles the confirmation checkbox and finish button
templ step3Scripts() {
<script>
(function() {
const confirmCheckbox = document.getElementById('confirm-saved');
const finishBtn = document.getElementById('btn-finish');
if (confirmCheckbox && finishBtn) {
confirmCheckbox.addEventListener('wa-change', (e) => {
if (e.target.checked) {
finishBtn.removeAttribute('disabled');
} else {
finishBtn.setAttribute('disabled', '');
}
});
}
})();
</script>
}