Files
nebula/views/login.templ

513 lines
14 KiB
Plaintext

package views
import "nebula/layouts"
type LoginState struct {
Step string
Method string
Error string
}
templ LoginPage(state LoginState) {
@layouts.CenteredCard("Sign In - Sonr") {
<div id="step-content" class="step-content">
@LoginStepContent(state)
</div>
<div id="htmx-indicator" class="htmx-indicator">
<wa-spinner></wa-spinner>
</div>
<footer slot="footer">
@LoginFooter()
</footer>
}
}
templ LoginStepContent(state LoginState) {
switch state.Step {
case "qr":
@LoginQRStep()
case "recovery":
@LoginRecoveryStep()
case "success":
@LoginSuccessStep()
default:
@LoginStep1()
}
}
templ LoginStepWithOOB(state LoginState) {
@LoginStepContent(state)
}
templ LoginStep1() {
@loginStyles()
<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-m" style="color: var(--wa-color-neutral-600);">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">
@AuthMethodCard("passkey", "fingerprint", "Face or Fingerprint", "Use your face, fingerprint, or device PIN", false)
@AuthMethodCard("security-key", "key", "Hardware Key", "Use a physical security key", false)
@AuthMethodCard("qr-code", "qrcode", "Use Another Device", "Scan a QR code with your phone", false)
</div>
<wa-divider></wa-divider>
<div class="wa-cluster wa-justify-content-between wa-gap-s">
<wa-button
appearance="plain"
size="small"
hx-get="/login/step/recovery"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
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>
@loginStep1Scripts()
}
templ AuthMethodCard(method string, icon string, title string, description string, disabled bool) {
<div
class={ "auth-method-card", templ.KV("disabled", disabled) }
data-method={ method }
id={ "method-" + method }
>
<wa-icon family="duotone" name={ icon }></wa-icon>
<div class="auth-method-info">
<h4 class="wa-label-m">{ title }</h4>
<p class="wa-caption-s">{ description }</p>
</div>
if method != "qr-code" {
<wa-spinner style="--size: 1.25rem; display: none;" id={ method + "-spinner" }></wa-spinner>
}
</div>
}
templ LoginQRStep() {
@loginStyles()
<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</p>
</div>
<wa-qr-code
value="https://sonr.id/auth?session=xyz789abc123"
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/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%;"
id="qr-status"
hx-get="/login/qr-status"
hx-trigger="every 2s"
hx-target="this"
hx-swap="innerHTML"
>
<wa-spinner></wa-spinner>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Waiting for your phone...</p>
</div>
<wa-button
appearance="outlined"
variant="neutral"
style="width: 100%;"
hx-get="/login/step/1"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
Back
</wa-button>
</div>
}
templ LoginRecoveryStep() {
@loginStyles()
<div class="wa-stack wa-gap-l">
<div class="wa-stack wa-gap-xs">
<h2 class="wa-heading-l">Recover Your Account</h2>
</div>
<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" style="color: var(--wa-color-neutral-600);">
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" style="color: var(--wa-color-neutral-600);">
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"
id="recovery-code"
name="recovery-code"
class="verification-code-input"
></wa-input>
</div>
</div>
<div class="wa-cluster wa-justify-content-end wa-gap-s">
<wa-button
appearance="outlined"
variant="neutral"
hx-get="/login/step/1"
hx-target="#step-content"
hx-swap="innerHTML transition:true"
hx-indicator="#htmx-indicator"
>
Back
</wa-button>
<wa-button variant="brand" id="btn-recover">
Recover
</wa-button>
</div>
</div>
@recoveryScripts()
}
templ LoginSuccessStep() {
@loginStyles()
<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 In</h2>
<p class="wa-caption-m" style="color: var(--wa-color-neutral-600);">
Signed in successfully.
</p>
</div>
<wa-button variant="brand" 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>
}
templ QRStatusWaiting() {
<wa-spinner></wa-spinner>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Waiting for your phone...</p>
}
templ QRStatusSuccess() {
<wa-icon name="circle-check" style="font-size: 2rem; color: var(--wa-color-success);"></wa-icon>
<p class="wa-caption-s" style="color: var(--wa-color-success);">Phone connected!</p>
<script>
setTimeout(() => {
htmx.ajax('GET', '/login/step/success', {target: '#step-content', swap: 'innerHTML transition:true'});
}, 500);
</script>
}
templ LoginFooter() {
<div class="wa-cluster wa-justify-content-center wa-gap-m">
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Don't have an account?</span>
<wa-button appearance="plain" size="small" onclick="window.location.href='/register'">
Create Account
</wa-button>
</div>
}
templ loginStyles() {
<style>
.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 {
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;
}
.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:not(.disabled) {
background: var(--wa-color-surface-alt-hover, var(--wa-color-neutral-100));
}
.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);
}
.success-icon {
font-size: 4rem;
color: var(--wa-color-success);
}
.verification-code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.3em;
max-width: 180px;
}
@media (max-width: 400px) {
.auth-method-card {
padding: var(--wa-space-s);
gap: var(--wa-space-s);
}
.auth-method-card wa-icon {
font-size: 1.5rem;
}
.verification-code-input {
font-size: 1.25rem;
max-width: 160px;
}
.success-icon {
font-size: 3rem;
}
}
@media (max-height: 600px) {
.auth-method-card {
padding: var(--wa-space-s);
}
}
</style>
}
templ loginStep1Scripts() {
<script>
(function() {
let selectedMethod = null;
const btnSignin = document.getElementById('btn-signin');
async function checkCapabilities() {
if (!window.PublicKeyCredential) {
document.getElementById('method-passkey')?.classList.add('disabled');
document.getElementById('method-security-key')?.classList.add('disabled');
return;
}
try {
const hasPlatform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!hasPlatform) {
document.getElementById('method-passkey')?.classList.add('disabled');
}
} catch (e) {
document.getElementById('method-passkey')?.classList.add('disabled');
}
try {
if (PublicKeyCredential.isConditionalMediationAvailable) {
const hasConditional = await PublicKeyCredential.isConditionalMediationAvailable();
if (hasConditional) {
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 auth successful:", credential);
htmx.ajax('GET', '/login/step/success', {target: '#step-content', swap: 'innerHTML transition:true'});
}
} catch (error) {
console.log("Conditional UI not used:", error.message);
}
}
function selectMethod(method) {
const card = document.getElementById('method-' + method);
if (!card || card.classList.contains('disabled')) return;
document.querySelectorAll('.auth-method-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedMethod = method;
btnSignin?.removeAttribute('disabled');
}
async function signIn() {
if (!selectedMethod) return;
if (selectedMethod === 'qr-code') {
htmx.ajax('GET', '/login/step/qr', {target: '#step-content', swap: 'innerHTML transition:true'});
return;
}
const spinner = document.getElementById(selectedMethod + '-spinner');
if (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("Auth successful:", credential);
htmx.ajax('GET', '/login/step/success', {target: '#step-content', swap: 'innerHTML transition:true'});
} catch (error) {
console.error("Auth failed:", error);
alert("Authentication failed: " + error.message);
} finally {
if (spinner) spinner.style.display = 'none';
}
}
document.querySelectorAll('.auth-method-card').forEach(card => {
card.addEventListener('click', () => selectMethod(card.dataset.method));
});
btnSignin?.addEventListener('click', signIn);
checkCapabilities();
})();
</script>
}
templ recoveryScripts() {
<script>
(function() {
const methodGroup = document.getElementById('recovery-method-group');
const keyForm = document.getElementById('recovery-key-form');
const qrForm = document.getElementById('recovery-qr-form');
const btnRecover = document.getElementById('btn-recover');
methodGroup?.addEventListener('wa-change', (e) => {
if (e.target.value === 'recovery-key') {
keyForm.style.display = 'block';
qrForm.style.display = 'none';
} else {
keyForm.style.display = 'none';
qrForm.style.display = 'block';
}
});
btnRecover?.addEventListener('click', () => {
const method = methodGroup?.value;
if (method === 'recovery-key') {
const key = document.getElementById('recovery-key-input')?.value;
if (key?.startsWith('rk_')) {
console.log("Recovery key validated");
htmx.ajax('GET', '/login/step/success', {target: '#step-content', swap: 'innerHTML transition:true'});
} else {
alert("Please enter a valid recovery key (starts with rk_)");
}
} else {
const code = document.getElementById('recovery-code')?.value;
if (code?.length === 6) {
console.log("Recovery code verified");
htmx.ajax('GET', '/login/step/success', {target: '#step-content', swap: 'innerHTML transition:true'});
} else {
alert("Please enter a valid 6-digit code");
}
}
});
})();
</script>
}