513 lines
14 KiB
Plaintext
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>
|
|
}
|