676 lines
19 KiB
Plaintext
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>
|
|
}
|