577 lines
26 KiB
HTML
577 lines
26 KiB
HTML
{{define "content"}}
|
||
<div class="row row-deck row-cards">
|
||
|
||
<!-- Login Page Customization -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-palette"></i> Login Page Customization</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- Background Image -->
|
||
<h4 class="mb-3"><i class="ti ti-photo"></i> Background Image</h4>
|
||
{{if loginBgImage}}
|
||
<div class="mb-3">
|
||
<div class="row align-items-center">
|
||
<div class="col-auto">
|
||
<img src="{{loginBgImage}}" class="rounded border" style="max-width: 240px; max-height: 140px; object-fit: cover;">
|
||
</div>
|
||
<div class="col-auto">
|
||
<form action="/admin/branding/remove-bg" method="post" onsubmit="return confirm('Remove the login background image?');">
|
||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||
<i class="ti ti-trash"></i> Remove Image
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
<form action="/admin/branding/upload" method="post" enctype="multipart/form-data">
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Upload Background Image</label>
|
||
<input type="file" name="login_bg" class="form-control" accept="image/png,image/jpeg,image/webp">
|
||
<small class="form-hint">Max 5 MB. JPEG, PNG or WebP. The image is centered and fills the screen without distortion.</small>
|
||
</div>
|
||
</div>
|
||
<div class="form-footer">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="ti ti-upload"></i> Upload
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Application Settings -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-settings"></i> Application Settings</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<form action="/admin/settings" method="post">
|
||
<input type="hidden" name="form_type" value="app_settings">
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Application Name</label>
|
||
<input type="text" name="app_name" class="form-control" value="{{index .Settings "app_name"}}" placeholder="Keywarden">
|
||
</div>
|
||
<div class="col-md-3 mb-3">
|
||
<label class="form-label">Default Key Type</label>
|
||
<select name="default_key_type" class="form-select">
|
||
<option value="ed25519" {{if eq (index .Settings "default_key_type") "ed25519"}}selected{{end}}>Ed25519</option>
|
||
<option value="rsa" {{if eq (index .Settings "default_key_type") "rsa"}}selected{{end}}>RSA</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3 mb-3">
|
||
<label class="form-label">Default RSA Key Bits</label>
|
||
<select name="default_key_bits" class="form-select">
|
||
<option value="4096" {{if eq (index .Settings "default_key_bits") "4096"}}selected{{end}}>4096</option>
|
||
<option value="2048" {{if eq (index .Settings "default_key_bits") "2048"}}selected{{end}}>2048</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Session Timeout (minutes)</label>
|
||
<input type="number" name="session_timeout" class="form-control" value="{{index .Settings "session_timeout"}}" placeholder="60" min="5" max="1440">
|
||
</div>
|
||
</div>
|
||
<div class="form-footer">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="ti ti-device-floppy"></i> Save Settings
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Security Settings -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-shield-lock"></i> Security Settings</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<form action="/admin/settings" method="post">
|
||
<input type="hidden" name="form_type" value="security_settings">
|
||
|
||
<!-- Password Policy -->
|
||
<h4 class="mb-3"><i class="ti ti-lock"></i> Password Policy</h4>
|
||
<div class="row mb-3">
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Minimum Password Length</label>
|
||
<input type="number" name="pw_min_length" class="form-control"
|
||
value="{{if index .Settings "pw_min_length"}}{{index .Settings "pw_min_length"}}{{else}}8{{end}}"
|
||
min="4" max="128" placeholder="8">
|
||
</div>
|
||
</div>
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<label class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" name="pw_require_upper" value="true"
|
||
{{if or (eq (index .Settings "pw_require_upper") "true") (eq (index .Settings "pw_require_upper") "")}}checked{{end}}>
|
||
<span class="form-check-label">Require uppercase letter (A-Z)</span>
|
||
</label>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" name="pw_require_lower" value="true"
|
||
{{if or (eq (index .Settings "pw_require_lower") "true") (eq (index .Settings "pw_require_lower") "")}}checked{{end}}>
|
||
<span class="form-check-label">Require lowercase letter (a-z)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<label class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" name="pw_require_digit" value="true"
|
||
{{if or (eq (index .Settings "pw_require_digit") "true") (eq (index .Settings "pw_require_digit") "")}}checked{{end}}>
|
||
<span class="form-check-label">Require digit (0-9)</span>
|
||
</label>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" name="pw_require_special" value="true"
|
||
{{if eq (index .Settings "pw_require_special") "true"}}checked{{end}}>
|
||
<span class="form-check-label">Require special character (!@#$...)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- MFA Enforcement -->
|
||
<h4 class="mb-3"><i class="ti ti-shield-check"></i> MFA Enforcement</h4>
|
||
<div class="row mb-3">
|
||
<div class="col-md-8">
|
||
<label class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" name="mfa_required" value="true"
|
||
{{if eq (index .Settings "mfa_required") "true"}}checked{{end}}>
|
||
<span class="form-check-label">Require MFA for all users</span>
|
||
<span class="form-check-description">When enabled, all users must set up two-factor authentication before using the application. Users without MFA will be redirected to the MFA setup page.</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Account Lockout -->
|
||
<h4 class="mb-3"><i class="ti ti-lock-access"></i> Account Lockout</h4>
|
||
<div class="row mb-3">
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Failed Attempts Before Lockout</label>
|
||
<input type="number" name="lockout_attempts" class="form-control"
|
||
value="{{if index .Settings "lockout_attempts"}}{{index .Settings "lockout_attempts"}}{{else}}5{{end}}"
|
||
min="0" max="100" placeholder="5">
|
||
<small class="form-hint">Set to 0 to disable account lockout.</small>
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Lockout Duration (minutes)</label>
|
||
<input type="number" name="lockout_duration" class="form-control"
|
||
value="{{if index .Settings "lockout_duration"}}{{index .Settings "lockout_duration"}}{{else}}15{{end}}"
|
||
min="1" max="1440" placeholder="15">
|
||
<small class="form-hint">How long to lock the account after too many failed attempts.</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-footer">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="ti ti-device-floppy"></i> Save Security Settings
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Email / SMTP Configuration -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-mail"></i> Email / SMTP</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
{{if .EmailEnabled}}
|
||
<div class="alert alert-success">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-check icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">SMTP is configured</h4>
|
||
<div class="text-secondary">Email notifications are available. SMTP settings are managed via environment variables.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<form action="/admin/settings/email/test" method="post">
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Send Test Email</label>
|
||
<div class="input-icon">
|
||
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
|
||
<input type="email" name="test_email" class="form-control" placeholder="recipient@example.com" required>
|
||
</div>
|
||
</div>
|
||
<div class="col-auto mb-3 d-flex align-items-end">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="ti ti-send"></i> Send Test
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-hint mt-n2 d-block mb-3">Send a test email to verify your SMTP configuration.</small>
|
||
</form>
|
||
{{else}}
|
||
<div class="alert alert-warning">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">SMTP is not configured</h4>
|
||
<div class="text-secondary">
|
||
Set the <code>KEYWARDEN_SMTP_HOST</code> environment variable to enable email notifications.
|
||
See the <a href="https://git.techniverse.net/scriptos/keywarden/src/branch/master/docs/email.md" target="_blank">Email documentation</a> for details.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Master Key -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-key"></i> System Master Key</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="alert alert-info">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">About the System Master Key</h4>
|
||
<div class="text-secondary">
|
||
The system master key is used by Keywarden to authenticate against remote servers for
|
||
key deployments and assignment syncs. You must add this public key to the
|
||
<code>~/.ssh/authorized_keys</code> file of the admin user on each target server.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{if .MasterKeyPublic}}
|
||
<input type="hidden" id="masterKeyValue" value="{{.MasterKeyPublic}}">
|
||
<div class="mb-3">
|
||
<label class="form-label">Public Key</label>
|
||
<div class="input-group">
|
||
<input type="password" class="form-control" id="masterKeyDisplay" value="{{.MasterKeyPublic}}" readonly>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
|
||
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
|
||
</button>
|
||
<button class="btn btn-outline-primary" type="button" onclick="copyMasterKey(this)" title="Copy">
|
||
<i class="ti ti-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Fingerprint</label>
|
||
<code>{{.MasterKeyFingerprint}}</code>
|
||
</div>
|
||
<script>
|
||
function copyMasterKey(btn) {
|
||
var text = document.getElementById('masterKeyValue').value;
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
navigator.clipboard.writeText(text).then(function() {
|
||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
|
||
});
|
||
} else {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
|
||
}
|
||
}
|
||
function toggleMasterKey() {
|
||
var input = document.getElementById('masterKeyDisplay');
|
||
var icon = document.getElementById('masterKeyEyeIcon');
|
||
if (input.type === 'password') {
|
||
input.type = 'text';
|
||
icon.className = 'ti ti-eye-off';
|
||
} else {
|
||
input.type = 'password';
|
||
icon.className = 'ti ti-eye';
|
||
}
|
||
}
|
||
</script>
|
||
{{else}}
|
||
<div class="alert alert-danger">
|
||
<i class="ti ti-alert-triangle"></i> System master key not found. Please restart Keywarden to generate it.
|
||
</div>
|
||
{{end}}
|
||
<hr>
|
||
<h4 class="mb-3"><i class="ti ti-refresh"></i> Regenerate Master Key</h4>
|
||
<div class="alert alert-warning">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">Warning</h4>
|
||
<div class="text-secondary">
|
||
Regenerating the master key will invalidate all existing server connections.
|
||
You must re-deploy the new public key to all target servers. This action cannot be undone.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<form action="/admin/masterkey/regenerate" method="post" onsubmit="return confirm('Are you absolutely sure you want to regenerate the system master key? All existing server connections will break until the new key is deployed.');">
|
||
<div class="row align-items-end">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Confirm your password</label>
|
||
<input type="password" name="confirm_password" class="form-control" placeholder="Enter your password" required>
|
||
</div>
|
||
<div class="col-auto mb-3">
|
||
<button type="submit" class="btn btn-danger">
|
||
<i class="ti ti-refresh"></i> Regenerate Master Key
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Key Enforcement (Bastillion-Style) -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-shield-check"></i> Key Enforcement</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="alert alert-info">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">Enforced Key Management</h4>
|
||
<div class="text-secondary">
|
||
When enabled, Keywarden periodically connects to all managed servers and verifies that only
|
||
authorized SSH keys (managed by Keywarden + the system master key) are present in
|
||
<code>authorized_keys</code>. Unauthorized keys are detected and optionally removed automatically.
|
||
<br><br>
|
||
<strong>Monitor mode:</strong> Detects unauthorized keys and logs them in the audit log, but does not remove them.<br>
|
||
<strong>Enforce mode:</strong> Detects unauthorized keys and <em>removes them automatically</em>, keeping only Keywarden-managed keys.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form action="/admin/settings" method="post">
|
||
<input type="hidden" name="form_type" value="enforcement_settings">
|
||
<div class="row mb-3">
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Enforcement Mode</label>
|
||
<select name="enforce_mode" class="form-select">
|
||
<option value="disabled" {{if or (not .EnforcementStatus) (eq (index .EnforcementStatus "mode") "disabled")}}selected{{end}}>Disabled</option>
|
||
<option value="monitor" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "monitor")}}selected{{end}}>Monitor (detect only)</option>
|
||
<option value="enforce" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "enforce")}}selected{{end}}>Enforce (detect & remove)</option>
|
||
</select>
|
||
<small class="form-hint">Choose how Keywarden handles unauthorized keys on your servers.</small>
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Check Interval (minutes)</label>
|
||
<input type="number" name="enforce_interval" class="form-control"
|
||
value="{{if and .EnforcementStatus (index .EnforcementStatus "interval")}}{{index .EnforcementStatus "interval"}}{{else}}15{{end}}"
|
||
min="1" max="1440" placeholder="15">
|
||
<small class="form-hint">How often Keywarden checks the servers (1–1440 minutes).</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-footer">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="ti ti-device-floppy"></i> Save Enforcement Settings
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{{if and .EnforcementStatus (index .EnforcementStatus "last_run")}}
|
||
<hr class="my-4">
|
||
<h4 class="mb-3"><i class="ti ti-history"></i> Last Enforcement Run</h4>
|
||
<div class="datagrid mb-3">
|
||
<div class="datagrid-item">
|
||
<div class="datagrid-title">Last Run</div>
|
||
<div class="datagrid-content">{{index .EnforcementStatus "last_run"}}</div>
|
||
</div>
|
||
<div class="datagrid-item">
|
||
<div class="datagrid-title">Result</div>
|
||
<div class="datagrid-content">{{index .EnforcementStatus "last_result"}}</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if and .EnforcementStatus (ne (index .EnforcementStatus "mode") "disabled")}}
|
||
<hr class="my-4">
|
||
<h4 class="mb-3"><i class="ti ti-player-play"></i> Manual Run</h4>
|
||
<form action="/admin/enforcement/run" method="post" onsubmit="return confirm('Start a key enforcement run now? This will connect to all managed servers.');">
|
||
<button type="submit" class="btn btn-warning">
|
||
<i class="ti ti-player-play"></i> Run Enforcement Now
|
||
</button>
|
||
<small class="form-hint d-inline-block ms-2">Trigger an immediate enforcement check on all servers.</small>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Backup & Restore -->
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title"><i class="ti ti-database-export"></i> Backup & Restore</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="alert alert-info">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">About Backups</h4>
|
||
<div class="text-secondary">
|
||
Backups contain all system data including users, SSH keys, servers, groups, assignments,
|
||
cron jobs, settings, and audit logs. The backup file is encrypted with AES-256-GCM using
|
||
the password you provide. Keep the password safe — without it, the backup cannot be restored.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Export Backup -->
|
||
<h4 class="mb-3"><i class="ti ti-download"></i> Export Backup</h4>
|
||
<form action="/admin/backup/export" method="post" id="backupExportForm">
|
||
<div class="row">
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Backup Password</label>
|
||
<input type="password" name="backup_password" id="backup_password" class="form-control" placeholder="Enter a secure password" required>
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Confirm Password</label>
|
||
<input type="password" name="backup_password_confirm" id="backup_password_confirm" class="form-control" placeholder="Confirm password" required>
|
||
</div>
|
||
<div class="col-auto mb-3 d-flex align-items-end">
|
||
<button type="submit" class="btn btn-primary" onclick="return validateBackupForm()">
|
||
<i class="ti ti-download"></i> Export Backup
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-hint mt-n2 d-block mb-3">Password must meet the configured password policy.</small>
|
||
</form>
|
||
|
||
<hr class="my-4">
|
||
|
||
<!-- Import Backup -->
|
||
<h4 class="mb-3"><i class="ti ti-upload"></i> Restore Backup</h4>
|
||
<div class="alert alert-warning">
|
||
<div class="d-flex">
|
||
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
|
||
<div>
|
||
<h4 class="alert-title">Warning</h4>
|
||
<div class="text-secondary">
|
||
Restoring a backup will <strong>replace all current data</strong> with the data from the backup file.
|
||
This action cannot be undone. Make sure to export a current backup first if needed.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<form action="/admin/backup/import" method="post" enctype="multipart/form-data" onsubmit="return confirm('Are you absolutely sure you want to restore this backup? ALL current data will be replaced. This action cannot be undone!');">
|
||
<div class="row">
|
||
<div class="col-md-5 mb-3">
|
||
<label class="form-label">Backup File (.kwbak)</label>
|
||
<input type="file" name="backup_file" class="form-control" accept=".kwbak" required>
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">Backup Password</label>
|
||
<input type="password" name="restore_password" class="form-control" placeholder="Enter the backup password" required>
|
||
</div>
|
||
<div class="col-auto mb-3 d-flex align-items-end">
|
||
<button type="submit" class="btn btn-danger">
|
||
<i class="ti ti-upload"></i> Restore Backup
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
function validateBackupForm() {
|
||
var pw = document.getElementById('backup_password').value;
|
||
var pwConfirm = document.getElementById('backup_password_confirm').value;
|
||
if (pw !== pwConfirm) {
|
||
alert('Passwords do not match.');
|
||
return false;
|
||
}
|
||
if (pw.length < 4) {
|
||
alert('Password is too short.');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Track unsaved changes on forms (except backup/masterkey forms)
|
||
(function() {
|
||
var settingsForms = document.querySelectorAll('form[action="/admin/settings"]');
|
||
settingsForms.forEach(function(form) {
|
||
var inputs = form.querySelectorAll('input, select, textarea');
|
||
var saveBtn = form.querySelector('button[type="submit"]');
|
||
var banner = null;
|
||
|
||
function markDirty() {
|
||
form.dataset.dirty = 'true';
|
||
// Highlight save button
|
||
if (saveBtn) {
|
||
saveBtn.classList.remove('btn-primary');
|
||
saveBtn.classList.add('btn-warning');
|
||
saveBtn.innerHTML = '<i class="ti ti-alert-triangle"></i> Unsaved changes – Save now!';
|
||
}
|
||
// Show banner if not already visible
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.className = 'alert alert-warning alert-dismissible mt-2 mb-0 py-2';
|
||
banner.setAttribute('role', 'alert');
|
||
banner.innerHTML = '<i class="ti ti-alert-triangle"></i> You have unsaved changes. Click the save button to apply them.';
|
||
form.querySelector('.form-footer').insertAdjacentElement('beforebegin', banner);
|
||
}
|
||
}
|
||
|
||
inputs.forEach(function(input) {
|
||
if (input.type === 'hidden') return;
|
||
input.addEventListener('change', markDirty);
|
||
input.addEventListener('input', markDirty);
|
||
});
|
||
|
||
form.addEventListener('submit', function() {
|
||
form.dataset.dirty = '';
|
||
window._adminSettingsSaving = true;
|
||
});
|
||
});
|
||
|
||
// Warn before leaving with unsaved changes
|
||
window.addEventListener('beforeunload', function(e) {
|
||
if (window._adminSettingsSaving) return;
|
||
var dirty = document.querySelector('form[data-dirty="true"]');
|
||
if (dirty) {
|
||
e.preventDefault();
|
||
e.returnValue = '';
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{{end}}
|