Release: v0.1.0-alpha
Some checks failed
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
Some checks failed
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
This commit is contained in:
13
web/embed.go
Normal file
13
web/embed.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/* templates/layout/*
|
||||
var TemplateFS embed.FS
|
||||
|
||||
//go:embed static/css/* static/css/fonts/* static/js/* static/favicon.svg
|
||||
var StaticFS embed.FS
|
||||
BIN
web/static/css/fonts/tabler-icons.ttf
Normal file
BIN
web/static/css/fonts/tabler-icons.ttf
Normal file
Binary file not shown.
BIN
web/static/css/fonts/tabler-icons.woff
Normal file
BIN
web/static/css/fonts/tabler-icons.woff
Normal file
Binary file not shown.
BIN
web/static/css/fonts/tabler-icons.woff2
Normal file
BIN
web/static/css/fonts/tabler-icons.woff2
Normal file
Binary file not shown.
4
web/static/css/tabler-icons.min.css
vendored
Normal file
4
web/static/css/tabler-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/static/css/tabler.min.css
vendored
Normal file
13
web/static/css/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
web/static/favicon.svg
Normal file
9
web/static/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<circle cx="12" cy="12" r="12" fill="#206bc4"/>
|
||||
<g transform="translate(3.5 3.5) scale(0.71)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="15" r="4"/>
|
||||
<line x1="10.85" y1="12.15" x2="19" y2="4"/>
|
||||
<line x1="18" y1="5" x2="20" y2="7"/>
|
||||
<line x1="15" y1="8" x2="17" y2="10"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
15
web/static/js/tabler.min.js
vendored
Normal file
15
web/static/js/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
432
web/templates/admin_settings.html
Normal file
432
web/templates/admin_settings.html
Normal file
@@ -0,0 +1,432 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
|
||||
<!-- 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="navigator.clipboard.writeText(document.getElementById('masterKeyValue').value); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" 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 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>
|
||||
|
||||
<!-- 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}}
|
||||
259
web/templates/assignments.html
Normal file
259
web/templates/assignments.html
Normal file
@@ -0,0 +1,259 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
{{/* Show assigned hosts for User role */}}
|
||||
{{if and (eq .User.Role "user") .Servers}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-server"></i> My Assigned Hosts</h3>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Servers}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-server me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{.Hostname}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-shield-lock"></i> {{if eq .User.Role "user"}}My Access Assignments{{else}}Access Assignments{{end}}</h3>
|
||||
{{if or (eq .User.Role "admin") (eq .User.Role "owner")}}
|
||||
<div class="card-actions">
|
||||
<a href="/assignments/add" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> Create Assignment
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
{{end}}
|
||||
<th>SSH Key</th>
|
||||
<th>Target</th>
|
||||
<th>System User</th>
|
||||
<th>State</th>
|
||||
<th>Options</th>
|
||||
<th>Password</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
<th class="w-1">Actions</th>
|
||||
{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Assignments}}
|
||||
<tr>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
<td>{{.ID}}</td>
|
||||
<td><i class="ti ti-user"></i> {{.Username}}</td>
|
||||
{{end}}
|
||||
<td><i class="ti ti-key"></i> {{.KeyName}}</td>
|
||||
<td>
|
||||
{{if eq .TargetType "host"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-server"></i> {{.TargetName}}</span>
|
||||
{{else if eq .TargetType "group"}}
|
||||
<span class="badge bg-purple-lt"><i class="ti ti-folders"></i> {{.TargetName}}</span>
|
||||
{{else}}
|
||||
<span class="text-secondary">–</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><code>{{.SystemUser}}</code></td>
|
||||
<td>
|
||||
{{if eq .DesiredState "present"}}
|
||||
<span class="badge bg-green-lt">Present</span>
|
||||
{{else}}
|
||||
<span class="badge bg-red-lt">Absent</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Sudo}}<span class="badge bg-orange-lt" title="Sudo enabled"><i class="ti ti-shield-check"></i> Sudo</span> {{end}}
|
||||
{{if .CreateUser}}<span class="badge bg-cyan-lt" title="Create system user"><i class="ti ti-user-plus"></i> Create</span>{{end}}
|
||||
{{if and (not .Sudo) (not .CreateUser)}}<span class="text-secondary">–</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .InitialPassword}}
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="initial-pw-hidden" id="pw-hidden-{{.ID}}">••••••••••</code>
|
||||
<code class="initial-pw-visible d-none" id="pw-visible-{{.ID}}">{{.InitialPassword}}</code>
|
||||
<button class="btn btn-sm btn-icon btn-ghost-secondary ms-1" type="button" onclick="togglePassword({{.ID}})" title="Show/Hide password">
|
||||
<i class="ti ti-eye" id="pw-icon-{{.ID}}"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-icon btn-ghost-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('pw-visible-{{.ID}}').textContent); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy password">
|
||||
<i class="ti ti-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="text-secondary">–</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "synced"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-check"></i> Synced</span>
|
||||
{{else if eq .Status "failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-x"></i> Failed</span>
|
||||
{{else}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a href="/assignments/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/assignments/{{.ID}}/sync" class="d-inline" onsubmit="return confirm('Sync this assignment now using the system master key?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-success" title="Sync / Deploy (uses system master key)">
|
||||
<i class="ti ti-refresh"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-danger" title="Delete"
|
||||
onclick="openDeleteModal({{.ID}}, '{{.SystemUser}}', '{{.TargetName}}', {{.CreateUser}})">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}11{{else}}8{{end}}" class="text-center text-secondary">
|
||||
{{if eq $.User.Role "user"}}No access assignments found for your account.{{else}}No access assignments found. <a href="/assignments/add">Create the first one</a>.{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function togglePassword(id) {
|
||||
var hidden = document.getElementById('pw-hidden-' + id);
|
||||
var visible = document.getElementById('pw-visible-' + id);
|
||||
var icon = document.getElementById('pw-icon-' + id);
|
||||
if (visible.classList.contains('d-none')) {
|
||||
visible.classList.remove('d-none');
|
||||
hidden.classList.add('d-none');
|
||||
icon.className = 'ti ti-eye-off';
|
||||
} else {
|
||||
visible.classList.add('d-none');
|
||||
hidden.classList.remove('d-none');
|
||||
icon.className = 'ti ti-eye';
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(assignID, systemUser, targetName, wasCreated) {
|
||||
document.getElementById('deleteAssignForm').action = '/assignments/' + assignID + '/delete';
|
||||
document.getElementById('deleteModalSystemUser').textContent = systemUser;
|
||||
document.getElementById('deleteModalTarget').textContent = targetName;
|
||||
|
||||
var deleteUserSection = document.getElementById('deleteUserSection');
|
||||
var deleteUserCheckbox = document.getElementById('deleteUserCheckbox');
|
||||
|
||||
// Only show user deletion option if the user was created by the assignment and is not root
|
||||
if (wasCreated && systemUser !== 'root') {
|
||||
deleteUserSection.classList.remove('d-none');
|
||||
deleteUserCheckbox.checked = false;
|
||||
} else {
|
||||
deleteUserSection.classList.add('d-none');
|
||||
deleteUserCheckbox.checked = false;
|
||||
}
|
||||
|
||||
// Update the warning text based on checkbox state
|
||||
updateDeleteWarning();
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('deleteAssignModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function updateDeleteWarning() {
|
||||
var checkbox = document.getElementById('deleteUserCheckbox');
|
||||
var warningText = document.getElementById('deleteWarningText');
|
||||
var warningBox = document.getElementById('deleteWarningBox');
|
||||
|
||||
if (checkbox.checked) {
|
||||
warningText.innerHTML = '<strong>Warning:</strong> The system user will be completely removed from the server, including their home directory, sudo rights, and SSH keys. This action cannot be undone!';
|
||||
warningBox.className = 'alert alert-danger';
|
||||
} else {
|
||||
warningText.innerHTML = 'The SSH key will be removed from the server. The system user will remain on the server.';
|
||||
warningBox.className = 'alert alert-info';
|
||||
}
|
||||
}
|
||||
|
||||
// Move delete modal to body so it is not clipped by overflow containers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var modal = document.getElementById('deleteAssignModal');
|
||||
if (modal) {
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Delete Assignment Modal -->
|
||||
<div class="modal modal-blur fade" id="deleteAssignModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<form method="POST" id="deleteAssignForm" action="">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="ti ti-alert-triangle text-danger"></i> Delete Access Assignment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to delete the assignment for system user <strong><code id="deleteModalSystemUser"></code></strong> on target <strong id="deleteModalTarget"></strong>.</p>
|
||||
|
||||
<div id="deleteWarningBox" class="alert alert-info">
|
||||
<span id="deleteWarningText">The SSH key will be removed from the server. The system user will remain on the server.</span>
|
||||
</div>
|
||||
|
||||
<div id="deleteUserSection" class="d-none">
|
||||
<hr>
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="delete_user" id="deleteUserCheckbox" value="on" onchange="updateDeleteWarning()">
|
||||
<span class="form-check-label">
|
||||
<strong>Also delete the Linux system user</strong><br>
|
||||
<small class="text-secondary">Removes the user account, home directory, sudo rights, and all SSH keys from the server.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="ti ti-trash"></i> Delete Assignment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
140
web/templates/assignments_add.html
Normal file
140
web/templates/assignments_add.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-plus"></i> Create Access Assignment</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/assignments/add" method="POST" id="assignment-form">
|
||||
<!-- User Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">User</label>
|
||||
<select name="user_id" id="user-select" class="form-select" required>
|
||||
<option value="">– Select user –</option>
|
||||
{{range .AssignAllUsers}}
|
||||
<option value="{{.ID}}">{{.Username}} ({{.Email}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- SSH Key Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">SSH Key</label>
|
||||
<select name="ssh_key_id" id="key-select" class="form-select" required>
|
||||
<option value="">– Select user first –</option>
|
||||
</select>
|
||||
<small class="form-hint">Only keys of the selected user are shown.</small>
|
||||
</div>
|
||||
|
||||
<!-- Target Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Target Type</label>
|
||||
<div class="form-selectgroup">
|
||||
<label class="form-selectgroup-item">
|
||||
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" checked onchange="toggleTarget()">
|
||||
<span class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</span>
|
||||
</label>
|
||||
<label class="form-selectgroup-item">
|
||||
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" onchange="toggleTarget()">
|
||||
<span class="form-selectgroup-label"><i class="ti ti-folders"></i> Host Group</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host Selection -->
|
||||
<div class="mb-3" id="host-section">
|
||||
<label class="form-label required">Host</label>
|
||||
<select name="server_id" id="server-select" class="form-select">
|
||||
<option value="">– Select host –</option>
|
||||
{{range .AssignAllHosts}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group Selection -->
|
||||
<div class="mb-3 d-none" id="group-section">
|
||||
<label class="form-label required">Host Group</label>
|
||||
<select name="group_id" id="group-select" class="form-select">
|
||||
<option value="">– Select group –</option>
|
||||
{{range .AssignAllGroups}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.ServerCount}} hosts)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- System User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">System User</label>
|
||||
<input type="text" name="system_user" class="form-control" placeholder="e.g. root, deploy, ubuntu" required>
|
||||
<small class="form-hint">The user account on the target host under which the key will be deployed.</small>
|
||||
</div>
|
||||
|
||||
<!-- Desired State -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Desired State</label>
|
||||
<select name="desired_state" class="form-select">
|
||||
<option value="present" selected>Present – Key should be deployed</option>
|
||||
<option value="absent">Absent – Key should be removed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Options</label>
|
||||
<div>
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="sudo">
|
||||
<span class="form-check-label">Grant sudo rights to the system user</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="create_user">
|
||||
<span class="form-check-label">Create system user on target host if it doesn't exist</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-shield-lock"></i> Create Assignment
|
||||
</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Key data grouped by user ID
|
||||
var keysByUser = {};
|
||||
{{range .AssignAllKeys}}
|
||||
if (!keysByUser[{{.UserID}}]) keysByUser[{{.UserID}}] = [];
|
||||
keysByUser[{{.UserID}}].push({id: {{.ID}}, name: "{{.Name}}", type: "{{.KeyType}}"});
|
||||
{{end}}
|
||||
|
||||
document.getElementById('user-select').addEventListener('change', function() {
|
||||
var uid = this.value;
|
||||
var keySelect = document.getElementById('key-select');
|
||||
keySelect.innerHTML = '<option value="">– Select key –</option>';
|
||||
if (uid && keysByUser[uid]) {
|
||||
keysByUser[uid].forEach(function(k) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = k.id;
|
||||
opt.textContent = k.name + ' (' + k.type + ')';
|
||||
keySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTarget() {
|
||||
var isHost = document.querySelector('input[name="target_type"][value="host"]').checked;
|
||||
document.getElementById('host-section').classList.toggle('d-none', !isHost);
|
||||
document.getElementById('group-section').classList.toggle('d-none', isHost);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
154
web/templates/assignments_edit.html
Normal file
154
web/templates/assignments_edit.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Access Assignment #{{.Assignment.ID}}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/assignments/{{.Assignment.ID}}/edit" method="POST" id="assignment-form">
|
||||
<!-- User Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">User</label>
|
||||
<select name="user_id" id="user-select" class="form-select" required>
|
||||
<option value="">– Select user –</option>
|
||||
{{range .AssignAllUsers}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Assignment.UserID}}selected{{end}}>{{.Username}} ({{.Email}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- SSH Key Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">SSH Key</label>
|
||||
<select name="ssh_key_id" id="key-select" class="form-select" required>
|
||||
<option value="">– Select key –</option>
|
||||
</select>
|
||||
<small class="form-hint">Only keys of the selected user are shown.</small>
|
||||
</div>
|
||||
|
||||
<!-- Target Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Target Type</label>
|
||||
<div class="form-selectgroup">
|
||||
<label class="form-selectgroup-item">
|
||||
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" {{if gt .Assignment.ServerID 0}}checked{{end}}{{if and (eq .Assignment.ServerID 0) (eq .Assignment.GroupID 0)}}checked{{end}} onchange="toggleTarget()">
|
||||
<span class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</span>
|
||||
</label>
|
||||
<label class="form-selectgroup-item">
|
||||
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" {{if gt .Assignment.GroupID 0}}checked{{end}} onchange="toggleTarget()">
|
||||
<span class="form-selectgroup-label"><i class="ti ti-folders"></i> Host Group</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host Selection -->
|
||||
<div class="mb-3{{if gt .Assignment.GroupID 0}} d-none{{end}}" id="host-section">
|
||||
<label class="form-label required">Host</label>
|
||||
<select name="server_id" id="server-select" class="form-select">
|
||||
<option value="">– Select host –</option>
|
||||
{{range .AssignAllHosts}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Assignment.ServerID}}selected{{end}}>{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group Selection -->
|
||||
<div class="mb-3{{if not (gt .Assignment.GroupID 0)}} d-none{{end}}" id="group-section">
|
||||
<label class="form-label required">Host Group</label>
|
||||
<select name="group_id" id="group-select" class="form-select">
|
||||
<option value="">– Select group –</option>
|
||||
{{range .AssignAllGroups}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Assignment.GroupID}}selected{{end}}>{{.Name}} ({{.ServerCount}} hosts)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- System User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">System User</label>
|
||||
<input type="text" name="system_user" class="form-control" value="{{.Assignment.SystemUser}}" required>
|
||||
<small class="form-hint">The user account on the target host under which the key will be deployed.</small>
|
||||
</div>
|
||||
|
||||
<!-- Desired State -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Desired State</label>
|
||||
<select name="desired_state" class="form-select">
|
||||
<option value="present" {{if eq .Assignment.DesiredState "present"}}selected{{end}}>Present – Key should be deployed</option>
|
||||
<option value="absent" {{if eq .Assignment.DesiredState "absent"}}selected{{end}}>Absent – Key should be removed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Options</label>
|
||||
<div>
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="sudo" {{if .Assignment.Sudo}}checked{{end}}>
|
||||
<span class="form-check-label">Grant sudo rights to the system user</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="create_user" {{if .Assignment.CreateUser}}checked{{end}}>
|
||||
<span class="form-check-label">Create system user on target host if it doesn't exist</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Changes
|
||||
</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Key data grouped by user ID
|
||||
var keysByUser = {};
|
||||
{{range .AssignAllKeys}}
|
||||
if (!keysByUser[{{.UserID}}]) keysByUser[{{.UserID}}] = [];
|
||||
keysByUser[{{.UserID}}].push({id: {{.ID}}, name: "{{.Name}}", type: "{{.KeyType}}"});
|
||||
{{end}}
|
||||
|
||||
var preselectedKeyID = {{.Assignment.SSHKeyID}};
|
||||
|
||||
function populateKeys(uid, selectedKeyID) {
|
||||
var keySelect = document.getElementById('key-select');
|
||||
keySelect.innerHTML = '<option value="">– Select key –</option>';
|
||||
if (uid && keysByUser[uid]) {
|
||||
keysByUser[uid].forEach(function(k) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = k.id;
|
||||
opt.textContent = k.name + ' (' + k.type + ')';
|
||||
if (k.id == selectedKeyID) opt.selected = true;
|
||||
keySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('user-select').addEventListener('change', function() {
|
||||
populateKeys(this.value, 0);
|
||||
});
|
||||
|
||||
// Initialize on page load with pre-selected values
|
||||
(function() {
|
||||
var userSelect = document.getElementById('user-select');
|
||||
if (userSelect.value) {
|
||||
populateKeys(userSelect.value, preselectedKeyID);
|
||||
}
|
||||
})();
|
||||
|
||||
function toggleTarget() {
|
||||
var isHost = document.querySelector('input[name="target_type"][value="host"]').checked;
|
||||
document.getElementById('host-section').classList.toggle('d-none', !isHost);
|
||||
document.getElementById('group-section').classList.toggle('d-none', isHost);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
167
web/templates/audit.html
Normal file
167
web/templates/audit.html
Normal file
@@ -0,0 +1,167 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<!-- Filter / Info -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-list-details"></i> Audit Log</h3>
|
||||
<div class="card-actions">
|
||||
{{if .AuditIsAdmin}}
|
||||
<div class="btn-group">
|
||||
<a href="/audit" class="btn btn-sm {{if not .AuditFilterUser}}btn-primary{{else}}btn-outline-primary{{end}}">
|
||||
<i class="ti ti-users"></i> All Users
|
||||
</a>
|
||||
<a href="/audit?filter=mine" class="btn btn-sm {{if .AuditFilterUser}}btn-primary{{else}}btn-outline-primary{{end}}">
|
||||
<i class="ti ti-user"></i> My Actions
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<span class="badge bg-blue-lt ms-2">{{.AuditTotal}} entries</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 170px;">Timestamp</th>
|
||||
<th style="width: 130px;">User</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
<th style="width: 130px;">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AuditEntries}}
|
||||
<tr>
|
||||
<td class="text-secondary">
|
||||
<i class="ti ti-clock"></i> {{.CreatedAt.Format "2006-01-02 15:04:05"}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Action "login_success"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-login"></i> Login</span>
|
||||
{{else if eq .Action "login_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-login"></i> Login Failed</span>
|
||||
{{else if eq .Action "logout"}}
|
||||
<span class="badge bg-secondary-lt"><i class="ti ti-logout"></i> Logout</span>
|
||||
{{else if eq .Action "mfa_verified"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Verified</span>
|
||||
{{else if eq .Action "mfa_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-shield-off"></i> MFA Failed</span>
|
||||
{{else if eq .Action "mfa_enabled"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Enabled</span>
|
||||
{{else if eq .Action "mfa_disabled"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-shield-off"></i> MFA Disabled</span>
|
||||
{{else if eq .Action "key_generated"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-key"></i> Key Generated</span>
|
||||
{{else if eq .Action "key_imported"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-download"></i> Key Imported</span>
|
||||
{{else if eq .Action "key_deleted"}}
|
||||
<span class="badge bg-orange-lt"><i class="ti ti-trash"></i> Key Deleted</span>
|
||||
{{else if eq .Action "key_downloaded"}}
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-file-download"></i> Key Downloaded</span>
|
||||
{{else if eq .Action "server_added"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-server"></i> Server Added</span>
|
||||
{{else if eq .Action "server_deleted"}}
|
||||
<span class="badge bg-orange-lt"><i class="ti ti-trash"></i> Server Deleted</span>
|
||||
{{else if eq .Action "server_test"}}
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-plug"></i> Server Test</span>
|
||||
{{else if eq .Action "server_auth_test"}}
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-lock-check"></i> Auth Test</span>
|
||||
{{else if eq .Action "deploy_success"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-send"></i> Deploy OK</span>
|
||||
{{else if eq .Action "deploy_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-send-off"></i> Deploy Failed</span>
|
||||
{{else if eq .Action "user_created"}}
|
||||
<span class="badge bg-purple-lt"><i class="ti ti-user-plus"></i> User Created</span>
|
||||
{{else if eq .Action "user_updated"}}
|
||||
<span class="badge bg-purple-lt"><i class="ti ti-user-edit"></i> User Updated</span>
|
||||
{{else if eq .Action "user_deleted"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-user-minus"></i> User Deleted</span>
|
||||
{{else if eq .Action "settings_changed"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-settings"></i> Settings Changed</span>
|
||||
{{else if eq .Action "password_changed"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-lock"></i> Password Changed</span>
|
||||
{{else if eq .Action "masterkey_regenerated"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-refresh"></i> Master Key Regen</span>
|
||||
{{else if eq .Action "masterkey_regen_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-key-off"></i> Master Key Regen Failed</span>
|
||||
{{else if eq .Action "avatar_changed"}}
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-photo"></i> Avatar Changed</span>
|
||||
{{else if eq .Action "email_notify_changed"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-mail-cog"></i> Email Notify Changed</span>
|
||||
{{else if eq .Action "email_test_sent"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-mail-check"></i> Email Test Sent</span>
|
||||
{{else if eq .Action "email_test_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-mail-off"></i> Email Test Failed</span>
|
||||
{{else if eq .Action "email_login_sent"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-mail-forward"></i> Login Email Sent</span>
|
||||
{{else if eq .Action "email_login_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-mail-off"></i> Login Email Failed</span>
|
||||
{{else if eq .Action "group_created"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-folder-plus"></i> Group Created</span>
|
||||
{{else if eq .Action "group_updated"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-folder-cog"></i> Group Updated</span>
|
||||
{{else if eq .Action "group_deleted"}}
|
||||
<span class="badge bg-orange-lt"><i class="ti ti-folder-minus"></i> Group Deleted</span>
|
||||
{{else if eq .Action "group_deploy"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-send"></i> Group Deploy</span>
|
||||
{{else if eq .Action "server_updated"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-server-cog"></i> Server Updated</span>
|
||||
{{else if eq .Action "cron_job_created"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-clock-plus"></i> Cron Created</span>
|
||||
{{else if eq .Action "cron_job_updated"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-clock-cog"></i> Cron Updated</span>
|
||||
{{else if eq .Action "cron_job_deleted"}}
|
||||
<span class="badge bg-orange-lt"><i class="ti ti-clock-minus"></i> Cron Deleted</span>
|
||||
{{else if eq .Action "cron_job_paused"}}
|
||||
<span class="badge bg-yellow-lt"><i class="ti ti-clock-pause"></i> Cron Paused</span>
|
||||
{{else if eq .Action "cron_job_resumed"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-clock-play"></i> Cron Resumed</span>
|
||||
{{else if eq .Action "cron_job_executed"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-clock-check"></i> Cron Executed</span>
|
||||
{{else if eq .Action "cron_job_failed"}}
|
||||
<span class="badge bg-red-lt"><i class="ti ti-clock-off"></i> Cron Failed</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary-lt">{{.Action}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary text-break" style="max-width: 400px;">{{.Details}}</td>
|
||||
<td class="text-secondary"><code>{{.IPAddress}}</code></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-mood-empty" style="font-size: 2rem;"></i><br>
|
||||
No audit entries found.
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if gt .AuditTotalPages 1}}
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
<p class="m-0 text-secondary">
|
||||
Page <strong>{{.AuditPage}}</strong> of <strong>{{.AuditTotalPages}}</strong>
|
||||
</p>
|
||||
<ul class="pagination m-0 ms-auto">
|
||||
<li class="page-item {{if le .AuditPage 1}}disabled{{end}}">
|
||||
<a class="page-link" href="/audit?page={{.AuditPrevPage}}{{if .AuditFilterUser}}&filter=mine{{end}}">
|
||||
<i class="ti ti-chevron-left"></i> Prev
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item {{if ge .AuditPage .AuditTotalPages}}disabled{{end}}">
|
||||
<a class="page-link" href="/audit?page={{.AuditNextPage}}{{if .AuditFilterUser}}&filter=mine{{end}}">
|
||||
Next <i class="ti ti-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
181
web/templates/cron.html
Normal file
181
web/templates/cron.html
Normal file
@@ -0,0 +1,181 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-clock"></i> Temporary Access</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/cron/add" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> New Access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>User</th>
|
||||
<th>Key</th>
|
||||
<th>Target</th>
|
||||
<th>System User</th>
|
||||
<th>Password</th>
|
||||
<th>Schedule</th>
|
||||
<th>Auto-Remove</th>
|
||||
<th>On Expiry</th>
|
||||
<th>Next Run</th>
|
||||
<th>Last Run</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CronJobs}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td>
|
||||
{{if .TargetUsername}}
|
||||
<i class="ti ti-user"></i> {{.TargetUsername}}
|
||||
{{else}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge bg-blue-lt">{{.KeyName}}</span></td>
|
||||
<td>
|
||||
{{if eq .TargetType "host"}}
|
||||
<i class="ti ti-server"></i> {{.TargetName}}
|
||||
{{else}}
|
||||
<i class="ti ti-folders"></i> {{.TargetName}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .SystemUser}}
|
||||
<code>{{.SystemUser}}</code>
|
||||
{{else}}
|
||||
<span class="text-secondary">root</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .InitialPassword}}
|
||||
<span class="d-inline-flex align-items-center">
|
||||
<code class="cron-pw-hidden" id="cron-pw-hidden-{{.ID}}">••••••••••</code>
|
||||
<code class="cron-pw-visible d-none" id="cron-pw-visible-{{.ID}}">{{.InitialPassword}}</code>
|
||||
<button class="btn btn-sm btn-icon btn-ghost-secondary ms-1" type="button" onclick="toggleCronPassword({{.ID}})" title="Show/Hide password">
|
||||
<i class="ti ti-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-icon btn-ghost-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('cron-pw-visible-{{.ID}}').textContent); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy password">
|
||||
<i class="ti ti-copy"></i>
|
||||
</button>
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Schedule "once"}}
|
||||
<span class="badge bg-cyan-lt"><i class="ti ti-clock"></i> Once</span>
|
||||
{{else if eq .Schedule "hourly"}}
|
||||
<span class="badge bg-orange-lt"><i class="ti ti-clock-hour-1"></i> Hourly at :{{printf "%02d" .MinuteOfHour}}</span>
|
||||
{{else if eq .Schedule "daily"}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-sun"></i> Daily at {{.TimeOfDay}}</span>
|
||||
{{else if eq .Schedule "weekly"}}
|
||||
<span class="badge bg-purple-lt"><i class="ti ti-calendar-week"></i> Weekly — {{.TimeOfDay}}</span>
|
||||
{{else if eq .Schedule "monthly"}}
|
||||
<span class="badge bg-pink-lt"><i class="ti ti-calendar"></i> Monthly on {{.DayOfMonth}}. — {{.TimeOfDay}}</span>
|
||||
{{end}}
|
||||
{{if and .Timezone (ne .Timezone "UTC")}}
|
||||
<br><small class="text-secondary"><i class="ti ti-world"></i> {{.Timezone}}</small>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if gt .RemoveAfterMin 0}}
|
||||
<span class="badge bg-yellow-lt">{{.RemoveAfterMin}} min</span>
|
||||
{{else}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .ExpiryAction "remove_key"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-key-off"></i> Remove Key</span>
|
||||
{{else if eq .ExpiryAction "disable_user"}}
|
||||
<span class="badge bg-warning-lt"><i class="ti ti-user-off"></i> Disable User</span>
|
||||
{{else if eq .ExpiryAction "delete_user"}}
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-user-minus"></i> Delete User</span>
|
||||
{{else}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "done"}}
|
||||
<span class="text-secondary">—</span>
|
||||
{{else}}
|
||||
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .LastRun}}
|
||||
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
||||
{{else}}
|
||||
<span class="text-secondary">never</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "active"}}
|
||||
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Active</span>
|
||||
{{else if eq .Status "paused"}}
|
||||
<span class="badge bg-warning-lt"><i class="ti ti-player-pause"></i> Paused</span>
|
||||
{{else if eq .Status "running"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-loader"></i> Running</span>
|
||||
{{else if eq .Status "done"}}
|
||||
<span class="badge bg-secondary-lt"><i class="ti ti-check"></i> Done</span>
|
||||
{{else if eq .Status "failed"}}
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-list flex-nowrap justify-content-end">
|
||||
{{if or (eq .Status "active") (eq .Status "paused")}}
|
||||
<form method="POST" action="/cron/{{.ID}}/toggle" class="d-inline">
|
||||
{{if eq .Status "active"}}
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-warning" title="Pause">
|
||||
<i class="ti ti-player-pause"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-success" title="Resume">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
<a href="/cron/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/cron/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this job?');">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="13" class="text-center text-secondary">No temporary access jobs yet. Create one to grant time-limited access.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCronPassword(id) {
|
||||
var hidden = document.getElementById('cron-pw-hidden-' + id);
|
||||
var visible = document.getElementById('cron-pw-visible-' + id);
|
||||
hidden.classList.toggle('d-none');
|
||||
visible.classList.toggle('d-none');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
503
web/templates/cron_add.html
Normal file
503
web/templates/cron_add.html
Normal file
@@ -0,0 +1,503 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-8 col-xl-6 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-clock-plus"></i> New Temporary Access</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/cron/add" method="POST" id="cron-form">
|
||||
<!-- Name -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Job Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Temporary deploy access for contractor" required>
|
||||
</div>
|
||||
|
||||
<!-- Target User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-user"></i> Target User</label>
|
||||
<select name="target_user_id" class="form-select" id="cron-target-user" required>
|
||||
<option value="">Choose a user...</option>
|
||||
{{range .AssignAllUsers}}
|
||||
<option value="{{.ID}}">{{.Username}} ({{.Role}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<small class="form-hint">Select a KeyWarden user. Their SSH keys will be loaded.</small>
|
||||
</div>
|
||||
|
||||
<!-- SSH Key (loaded via AJAX) -->
|
||||
<div class="mb-3" id="cron-key-wrapper" style="display:none;">
|
||||
<label class="form-label required"><i class="ti ti-key"></i> SSH Key</label>
|
||||
<select name="key_id" class="form-select" id="cron-key-id" required>
|
||||
<option value="">Loading keys...</option>
|
||||
</select>
|
||||
<small class="form-hint">Select the SSH key to deploy for this user.</small>
|
||||
</div>
|
||||
|
||||
<!-- No Keys Warning -->
|
||||
<div class="mb-3" id="cron-no-keys" style="display:none;">
|
||||
<div class="alert alert-warning">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-alert-triangle me-2 fs-2"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">No SSH Keys</h4>
|
||||
<div>This user has no SSH keys. Please <a href="/keys/generate">generate</a> or <a href="/keys/import">import</a> a key first.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-server"></i> Target</label>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-auto">
|
||||
<label class="form-selectgroup-item" style="cursor:pointer;">
|
||||
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" checked id="target-type-host">
|
||||
<div class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-selectgroup-item" style="cursor:pointer;">
|
||||
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" id="target-type-group">
|
||||
<div class="form-selectgroup-label"><i class="ti ti-folders"></i> Server Group</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="target-host-select">
|
||||
<select name="server_id" class="form-select" id="cron-server-id">
|
||||
<option value="">Choose a server...</option>
|
||||
{{range .Servers}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div id="target-group-select" style="display:none;">
|
||||
<select name="group_id" class="form-select" id="cron-group-id">
|
||||
<option value="">Choose a group...</option>
|
||||
{{range .Groups}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-terminal-2"></i> System User</label>
|
||||
<input type="text" name="system_user" class="form-control" placeholder="e.g. deploy, admin, root" required>
|
||||
<small class="form-hint">The Linux user on the target server(s) that will receive the SSH key.</small>
|
||||
</div>
|
||||
|
||||
<!-- Options: Sudo & Create User -->
|
||||
<div class="mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="sudo" id="cron-sudo">
|
||||
<span class="form-check-label">Grant Sudo</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="create_user" id="cron-create-user">
|
||||
<span class="form-check-label">Create User if Missing</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial Password (shown when Create User is checked) -->
|
||||
<div class="mb-3" id="cron-initial-pw-wrapper" style="display:none;">
|
||||
<label class="form-label"><i class="ti ti-lock"></i> Initial Password</label>
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-info-circle me-2"></i>
|
||||
<span>A secure initial password will be <strong>auto-generated</strong> when the user is created on the target host.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- Timezone -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-world"></i> Timezone</label>
|
||||
<select name="timezone" class="form-select" id="cron-timezone" required>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="Europe/Berlin">Europe/Berlin (CET/CEST)</option>
|
||||
<option value="Europe/London">Europe/London (GMT/BST)</option>
|
||||
<option value="Europe/Paris">Europe/Paris (CET/CEST)</option>
|
||||
<option value="Europe/Zurich">Europe/Zurich (CET/CEST)</option>
|
||||
<option value="Europe/Vienna">Europe/Vienna (CET/CEST)</option>
|
||||
<option value="Europe/Amsterdam">Europe/Amsterdam (CET/CEST)</option>
|
||||
<option value="Europe/Warsaw">Europe/Warsaw (CET/CEST)</option>
|
||||
<option value="Europe/Moscow">Europe/Moscow (MSK)</option>
|
||||
<option value="America/New_York">America/New_York (EST/EDT)</option>
|
||||
<option value="America/Chicago">America/Chicago (CST/CDT)</option>
|
||||
<option value="America/Denver">America/Denver (MST/MDT)</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles (PST/PDT)</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai (CST)</option>
|
||||
<option value="Asia/Kolkata">Asia/Kolkata (IST)</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
<small class="form-hint">All times will be interpreted in this timezone. Your browser timezone is auto-detected.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-repeat"></i> Schedule</label>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="once" class="form-selectgroup-input" checked>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-clock d-block mb-1 fs-2"></i>
|
||||
<strong>Once</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="hourly" class="form-selectgroup-input">
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-clock-hour-1 d-block mb-1 fs-2"></i>
|
||||
<strong>Hourly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="daily" class="form-selectgroup-input">
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-sun d-block mb-1 fs-2"></i>
|
||||
<strong>Daily</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="weekly" class="form-selectgroup-input">
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-calendar-week d-block mb-1 fs-2"></i>
|
||||
<strong>Weekly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="monthly" class="form-selectgroup-input">
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-calendar d-block mb-1 fs-2"></i>
|
||||
<strong>Monthly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Once — full datetime picker -->
|
||||
<div class="mb-3 schedule-option" id="sched-once">
|
||||
<label class="form-label required">Date & Time</label>
|
||||
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at">
|
||||
<small class="form-hint">Select the exact date and time for this one-time job.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Hourly — minute of hour -->
|
||||
<div class="mb-3 schedule-option" id="sched-hourly" style="display:none;">
|
||||
<label class="form-label required">Run at minute</label>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every hour at minute</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="minute_of_hour" class="form-select" style="width:auto;" id="cron-minute-of-hour">
|
||||
<option value="0">:00</option>
|
||||
<option value="5">:05</option>
|
||||
<option value="10">:10</option>
|
||||
<option value="15">:15</option>
|
||||
<option value="20">:20</option>
|
||||
<option value="25">:25</option>
|
||||
<option value="30">:30</option>
|
||||
<option value="35">:35</option>
|
||||
<option value="40">:40</option>
|
||||
<option value="45">:45</option>
|
||||
<option value="50">:50</option>
|
||||
<option value="55">:55</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every hour at the selected minute.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Daily — time of day -->
|
||||
<div class="mb-3 schedule-option" id="sched-daily" style="display:none;">
|
||||
<label class="form-label required">Time of Day</label>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every day at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-daily" value="02:00" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every day at this time.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Weekly — day of week + time -->
|
||||
<div class="mb-3 schedule-option" id="sched-weekly" style="display:none;">
|
||||
<label class="form-label required">Day & Time</label>
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="day_of_week" class="form-select" style="width:auto;" id="cron-day-of-week">
|
||||
<option value="1">Monday</option>
|
||||
<option value="2">Tuesday</option>
|
||||
<option value="3">Wednesday</option>
|
||||
<option value="4">Thursday</option>
|
||||
<option value="5">Friday</option>
|
||||
<option value="6">Saturday</option>
|
||||
<option value="0">Sunday</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-weekly" value="02:00" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every week on the selected day and time.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Monthly — day of month + time -->
|
||||
<div class="mb-3 schedule-option" id="sched-monthly" style="display:none;">
|
||||
<label class="form-label required">Day & Time</label>
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">On the</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="day_of_month" class="form-select" style="width:auto;" id="cron-day-of-month">
|
||||
{{range $i := .DaysOfMonth}}
|
||||
<option value="{{$i}}">{{$i}}.</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">of each month at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-monthly" value="02:00" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run monthly on the selected day. If the day doesn't exist (e.g. 31st in February), it runs on the last day of the month.</small>
|
||||
</div>
|
||||
|
||||
<!-- Next Run Preview -->
|
||||
<div class="mb-3" id="next-run-preview" style="display:none;">
|
||||
<div class="alert alert-info">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-info-circle me-2 fs-2"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">Schedule Preview</h4>
|
||||
<div id="next-run-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- Auto-remove -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="ti ti-hourglass"></i> Auto-Remove After (minutes)</label>
|
||||
<input type="number" name="remove_after_min" class="form-control" min="0" value="0" placeholder="0 = keep permanently">
|
||||
<small class="form-hint">Set to 0 to keep the access permanently. E.g., 120 = revoke access after 2 hours.</small>
|
||||
</div>
|
||||
|
||||
<!-- Expiry Action -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="ti ti-shield-off"></i> On Expiry</label>
|
||||
<select name="expiry_action" class="form-select" id="cron-expiry-action">
|
||||
<option value="remove_key" selected>Remove SSH Key only</option>
|
||||
<option value="disable_user">Disable User (lock account + nologin)</option>
|
||||
<option value="delete_user">Delete User (remove system user completely)</option>
|
||||
</select>
|
||||
<small class="form-hint">What happens when the temporary access expires.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-clock-plus"></i> Create Temporary Access
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Target user change: load SSH keys via AJAX
|
||||
var userSelect = document.getElementById('cron-target-user');
|
||||
var keySelect = document.getElementById('cron-key-id');
|
||||
var keyWrapper = document.getElementById('cron-key-wrapper');
|
||||
var noKeys = document.getElementById('cron-no-keys');
|
||||
|
||||
userSelect.addEventListener('change', function() {
|
||||
var userId = this.value;
|
||||
keyWrapper.style.display = 'none';
|
||||
noKeys.style.display = 'none';
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
keySelect.innerHTML = '<option value="">Loading...</option>';
|
||||
keyWrapper.style.display = 'block';
|
||||
|
||||
fetch('/api/cron/keys?user_id=' + userId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var keys = data || [];
|
||||
keySelect.innerHTML = '<option value="">Choose a key...</option>';
|
||||
|
||||
if (keys.length === 0) {
|
||||
keyWrapper.style.display = 'none';
|
||||
noKeys.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach(function(k) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = k.id;
|
||||
opt.textContent = k.name + ' (' + k.key_type + ')';
|
||||
keySelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
keySelect.innerHTML = '<option value="">Failed to load keys</option>';
|
||||
});
|
||||
});
|
||||
|
||||
// Target type toggle
|
||||
document.getElementById('target-type-host').addEventListener('change', function() {
|
||||
document.getElementById('target-host-select').style.display = 'block';
|
||||
document.getElementById('target-group-select').style.display = 'none';
|
||||
});
|
||||
document.getElementById('target-type-group').addEventListener('change', function() {
|
||||
document.getElementById('target-host-select').style.display = 'none';
|
||||
document.getElementById('target-group-select').style.display = 'block';
|
||||
});
|
||||
|
||||
// Create user toggle → show initial password
|
||||
document.getElementById('cron-create-user').addEventListener('change', function() {
|
||||
document.getElementById('cron-initial-pw-wrapper').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Schedule type toggle
|
||||
var scheduleOptions = ['once', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||
function updateScheduleUI() {
|
||||
var selected = document.querySelector('input[name="schedule"]:checked').value;
|
||||
scheduleOptions.forEach(function(opt) {
|
||||
var el = document.getElementById('sched-' + opt);
|
||||
if (el) el.style.display = (opt === selected) ? 'block' : 'none';
|
||||
});
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="schedule"]').forEach(function(el) {
|
||||
el.addEventListener('change', updateScheduleUI);
|
||||
});
|
||||
|
||||
// Auto-detect timezone
|
||||
try {
|
||||
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
var tzSelect = document.getElementById('cron-timezone');
|
||||
for (var i = 0; i < tzSelect.options.length; i++) {
|
||||
if (tzSelect.options[i].value === tz) {
|
||||
tzSelect.value = tz;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Preview generation
|
||||
var dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function updatePreview() {
|
||||
var schedule = document.querySelector('input[name="schedule"]:checked').value;
|
||||
var tz = document.getElementById('cron-timezone').value;
|
||||
var previewEl = document.getElementById('next-run-preview');
|
||||
var textEl = document.getElementById('next-run-text');
|
||||
var text = '';
|
||||
|
||||
switch(schedule) {
|
||||
case 'once':
|
||||
var dt = document.getElementById('cron-scheduled-at').value;
|
||||
if (dt) {
|
||||
text = 'Runs once on <strong>' + dt.replace('T', ' at ') + '</strong> (' + tz + ')';
|
||||
}
|
||||
break;
|
||||
case 'hourly':
|
||||
var min = document.getElementById('cron-minute-of-hour').value;
|
||||
text = 'Runs <strong>every hour at :' + String(min).padStart(2, '0') + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'daily':
|
||||
var time = document.getElementById('cron-time-daily').value || '02:00';
|
||||
text = 'Runs <strong>every day at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'weekly':
|
||||
var dow = document.getElementById('cron-day-of-week').value;
|
||||
var time = document.getElementById('cron-time-weekly').value || '02:00';
|
||||
text = 'Runs <strong>every ' + dayNames[dow] + ' at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'monthly':
|
||||
var dom = document.getElementById('cron-day-of-month').value;
|
||||
var time = document.getElementById('cron-time-monthly').value || '02:00';
|
||||
text = 'Runs <strong>on the ' + dom + '. of each month at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
previewEl.style.display = 'block';
|
||||
textEl.innerHTML = text;
|
||||
} else {
|
||||
previewEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes on all schedule inputs
|
||||
document.querySelectorAll('#cron-scheduled-at, #cron-minute-of-hour, #cron-time-daily, #cron-time-weekly, #cron-day-of-week, #cron-time-monthly, #cron-day-of-month, #cron-timezone').forEach(function(el) {
|
||||
el.addEventListener('change', updatePreview);
|
||||
el.addEventListener('input', updatePreview);
|
||||
});
|
||||
|
||||
// Consolidate time_of_day fields before submit
|
||||
document.getElementById('cron-form').addEventListener('submit', function(e) {
|
||||
var schedule = document.querySelector('input[name="schedule"]:checked').value;
|
||||
var existing = document.querySelector('input[name="time_of_day"][type="hidden"]');
|
||||
if (existing) existing.remove();
|
||||
var hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'time_of_day';
|
||||
|
||||
if (schedule === 'daily') {
|
||||
hidden.value = document.getElementById('cron-time-daily').value || '02:00';
|
||||
} else if (schedule === 'weekly') {
|
||||
hidden.value = document.getElementById('cron-time-weekly').value || '02:00';
|
||||
} else if (schedule === 'monthly') {
|
||||
hidden.value = document.getElementById('cron-time-monthly').value || '02:00';
|
||||
} else {
|
||||
hidden.value = '00:00';
|
||||
}
|
||||
this.appendChild(hidden);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateScheduleUI();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
510
web/templates/cron_edit.html
Normal file
510
web/templates/cron_edit.html
Normal file
@@ -0,0 +1,510 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-8 col-xl-6 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-clock-edit"></i> Edit Temporary Access</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{$job := .CronJob}}
|
||||
<form action="/cron/{{$job.ID}}/edit" method="POST" id="cron-form">
|
||||
<!-- Name -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Job Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{$job.Name}}" required>
|
||||
</div>
|
||||
|
||||
<!-- Target User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-user"></i> Target User</label>
|
||||
<select name="target_user_id" class="form-select" id="cron-target-user" required>
|
||||
<option value="">Choose a user...</option>
|
||||
{{range $.AssignAllUsers}}
|
||||
<option value="{{.ID}}" {{if eq .ID $job.TargetUserID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<small class="form-hint">Select a KeyWarden user. Their SSH keys will be loaded.</small>
|
||||
</div>
|
||||
|
||||
<!-- SSH Key (loaded via AJAX) -->
|
||||
<div class="mb-3" id="cron-key-wrapper" {{if le $job.SSHKeyID 0}}style="display:none;"{{end}}>
|
||||
<label class="form-label required"><i class="ti ti-key"></i> SSH Key</label>
|
||||
<select name="key_id" class="form-select" id="cron-key-id" required>
|
||||
<option value="">Loading keys...</option>
|
||||
</select>
|
||||
<small class="form-hint">Select the SSH key to deploy for this user.</small>
|
||||
</div>
|
||||
|
||||
<!-- No Keys Warning -->
|
||||
<div class="mb-3" id="cron-no-keys" style="display:none;">
|
||||
<div class="alert alert-warning">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-alert-triangle me-2 fs-2"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">No SSH Keys</h4>
|
||||
<div>This user has no SSH keys. Please <a href="/keys/generate">generate</a> or <a href="/keys/import">import</a> a key first.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-server"></i> Target</label>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-auto">
|
||||
<label class="form-selectgroup-item" style="cursor:pointer;">
|
||||
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" {{if or (gt $job.ServerID 0) (eq $job.GroupID 0)}}checked{{end}} id="target-type-host">
|
||||
<div class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-selectgroup-item" style="cursor:pointer;">
|
||||
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" {{if gt $job.GroupID 0}}checked{{end}} id="target-type-group">
|
||||
<div class="form-selectgroup-label"><i class="ti ti-folders"></i> Server Group</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="target-host-select" {{if gt $job.GroupID 0}}style="display:none;"{{end}}>
|
||||
<select name="server_id" class="form-select" id="cron-server-id">
|
||||
<option value="">Choose a server...</option>
|
||||
{{range $.Servers}}
|
||||
<option value="{{.ID}}" {{if eq .ID $job.ServerID}}selected{{end}}>{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div id="target-group-select" {{if le $job.GroupID 0}}style="display:none;"{{end}}>
|
||||
<select name="group_id" class="form-select" id="cron-group-id">
|
||||
<option value="">Choose a group...</option>
|
||||
{{range $.Groups}}
|
||||
<option value="{{.ID}}" {{if eq .ID $job.GroupID}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System User -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-terminal-2"></i> System User</label>
|
||||
<input type="text" name="system_user" class="form-control" value="{{$job.SystemUser}}" placeholder="e.g. deploy, admin, root" required>
|
||||
<small class="form-hint">The Linux user on the target server(s) that will receive the SSH key.</small>
|
||||
</div>
|
||||
|
||||
<!-- Options: Sudo & Create User -->
|
||||
<div class="mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="sudo" id="cron-sudo" {{if $job.Sudo}}checked{{end}}>
|
||||
<span class="form-check-label">Grant Sudo</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="create_user" id="cron-create-user" {{if $job.CreateUser}}checked{{end}}>
|
||||
<span class="form-check-label">Create User if Missing</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial Password (shown when Create User is checked) -->
|
||||
<div class="mb-3" id="cron-initial-pw-wrapper" {{if not $job.CreateUser}}style="display:none;"{{end}}>
|
||||
<label class="form-label"><i class="ti ti-lock"></i> Initial Password</label>
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-info-circle me-2"></i>
|
||||
<span>A secure initial password will be <strong>auto-generated</strong> when the user is created on the target host.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- Timezone -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-world"></i> Timezone</label>
|
||||
<select name="timezone" class="form-select" id="cron-timezone" required>
|
||||
<option value="UTC" {{if eq $job.Timezone "UTC"}}selected{{end}}>UTC</option>
|
||||
<option value="Europe/Berlin" {{if eq $job.Timezone "Europe/Berlin"}}selected{{end}}>Europe/Berlin (CET/CEST)</option>
|
||||
<option value="Europe/London" {{if eq $job.Timezone "Europe/London"}}selected{{end}}>Europe/London (GMT/BST)</option>
|
||||
<option value="Europe/Paris" {{if eq $job.Timezone "Europe/Paris"}}selected{{end}}>Europe/Paris (CET/CEST)</option>
|
||||
<option value="Europe/Zurich" {{if eq $job.Timezone "Europe/Zurich"}}selected{{end}}>Europe/Zurich (CET/CEST)</option>
|
||||
<option value="Europe/Vienna" {{if eq $job.Timezone "Europe/Vienna"}}selected{{end}}>Europe/Vienna (CET/CEST)</option>
|
||||
<option value="Europe/Amsterdam" {{if eq $job.Timezone "Europe/Amsterdam"}}selected{{end}}>Europe/Amsterdam (CET/CEST)</option>
|
||||
<option value="Europe/Warsaw" {{if eq $job.Timezone "Europe/Warsaw"}}selected{{end}}>Europe/Warsaw (CET/CEST)</option>
|
||||
<option value="Europe/Moscow" {{if eq $job.Timezone "Europe/Moscow"}}selected{{end}}>Europe/Moscow (MSK)</option>
|
||||
<option value="America/New_York" {{if eq $job.Timezone "America/New_York"}}selected{{end}}>America/New_York (EST/EDT)</option>
|
||||
<option value="America/Chicago" {{if eq $job.Timezone "America/Chicago"}}selected{{end}}>America/Chicago (CST/CDT)</option>
|
||||
<option value="America/Denver" {{if eq $job.Timezone "America/Denver"}}selected{{end}}>America/Denver (MST/MDT)</option>
|
||||
<option value="America/Los_Angeles" {{if eq $job.Timezone "America/Los_Angeles"}}selected{{end}}>America/Los_Angeles (PST/PDT)</option>
|
||||
<option value="Asia/Tokyo" {{if eq $job.Timezone "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo (JST)</option>
|
||||
<option value="Asia/Shanghai" {{if eq $job.Timezone "Asia/Shanghai"}}selected{{end}}>Asia/Shanghai (CST)</option>
|
||||
<option value="Asia/Kolkata" {{if eq $job.Timezone "Asia/Kolkata"}}selected{{end}}>Asia/Kolkata (IST)</option>
|
||||
<option value="Australia/Sydney" {{if eq $job.Timezone "Australia/Sydney"}}selected{{end}}>Australia/Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
<small class="form-hint">All times will be interpreted in this timezone.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Type -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required"><i class="ti ti-repeat"></i> Schedule</label>
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="once" class="form-selectgroup-input" {{if eq $job.Schedule "once"}}checked{{end}}>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-clock d-block mb-1 fs-2"></i>
|
||||
<strong>Once</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="hourly" class="form-selectgroup-input" {{if eq $job.Schedule "hourly"}}checked{{end}}>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-clock-hour-1 d-block mb-1 fs-2"></i>
|
||||
<strong>Hourly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="daily" class="form-selectgroup-input" {{if eq $job.Schedule "daily"}}checked{{end}}>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-sun d-block mb-1 fs-2"></i>
|
||||
<strong>Daily</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="weekly" class="form-selectgroup-input" {{if eq $job.Schedule "weekly"}}checked{{end}}>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-calendar-week d-block mb-1 fs-2"></i>
|
||||
<strong>Weekly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="schedule" value="monthly" class="form-selectgroup-input" {{if eq $job.Schedule "monthly"}}checked{{end}}>
|
||||
<div class="form-selectgroup-label text-center p-2">
|
||||
<i class="ti ti-calendar d-block mb-1 fs-2"></i>
|
||||
<strong>Monthly</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Once — full datetime picker -->
|
||||
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Date & Time</label>
|
||||
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
|
||||
value="{{$job.ScheduledAt.Format "2006-01-02T15:04"}}">
|
||||
<small class="form-hint">Select the exact date and time for this one-time job.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Hourly — minute of hour -->
|
||||
<div class="mb-3 schedule-option" id="sched-hourly" {{if ne $job.Schedule "hourly"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Run at minute</label>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every hour at minute</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="minute_of_hour" class="form-select" style="width:auto;" id="cron-minute-of-hour">
|
||||
<option value="0" {{if eq $job.MinuteOfHour 0}}selected{{end}}>:00</option>
|
||||
<option value="5" {{if eq $job.MinuteOfHour 5}}selected{{end}}>:05</option>
|
||||
<option value="10" {{if eq $job.MinuteOfHour 10}}selected{{end}}>:10</option>
|
||||
<option value="15" {{if eq $job.MinuteOfHour 15}}selected{{end}}>:15</option>
|
||||
<option value="20" {{if eq $job.MinuteOfHour 20}}selected{{end}}>:20</option>
|
||||
<option value="25" {{if eq $job.MinuteOfHour 25}}selected{{end}}>:25</option>
|
||||
<option value="30" {{if eq $job.MinuteOfHour 30}}selected{{end}}>:30</option>
|
||||
<option value="35" {{if eq $job.MinuteOfHour 35}}selected{{end}}>:35</option>
|
||||
<option value="40" {{if eq $job.MinuteOfHour 40}}selected{{end}}>:40</option>
|
||||
<option value="45" {{if eq $job.MinuteOfHour 45}}selected{{end}}>:45</option>
|
||||
<option value="50" {{if eq $job.MinuteOfHour 50}}selected{{end}}>:50</option>
|
||||
<option value="55" {{if eq $job.MinuteOfHour 55}}selected{{end}}>:55</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every hour at the selected minute.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Daily — time of day -->
|
||||
<div class="mb-3 schedule-option" id="sched-daily" {{if ne $job.Schedule "daily"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Time of Day</label>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every day at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-daily"
|
||||
value="{{$job.TimeOfDay}}" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every day at this time.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Weekly — day of week + time -->
|
||||
<div class="mb-3 schedule-option" id="sched-weekly" {{if ne $job.Schedule "weekly"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Day & Time</label>
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">Every</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="day_of_week" class="form-select" style="width:auto;" id="cron-day-of-week">
|
||||
<option value="1" {{if eq $job.DayOfWeek 1}}selected{{end}}>Monday</option>
|
||||
<option value="2" {{if eq $job.DayOfWeek 2}}selected{{end}}>Tuesday</option>
|
||||
<option value="3" {{if eq $job.DayOfWeek 3}}selected{{end}}>Wednesday</option>
|
||||
<option value="4" {{if eq $job.DayOfWeek 4}}selected{{end}}>Thursday</option>
|
||||
<option value="5" {{if eq $job.DayOfWeek 5}}selected{{end}}>Friday</option>
|
||||
<option value="6" {{if eq $job.DayOfWeek 6}}selected{{end}}>Saturday</option>
|
||||
<option value="0" {{if eq $job.DayOfWeek 0}}selected{{end}}>Sunday</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-weekly"
|
||||
value="{{$job.TimeOfDay}}" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run every week on the selected day and time.</small>
|
||||
</div>
|
||||
|
||||
<!-- Schedule: Monthly — day of month + time -->
|
||||
<div class="mb-3 schedule-option" id="sched-monthly" {{if ne $job.Schedule "monthly"}}style="display:none;"{{end}}>
|
||||
<label class="form-label required">Day & Time</label>
|
||||
<div class="row align-items-center g-2">
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">On the</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="day_of_month" class="form-select" style="width:auto;" id="cron-day-of-month">
|
||||
{{range $i := $.DaysOfMonth}}
|
||||
<option value="{{$i}}" {{if eq $i $job.DayOfMonth}}selected{{end}}>{{$i}}.</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="text-secondary">of each month at</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="time" class="form-control" id="cron-time-monthly"
|
||||
value="{{$job.TimeOfDay}}" style="width:auto;">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint">The job will run monthly on the selected day. If the day doesn't exist (e.g. 31st in February), it runs on the last day of the month.</small>
|
||||
</div>
|
||||
|
||||
<!-- Next Run Preview -->
|
||||
<div class="mb-3" id="next-run-preview" style="display:none;">
|
||||
<div class="alert alert-info">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-info-circle me-2 fs-2"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">Schedule Preview</h4>
|
||||
<div id="next-run-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- Auto-remove -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="ti ti-hourglass"></i> Auto-Remove After (minutes)</label>
|
||||
<input type="number" name="remove_after_min" class="form-control" min="0" value="{{$job.RemoveAfterMin}}" placeholder="0 = keep permanently">
|
||||
<small class="form-hint">Set to 0 to keep the access permanently. E.g., 120 = revoke access after 2 hours.</small>
|
||||
</div>
|
||||
|
||||
<!-- Expiry Action -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="ti ti-shield-off"></i> On Expiry</label>
|
||||
<select name="expiry_action" class="form-select" id="cron-expiry-action">
|
||||
<option value="remove_key" {{if eq $job.ExpiryAction "remove_key"}}selected{{end}}>Remove SSH Key only</option>
|
||||
<option value="disable_user" {{if eq $job.ExpiryAction "disable_user"}}selected{{end}}>Disable User (lock account + nologin)</option>
|
||||
<option value="delete_user" {{if eq $job.ExpiryAction "delete_user"}}selected{{end}}>Delete User (remove system user completely)</option>
|
||||
</select>
|
||||
<small class="form-hint">What happens when the temporary access expires.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-device-floppy"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var preselectedKeyId = {{$job.SSHKeyID}};
|
||||
var preselectedUserId = {{$job.TargetUserID}};
|
||||
|
||||
// Target user change: load SSH keys via AJAX
|
||||
var userSelect = document.getElementById('cron-target-user');
|
||||
var keySelect = document.getElementById('cron-key-id');
|
||||
var keyWrapper = document.getElementById('cron-key-wrapper');
|
||||
var noKeys = document.getElementById('cron-no-keys');
|
||||
|
||||
function loadKeys(userId, preselectId) {
|
||||
keyWrapper.style.display = 'none';
|
||||
noKeys.style.display = 'none';
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
keySelect.innerHTML = '<option value="">Loading...</option>';
|
||||
keyWrapper.style.display = 'block';
|
||||
|
||||
fetch('/api/cron/keys?user_id=' + userId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var keys = data || [];
|
||||
keySelect.innerHTML = '<option value="">Choose a key...</option>';
|
||||
|
||||
if (keys.length === 0) {
|
||||
keyWrapper.style.display = 'none';
|
||||
noKeys.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach(function(k) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = k.id;
|
||||
opt.textContent = k.name + ' (' + k.key_type + ')';
|
||||
if (preselectId && k.id === preselectId) {
|
||||
opt.selected = true;
|
||||
}
|
||||
keySelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
keySelect.innerHTML = '<option value="">Failed to load keys</option>';
|
||||
});
|
||||
}
|
||||
|
||||
userSelect.addEventListener('change', function() {
|
||||
loadKeys(this.value, null);
|
||||
});
|
||||
|
||||
// Load keys for preselected user on page load
|
||||
if (preselectedUserId > 0) {
|
||||
loadKeys(preselectedUserId, preselectedKeyId);
|
||||
}
|
||||
|
||||
// Target type toggle
|
||||
document.getElementById('target-type-host').addEventListener('change', function() {
|
||||
document.getElementById('target-host-select').style.display = 'block';
|
||||
document.getElementById('target-group-select').style.display = 'none';
|
||||
});
|
||||
document.getElementById('target-type-group').addEventListener('change', function() {
|
||||
document.getElementById('target-host-select').style.display = 'none';
|
||||
document.getElementById('target-group-select').style.display = 'block';
|
||||
});
|
||||
|
||||
// Create user toggle → show initial password
|
||||
document.getElementById('cron-create-user').addEventListener('change', function() {
|
||||
document.getElementById('cron-initial-pw-wrapper').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Schedule type toggle
|
||||
var scheduleOptions = ['once', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||
function updateScheduleUI() {
|
||||
var selected = document.querySelector('input[name="schedule"]:checked').value;
|
||||
scheduleOptions.forEach(function(opt) {
|
||||
var el = document.getElementById('sched-' + opt);
|
||||
if (el) el.style.display = (opt === selected) ? 'block' : 'none';
|
||||
});
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="schedule"]').forEach(function(el) {
|
||||
el.addEventListener('change', updateScheduleUI);
|
||||
});
|
||||
|
||||
// Preview generation
|
||||
var dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function updatePreview() {
|
||||
var schedule = document.querySelector('input[name="schedule"]:checked').value;
|
||||
var tz = document.getElementById('cron-timezone').value;
|
||||
var previewEl = document.getElementById('next-run-preview');
|
||||
var textEl = document.getElementById('next-run-text');
|
||||
var text = '';
|
||||
|
||||
switch(schedule) {
|
||||
case 'once':
|
||||
var dt = document.getElementById('cron-scheduled-at').value;
|
||||
if (dt) {
|
||||
text = 'Runs once on <strong>' + dt.replace('T', ' at ') + '</strong> (' + tz + ')';
|
||||
}
|
||||
break;
|
||||
case 'hourly':
|
||||
var min = document.getElementById('cron-minute-of-hour').value;
|
||||
text = 'Runs <strong>every hour at :' + String(min).padStart(2, '0') + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'daily':
|
||||
var time = document.getElementById('cron-time-daily').value || '02:00';
|
||||
text = 'Runs <strong>every day at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'weekly':
|
||||
var dow = document.getElementById('cron-day-of-week').value;
|
||||
var time = document.getElementById('cron-time-weekly').value || '02:00';
|
||||
text = 'Runs <strong>every ' + dayNames[dow] + ' at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
case 'monthly':
|
||||
var dom = document.getElementById('cron-day-of-month').value;
|
||||
var time = document.getElementById('cron-time-monthly').value || '02:00';
|
||||
text = 'Runs <strong>on the ' + dom + '. of each month at ' + time + '</strong> (' + tz + ')';
|
||||
break;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
previewEl.style.display = 'block';
|
||||
textEl.innerHTML = text;
|
||||
} else {
|
||||
previewEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes on all schedule inputs
|
||||
document.querySelectorAll('#cron-scheduled-at, #cron-minute-of-hour, #cron-time-daily, #cron-time-weekly, #cron-day-of-week, #cron-time-monthly, #cron-day-of-month, #cron-timezone').forEach(function(el) {
|
||||
el.addEventListener('change', updatePreview);
|
||||
el.addEventListener('input', updatePreview);
|
||||
});
|
||||
|
||||
// Consolidate time_of_day fields before submit
|
||||
document.getElementById('cron-form').addEventListener('submit', function(e) {
|
||||
var schedule = document.querySelector('input[name="schedule"]:checked').value;
|
||||
var existing = document.querySelector('input[name="time_of_day"][type="hidden"]');
|
||||
if (existing) existing.remove();
|
||||
var hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'time_of_day';
|
||||
|
||||
if (schedule === 'daily') {
|
||||
hidden.value = document.getElementById('cron-time-daily').value || '02:00';
|
||||
} else if (schedule === 'weekly') {
|
||||
hidden.value = document.getElementById('cron-time-weekly').value || '02:00';
|
||||
} else if (schedule === 'monthly') {
|
||||
hidden.value = document.getElementById('cron-time-monthly').value || '02:00';
|
||||
} else {
|
||||
hidden.value = '00:00';
|
||||
}
|
||||
this.appendChild(hidden);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateScheduleUI();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
223
web/templates/dashboard.html
Normal file
223
web/templates/dashboard.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards g-3">
|
||||
|
||||
<!-- ═══ Stat Cards Row ═══ -->
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-secondary mb-1"><i class="ti ti-key"></i> SSH Keys</div>
|
||||
<div class="h1 mb-0">{{.KeyCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-secondary mb-1"><i class="ti ti-server"></i> Hosts</div>
|
||||
<div class="h1 mb-0">{{.ServerCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-secondary mb-1"><i class="ti ti-folders"></i> Server Groups</div>
|
||||
<div class="h1 mb-0">{{.GroupCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-secondary mb-1"><i class="ti ti-send"></i> Deployments</div>
|
||||
<div class="h1 mb-0">{{.DeployCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-secondary mb-1"><i class="ti ti-shield-lock"></i> Assignments</div>
|
||||
<div class="h1 mb-0">{{.AssignmentCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
{{if eq .UserRole "admin"}}
|
||||
<div class="text-secondary mb-1"><i class="ti ti-users"></i> Users</div>
|
||||
<div class="h1 mb-0">{{.UserCount}}</div>
|
||||
{{else}}
|
||||
<div class="text-secondary mb-1"><i class="ti ti-clock"></i> Temporary Access</div>
|
||||
<div class="h1 mb-0">{{.CronCount}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Quick Actions ═══ -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-bolt"></i> Quick Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/keys/generate" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-key"></i> Generate Key
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/keys/import" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-file-import"></i> Import Key
|
||||
</a>
|
||||
</div>
|
||||
{{if ne .UserRole "user"}}
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/servers/add" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-server"></i> Add Host
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/deploy" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-send"></i> Deploy Keys
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/cron/add" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-clock-plus"></i> Temporary Access
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="/assignments/add" class="btn btn-outline-primary w-100">
|
||||
<i class="ti ti-shield-plus"></i> New Assignment
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Recent SSH Keys ═══ -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-key"></i> Recent SSH Keys</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/keys" class="btn btn-outline-primary btn-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Fingerprint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="badge bg-azure-lt">{{.KeyType}}</span></td>
|
||||
<td><code>{{.Fingerprint}}</code></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No SSH keys yet. <a href="/keys/generate">Generate one!</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Recent Deployments ═══ -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-send"></i> Recent Deployments</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/deploy" class="btn btn-outline-primary btn-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Server</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentDeploys}}
|
||||
<tr>
|
||||
<td>{{index . "key_name"}}</td>
|
||||
<td>{{index . "server_name"}}</td>
|
||||
<td>
|
||||
{{if eq (index . "status") "success"}}
|
||||
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Success</span>
|
||||
{{else}}
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No deployments yet.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Recent Activity (Audit Log) ═══ -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-history"></i> Recent Activity</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/audit" class="btn btn-outline-primary btn-sm">View All</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Details</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentAudit}}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
|
||||
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
|
||||
<td><code>{{.IPAddress}}</code></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-secondary">No activity recorded yet.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
277
web/templates/deploy.html
Normal file
277
web/templates/deploy.html
Normal file
@@ -0,0 +1,277 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-send"></i> Deploy SSH Key</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Step 1: Target Type -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">1</span> Deploy Target
|
||||
</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="deploy_target" value="host" class="form-selectgroup-input" id="target-host">
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-server fs-2"></i></div>
|
||||
<div>
|
||||
<strong>Single Host</strong>
|
||||
<div class="text-secondary">Deploy to one server</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
|
||||
<input type="radio" name="deploy_target" value="group" class="form-selectgroup-input" id="target-group">
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-folders fs-2"></i></div>
|
||||
<div>
|
||||
<strong>Server Group</strong>
|
||||
<div class="text-secondary">Deploy to all servers in a group</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps 2-4 (hidden until target is chosen) -->
|
||||
<div id="deploy-details" style="display:none;">
|
||||
|
||||
<!-- Host form (POST to /deploy) -->
|
||||
<form action="/deploy" method="POST" id="form-host" style="display:none;">
|
||||
<!-- Step 2: Key -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">2</span> SSH Key
|
||||
</label>
|
||||
<select name="key_id" class="form-select" required>
|
||||
<option value="">Choose a key...</option>
|
||||
{{range .Keys}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Step 3: Host -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">3</span> Target Host
|
||||
</label>
|
||||
<select name="server_id" class="form-select" required>
|
||||
<option value="">Choose a host...</option>
|
||||
{{range .Servers}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Step 4: Auth -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">4</span> Authentication Method
|
||||
</label>
|
||||
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="radio" name="auth_method" value="password" class="form-selectgroup-input host-auth" checked>
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-lock"></i></div>
|
||||
<div>
|
||||
<strong>Password</strong>
|
||||
<div class="text-secondary">Use SSH password to authenticate</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="radio" name="auth_method" value="key" class="form-selectgroup-input host-auth">
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-key"></i></div>
|
||||
<div>
|
||||
<strong>Existing Key</strong>
|
||||
<div class="text-secondary">Use an existing key from Keywarden to authenticate</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="host-password-group">
|
||||
<label class="form-label">SSH Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="Server password">
|
||||
</div>
|
||||
<div class="mb-3" id="host-authkey-group" style="display:none;">
|
||||
<label class="form-label">Authentication Key</label>
|
||||
<select name="auth_key_id" class="form-select">
|
||||
<option value="">Choose a key for auth...</option>
|
||||
{{range .Keys}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-send"></i> Deploy Key to Host
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Group form (POST to /deploy/group) -->
|
||||
<form action="/deploy/group" method="POST" id="form-group" style="display:none;">
|
||||
<!-- Step 2: Key -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">2</span> SSH Key
|
||||
</label>
|
||||
<select name="key_id" class="form-select" required>
|
||||
<option value="">Choose a key...</option>
|
||||
{{range .Keys}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Step 3: Group -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">3</span> Target Group
|
||||
</label>
|
||||
<select name="group_id" class="form-select" required>
|
||||
<option value="">Choose a group...</option>
|
||||
{{range .Groups}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.ServerCount}} server{{if ne .ServerCount 1}}s{{end}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Step 4: Auth -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">
|
||||
<span class="badge bg-blue-lt me-1">4</span> Authentication Method
|
||||
</label>
|
||||
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="radio" name="auth_method" value="password" class="form-selectgroup-input grp-auth" checked>
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-lock"></i></div>
|
||||
<div>
|
||||
<strong>Password</strong>
|
||||
<div class="text-secondary">Same password for all hosts in group</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="radio" name="auth_method" value="key" class="form-selectgroup-input grp-auth">
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3"><i class="ti ti-key"></i></div>
|
||||
<div>
|
||||
<strong>Existing Key</strong>
|
||||
<div class="text-secondary">Use an existing key from Keywarden</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="grp-password-group">
|
||||
<label class="form-label">SSH Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="Server password">
|
||||
</div>
|
||||
<div class="mb-3" id="grp-authkey-group" style="display:none;">
|
||||
<label class="form-label">Authentication Key</label>
|
||||
<select name="auth_key_id" class="form-select">
|
||||
<option value="">Choose a key for auth...</option>
|
||||
{{range .Keys}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-send"></i> Deploy Key to Group
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div><!-- /deploy-details -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployment History -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-history"></i> Deployment History</h3>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Server</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Deployments}}
|
||||
<tr>
|
||||
<td>{{index . "key_name"}}</td>
|
||||
<td>{{index . "server_name"}}</td>
|
||||
<td>
|
||||
{{if eq (index . "status") "success"}}
|
||||
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Success</span>
|
||||
{{else}}
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{index . "deployed_at"}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-secondary">No deployments yet.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var details = document.getElementById('deploy-details');
|
||||
var formHost = document.getElementById('form-host');
|
||||
var formGroup = document.getElementById('form-group');
|
||||
|
||||
// Step 1: Target selection
|
||||
document.querySelectorAll('input[name="deploy_target"]').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
details.style.display = 'block';
|
||||
if (this.value === 'host') {
|
||||
formHost.style.display = 'block';
|
||||
formGroup.style.display = 'none';
|
||||
} else {
|
||||
formHost.style.display = 'none';
|
||||
formGroup.style.display = 'block';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Host auth toggle
|
||||
document.querySelectorAll('.host-auth').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
document.getElementById('host-password-group').style.display = this.value === 'password' ? 'block' : 'none';
|
||||
document.getElementById('host-authkey-group').style.display = this.value === 'key' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Group auth toggle
|
||||
document.querySelectorAll('.grp-auth').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
document.getElementById('grp-password-group').style.display = this.value === 'password' ? 'block' : 'none';
|
||||
document.getElementById('grp-authkey-group').style.display = this.value === 'key' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
65
web/templates/force_password_change.html
Normal file
65
web/templates/force_password_change.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-lock-exclamation"></i> Password Change Required</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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">You must change your password</h4>
|
||||
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .PasswordPolicy}}
|
||||
<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">Password Requirements</h4>
|
||||
<ul class="mb-0">
|
||||
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
|
||||
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
|
||||
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
|
||||
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
|
||||
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/password/change" method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">New Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
|
||||
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Confirm New Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
|
||||
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-lock-check"></i> Set New Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
124
web/templates/invite_accept.html
Normal file
124
web/templates/invite_accept.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<title>Complete Registration - {{appName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script>
|
||||
(function() {
|
||||
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
|
||||
html[data-bs-theme="light"],
|
||||
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
|
||||
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
|
||||
</head>
|
||||
<body class="d-flex flex-column">
|
||||
<div class="page page-center">
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4">
|
||||
<h1><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
{{if .Error}}
|
||||
<!-- Error state (invalid/expired/used token) -->
|
||||
<h2 class="h2 text-center mb-4">
|
||||
<i class="ti ti-alert-triangle text-warning"></i> {{.Title}}
|
||||
</h2>
|
||||
<div class="alert alert-warning">
|
||||
<i class="ti ti-alert-circle"></i> {{.Error}}
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="/login" class="btn btn-primary">
|
||||
<i class="ti ti-login"></i> Go to Login
|
||||
</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Registration form -->
|
||||
<h2 class="h2 text-center mb-4">
|
||||
<i class="ti ti-user-check"></i> Complete Registration
|
||||
</h2>
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-{{.Flash.Type}}">
|
||||
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="text-secondary text-center mb-3">
|
||||
Welcome, <strong>{{.EditUser.Username}}</strong>! Please set your password to activate your account.
|
||||
</p>
|
||||
<form action="/invite/{{.Data}}" method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
|
||||
<input type="text" class="form-control" value="{{.EditUser.Username}}" disabled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">New Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
|
||||
<input type="password" name="new_password" class="form-control" placeholder="New password" required autofocus minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
{{if .PasswordPolicy}}
|
||||
<small class="form-hint">
|
||||
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
|
||||
</small>
|
||||
{{else}}
|
||||
<small class="form-hint">Minimum 8 characters.</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Confirm Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
|
||||
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-user-check"></i> Activate Account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-secondary mt-3">
|
||||
© 2026 Keywarden | AGPLv3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tabler.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
|
||||
var token = m ? decodeURIComponent(m[1]) : '';
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = token;
|
||||
form.prepend(input);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
web/templates/keys.html
Normal file
124
web/templates/keys.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-key"></i> SSH Keys</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/keys/generate" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> Generate New Key
|
||||
</a>
|
||||
<a href="/keys/import" class="btn btn-outline-primary ms-2">
|
||||
<i class="ti ti-upload"></i> Import Key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{if or (eq .User.Role "admin") (eq .User.Role "owner")}}
|
||||
<th>Owner</th>
|
||||
{{end}}
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Bits</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||
{{/* Admin/Owner view: show all keys with owner info */}}
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-{{if eq .OwnerUsername $.User.Username}}green-lt{{else}}blue-lt{{end}}">{{.OwnerUsername}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-key me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{if eq .KeyType "ed25519"}}green-lt{{else}}azure-lt{{end}}">{{.KeyType}}</span>
|
||||
</td>
|
||||
<td>{{.Bits}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
|
||||
<i class="ti ti-eye"></i>
|
||||
</a>
|
||||
{{if eq .UserID $.User.ID}}
|
||||
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||
<i class="ti ti-download"></i>
|
||||
</a>
|
||||
{{end}}
|
||||
<form method="POST" action="/keys/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this key?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-key-off" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No SSH keys found. Generate or import one to get started.</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* User view: show only own keys */}}
|
||||
{{range .Keys}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-key me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{if eq .KeyType "ed25519"}}green-lt{{else}}azure-lt{{end}}">{{.KeyType}}</span>
|
||||
</td>
|
||||
<td>{{.Bits}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
|
||||
<i class="ti ti-eye"></i>
|
||||
</a>
|
||||
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||
<i class="ti ti-download"></i>
|
||||
</a>
|
||||
<form method="POST" action="/keys/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this key?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-key-off" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No SSH keys found. Generate or import one to get started.</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
95
web/templates/keys_generate.html
Normal file
95
web/templates/keys_generate.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-plus"></i> Generate New SSH Key</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/keys/generate" method="POST">
|
||||
{{if .Users}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Generate for User</label>
|
||||
<select name="target_user_id" class="form-select">
|
||||
{{$currentUser := .User}}
|
||||
{{range .Users}}
|
||||
<option value="{{.ID}}" {{if eq .ID $currentUser.ID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<small class="form-hint">As admin you can generate SSH keys for any user</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Key Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. production-server" required>
|
||||
<small class="form-hint">A friendly name to identify this key</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Key Comment</label>
|
||||
<input type="text" name="comment" class="form-control" placeholder="e.g. user@hostname">
|
||||
<small class="form-hint">Comment appended to the public key (visible in authorized_keys and with <code>ssh-keygen -l</code>)</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Key Type</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4 col-md-4 col-sm-12">
|
||||
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
|
||||
<input type="radio" name="key_type" value="ed25519" class="form-selectgroup-input" checked>
|
||||
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
|
||||
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield-check text-success"></i></span>
|
||||
<span class="fw-bold mb-1">Ed25519</span>
|
||||
<small class="text-secondary">Modern, fast, secure (recommended)</small>
|
||||
<span class="badge bg-success-lt text-success mt-2">Recommended</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 col-sm-12">
|
||||
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
|
||||
<input type="radio" name="key_type" value="ed448" class="form-selectgroup-input">
|
||||
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
|
||||
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield-lock text-azure"></i></span>
|
||||
<span class="fw-bold mb-1">Ed448</span>
|
||||
<small class="text-secondary">448-bit Edwards curve, higher security margin</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 col-sm-12">
|
||||
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
|
||||
<input type="radio" name="key_type" value="rsa" class="form-selectgroup-input">
|
||||
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
|
||||
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield text-orange"></i></span>
|
||||
<span class="fw-bold mb-1">RSA</span>
|
||||
<small class="text-secondary">Legacy, wide compatibility</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="rsa-bits-group" style="display:none;">
|
||||
<label class="form-label">RSA Key Size</label>
|
||||
<select name="bits" class="form-select">
|
||||
<option value="4096" selected>4096 bits (recommended)</option>
|
||||
<option value="2048">2048 bits (legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-key"></i> Generate Key
|
||||
</button>
|
||||
<a href="/keys" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('input[name="key_type"]').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
document.getElementById('rsa-bits-group').style.display =
|
||||
this.value === 'rsa' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
42
web/templates/keys_import.html
Normal file
42
web/templates/keys_import.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-upload"></i> Import SSH Key</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/keys/import" method="POST">
|
||||
{{if .Users}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Import for User</label>
|
||||
<select name="target_user_id" class="form-select">
|
||||
{{$currentUser := .User}}
|
||||
{{range .Users}}
|
||||
<option value="{{.ID}}" {{if eq .ID $currentUser.ID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<small class="form-hint">As admin you can import SSH keys for any user</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Key Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. My Server Key" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Private Key (PEM)</label>
|
||||
<textarea name="private_key" class="form-control" rows="10" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----" required style="font-family: monospace; font-size: 0.85rem;"></textarea>
|
||||
<small class="form-hint">Paste your private key in PEM format. The public key and fingerprint will be automatically extracted.</small>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-upload"></i> Import Key
|
||||
</button>
|
||||
<a href="/keys" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
669
web/templates/layout/base.html
Normal file
669
web/templates/layout/base.html
Normal file
@@ -0,0 +1,669 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
|
||||
<title>{{.Title}} - {{appName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<!-- Preload icon font to prevent re-decode lag on tab restore -->
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'auto';
|
||||
var resolved = theme;
|
||||
if (theme === 'auto') {
|
||||
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* Critical inline styles: prevent white flash between page navigations */
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #0F1829; color-scheme: dark; }
|
||||
html[data-bs-theme="light"],
|
||||
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
|
||||
.navbar-brand-image { height: 2rem; }
|
||||
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
|
||||
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
|
||||
/* Text selection colors */
|
||||
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
|
||||
/* Consistent spacing between Tabler icons and adjacent text */
|
||||
i.ti { margin-right: 0.25em; }
|
||||
.btn-icon > i.ti, .input-icon-addon > i.ti, .nav-link-icon > i.ti { margin-right: 0; }
|
||||
|
||||
/* ══ PAGE LAYOUT: full-width header on top, sidebar + content below ══ */
|
||||
.page {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.page-content-row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Full-width top header ── */
|
||||
header.navbar.keywarden-top-header {
|
||||
background: #1D2B38 !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
[data-bs-theme="light"] header.navbar.keywarden-top-header {
|
||||
background: #1D2B38 !important;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
header.navbar.keywarden-top-header .nav-link { color: #c8d6e5 !important; }
|
||||
header.navbar.keywarden-top-header .nav-link .text-secondary { color: #8fa8c8 !important; }
|
||||
header.navbar.keywarden-top-header .fw-bold { color: #e8eef5; }
|
||||
|
||||
/* ── Header brand area (left side, aligned with sidebar) ── */
|
||||
.keywarden-header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keywarden-header-brand .keywarden-brand { color: #c8d6e5; font-size: 1.15rem; }
|
||||
.keywarden-header-brand .keywarden-brand:hover { color: #fff; text-decoration: none; }
|
||||
.keywarden-header-brand .keywarden-brand i.ti { color: #4da3ff; font-size: 1.3rem; }
|
||||
|
||||
/* ── Angular header buttons ── */
|
||||
header.navbar.keywarden-top-header .btn-header-modern {
|
||||
color: #c8d6e5;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 2px solid rgba(255,255,255,0.18);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
header.navbar.keywarden-top-header .btn-header-modern:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.14);
|
||||
border-color: rgba(255,255,255,0.32);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
header.navbar.keywarden-top-header .btn-header-modern.btn-icon {
|
||||
width: 2.16875rem;
|
||||
height: 2.16875rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Sidebar (vertical, below header) ── */
|
||||
.navbar-vertical {
|
||||
background: #1D2B38 !important;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.navbar-vertical > .container-fluid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.navbar-vertical .navbar-collapse {
|
||||
flex: 1;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.navbar-vertical .navbar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-bs-theme="light"] .navbar-vertical { background: #1D2B38 !important; }
|
||||
|
||||
/* ── Page content area ── */
|
||||
.page-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .page-wrapper { background: #0F1829; }
|
||||
[data-bs-theme="dark"] .page-body { background: #0F1829; }
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
|
||||
.page-body { content-visibility: auto; contain-intrinsic-size: auto 500px; }
|
||||
|
||||
/* ── Narrower dashboard stat cards ── */
|
||||
.stat-card-narrow { max-width: 220px; }
|
||||
|
||||
/* ── Desktop: sidebar always visible ── */
|
||||
@media (min-width: 992px) {
|
||||
.navbar-vertical {
|
||||
width: 256px !important;
|
||||
min-width: 256px !important;
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
left: auto !important;
|
||||
}
|
||||
.navbar-vertical .container-fluid {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.navbar-vertical .nav-link-title {
|
||||
opacity: 1;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
width: auto;
|
||||
}
|
||||
.navbar-vertical .nav-link {
|
||||
justify-content: start;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.navbar-vertical .nav-link-icon {
|
||||
margin-right: 0.5rem;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.navbar-vertical .nav-category {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
padding: 0.6rem 0.75rem 0.25rem;
|
||||
}
|
||||
.navbar-vertical .nav-item + .nav-category,
|
||||
.navbar-vertical .nav-category + .nav-category {
|
||||
margin-top: 0.4rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
.navbar-vertical .nav-category:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
/* Hide mobile burger button on desktop */
|
||||
.mobile-menu-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
.page-wrapper {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile: off-canvas sidebar ── */
|
||||
@media (max-width: 991.98px) {
|
||||
aside.navbar-vertical {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px !important;
|
||||
max-width: 85vw;
|
||||
z-index: 1050;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
aside.navbar-vertical.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
aside.navbar-vertical .navbar-collapse {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
aside.navbar-vertical .container-fluid {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
aside.navbar-vertical .nav-link-title {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
width: auto;
|
||||
}
|
||||
aside.navbar-vertical .nav-link {
|
||||
justify-content: start;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
aside.navbar-vertical .nav-link-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
aside.navbar-vertical .nav-category {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
padding: 0.6rem 0.75rem 0.25rem;
|
||||
}
|
||||
/* Backdrop overlay */
|
||||
.mobile-sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1040;
|
||||
}
|
||||
.mobile-sidebar-backdrop.show {
|
||||
display: block;
|
||||
}
|
||||
/* Mobile burger button */
|
||||
.mobile-menu-toggle {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
color: #8fa8c8;
|
||||
border-radius: 6px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
padding: 0;
|
||||
}
|
||||
.mobile-menu-toggle:hover {
|
||||
background: rgba(255,255,255,0.16);
|
||||
color: #fff;
|
||||
}
|
||||
.mobile-menu-toggle .ti {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sidebar category headers ── */
|
||||
.nav-category {
|
||||
display: block;
|
||||
padding: 0.6rem 0.75rem 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(138, 166, 200, 0.55);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.nav-category:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
/* Show a subtle divider line above categories (except the first) */
|
||||
.nav-category + .nav-category,
|
||||
.nav-item + .nav-category {
|
||||
margin-top: 0.4rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
/* ── Avatar in dropdown ── */
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
|
||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
|
||||
</head>
|
||||
<body class="layout-fluid">
|
||||
<div class="page">
|
||||
<!-- ═══ FULL-WIDTH TOP HEADER ═══ -->
|
||||
<header class="navbar d-print-none keywarden-top-header" data-bs-theme="dark">
|
||||
<div class="container-fluid px-3">
|
||||
<div class="d-flex align-items-center w-100">
|
||||
<!-- Logo + Brand (left side, aligned with sidebar) -->
|
||||
<div class="keywarden-header-brand">
|
||||
<button class="mobile-menu-toggle d-lg-none" onclick="toggleMobileMenu()" title="Menü" id="mobile-menu-btn">
|
||||
<i class="ti ti-menu-2" id="mobile-menu-icon"></i>
|
||||
</button>
|
||||
<a href="/dashboard" class="keywarden-brand text-decoration-none d-flex align-items-center">
|
||||
<i class="ti ti-key"></i> {{appName}}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow-1"></div>
|
||||
<!-- Repository Link -->
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
||||
<i class="ti ti-brand-git"></i> Repository
|
||||
</a>
|
||||
</div>
|
||||
<!-- Documentation -->
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="https://git.techniverse.net/scriptos/keywarden/src/branch/master/docs" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Documentation">
|
||||
<i class="ti ti-book"></i> Docs
|
||||
</a>
|
||||
</div>
|
||||
<!-- Theme Toggle -->
|
||||
<div class="nav-item d-flex me-2">
|
||||
<button id="theme-toggle" class="btn btn-header-modern btn-sm btn-icon" title="Toggle theme" onclick="toggleTheme()">
|
||||
<i class="ti ti-sun" id="theme-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- User Badge -->
|
||||
{{with .User}}
|
||||
<div class="nav-item dropdown">
|
||||
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
|
||||
<span class="avatar avatar-sm rounded-circle bg-primary-lt">
|
||||
{{if .AvatarBase64}}
|
||||
<img src="/avatar/{{.ID}}" class="avatar-img" alt="Avatar">
|
||||
{{else}}
|
||||
<i class="ti ti-user" style="font-size: 1.1rem;"></i>
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="d-none d-xl-block ps-2">
|
||||
<div class="fw-bold">{{.Username}}</div>
|
||||
<div class="mt-1 small text-secondary">{{if eq .Role "owner"}}Owner{{else if eq .Role "admin"}}Administrator{{else}}User{{end}}</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
|
||||
<a class="dropdown-item text-danger" href="/logout">
|
||||
<i class="ti ti-logout"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ═══ CONTENT ROW: sidebar + main ═══ -->
|
||||
<div class="page-content-row">
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div class="mobile-sidebar-backdrop" id="mobile-sidebar-backdrop" onclick="closeMobileMenu()"></div>
|
||||
<!-- Sidebar -->
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark" id="keywarden-sidebar">
|
||||
<div class="container-fluid">
|
||||
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||
<ul class="navbar-nav pt-lg-3">
|
||||
<!-- ── Overview ── -->
|
||||
<li class="nav-category">Overview</li>
|
||||
<li class="nav-item{{if eq .Active "dashboard"}} active{{end}}">
|
||||
<a class="nav-link" href="/dashboard">
|
||||
<span class="nav-link-icon"><i class="ti ti-dashboard"></i></span>
|
||||
<span class="nav-link-title">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- ── Infrastructure (Admin/Owner only) ── -->
|
||||
{{with .User}}
|
||||
{{if or (eq .Role "admin") (eq .Role "owner")}}
|
||||
<li class="nav-category">Infrastructure</li>
|
||||
<li class="nav-item{{if eq $.Active "servers"}} active{{end}}">
|
||||
<a class="nav-link" href="/servers">
|
||||
<span class="nav-link-icon"><i class="ti ti-server"></i></span>
|
||||
<span class="nav-link-title">Hosts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item{{if eq $.Active "groups"}} active{{end}}">
|
||||
<a class="nav-link" href="/groups">
|
||||
<span class="nav-link-icon"><i class="ti ti-folders"></i></span>
|
||||
<span class="nav-link-title">Groups</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<!-- ── Key Management ── -->
|
||||
<li class="nav-category">Key Management</li>
|
||||
<li class="nav-item{{if eq .Active "keys"}} active{{end}}">
|
||||
<a class="nav-link" href="/keys">
|
||||
<span class="nav-link-icon"><i class="ti ti-key"></i></span>
|
||||
<span class="nav-link-title">SSH Keys</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- My Access (visible to all users) -->
|
||||
<li class="nav-item{{if eq .Active "my_access"}} active{{end}}">
|
||||
<a class="nav-link" href="/my/access">
|
||||
<span class="nav-link-icon"><i class="ti ti-shield-check"></i></span>
|
||||
<span class="nav-link-title">My Access</span>
|
||||
</a>
|
||||
</li>
|
||||
{{with .User}}
|
||||
{{if or (eq .Role "admin") (eq .Role "owner")}}
|
||||
<li class="nav-item{{if eq $.Active "deploy"}} active{{end}}">
|
||||
<a class="nav-link" href="/deploy">
|
||||
<span class="nav-link-icon"><i class="ti ti-send"></i></span>
|
||||
<span class="nav-link-title">Deploy Keys</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<!-- ── Operations (Admin/Owner only) ── -->
|
||||
{{with .User}}
|
||||
{{if or (eq .Role "admin") (eq .Role "owner")}}
|
||||
<li class="nav-category">Operations</li>
|
||||
<li class="nav-item{{if eq $.Active "cron"}} active{{end}}">
|
||||
<a class="nav-link" href="/cron">
|
||||
<span class="nav-link-icon"><i class="ti ti-clock"></i></span>
|
||||
<span class="nav-link-title">Temporary Access</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<li class="nav-item{{if eq .Active "audit"}} active{{end}}">
|
||||
<a class="nav-link" href="/audit">
|
||||
<span class="nav-link-icon"><i class="ti ti-list-details"></i></span>
|
||||
<span class="nav-link-title">Audit Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- ── Administration ── -->
|
||||
<li class="nav-category">Administration</li>
|
||||
{{with .User}}
|
||||
{{if or (eq .Role "admin") (eq .Role "owner")}}
|
||||
<li class="nav-item{{if eq $.Active "users"}} active{{end}}">
|
||||
<a class="nav-link" href="/users">
|
||||
<span class="nav-link-icon"><i class="ti ti-users"></i></span>
|
||||
<span class="nav-link-title">Users</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item{{if eq $.Active "assignments"}} active{{end}}">
|
||||
<a class="nav-link" href="/assignments">
|
||||
<span class="nav-link-icon"><i class="ti ti-shield-lock"></i></span>
|
||||
<span class="nav-link-title">Access Assignments</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<li class="nav-item{{if eq .Active "settings"}} active{{end}}">
|
||||
<a class="nav-link" href="/settings">
|
||||
<span class="nav-link-icon"><i class="ti ti-settings"></i></span>
|
||||
<span class="nav-link-title">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
{{with .User}}
|
||||
{{if eq .Role "owner"}}
|
||||
<li class="nav-item{{if eq $.Active "admin_settings"}} active{{end}}">
|
||||
<a class="nav-link" href="/admin/settings">
|
||||
<span class="nav-link-icon"><i class="ti ti-shield-cog"></i></span>
|
||||
<span class="nav-link-title">Admin Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<!-- ── System (Admin/Owner only) ── -->
|
||||
{{with .User}}
|
||||
{{if or (eq .Role "admin") (eq .Role "owner")}}
|
||||
<li class="nav-category">System</li>
|
||||
<li class="nav-item{{if eq $.Active "system_info"}} active{{end}}">
|
||||
<a class="nav-link" href="/system">
|
||||
<span class="nav-link-icon"><i class="ti ti-info-circle"></i></span>
|
||||
<span class="nav-link-title">System Information</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main content -->
|
||||
<div class="page-wrapper">
|
||||
<div class="page-header d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="page-pretitle">Keywarden – Centralized SSH Key Management and Deployment</div>
|
||||
<h2 class="page-title">{{.Title}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-{{.Flash.Type}} alert-dismissible" role="alert">
|
||||
<div class="d-flex">
|
||||
<div>
|
||||
{{if eq .Flash.Type "success"}}<i class="ti ti-check icon alert-icon"></i>{{end}}
|
||||
{{if eq .Flash.Type "danger"}}<i class="ti ti-alert-circle icon alert-icon"></i>{{end}}
|
||||
{{if eq .Flash.Type "warning"}}<i class="ti ti-alert-triangle icon alert-icon"></i>{{end}}
|
||||
</div>
|
||||
<div>{{.Flash.Message}}</div>
|
||||
</div>
|
||||
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer footer-transparent d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="row text-center align-items-center">
|
||||
<div class="col-12">
|
||||
<span class="text-secondary">© 2026 Keywarden – Centralized SSH Key Management and Deployment | AGPLv3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div><!-- /page-content-row -->
|
||||
</div><!-- /page -->
|
||||
<!-- Tabler JS (self-hosted) -->
|
||||
<script src="/static/js/tabler.min.js"></script>
|
||||
<script>
|
||||
// --- Theme Toggle ---
|
||||
function getResolvedTheme() {
|
||||
var stored = document.documentElement.getAttribute('data-bs-theme');
|
||||
return stored || 'light';
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
updateThemeIcon(theme);
|
||||
}
|
||||
function updateThemeIcon(theme) {
|
||||
var icon = document.getElementById('theme-icon');
|
||||
if (!icon) return;
|
||||
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
|
||||
}
|
||||
function toggleTheme() {
|
||||
var current = getResolvedTheme();
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
// Persist choice via API (fire-and-forget)
|
||||
var csrf = (document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/) || [])[1] || '';
|
||||
fetch('/settings/theme', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'theme=' + encodeURIComponent(next) + '&_csrf=' + encodeURIComponent(csrf)
|
||||
});
|
||||
}
|
||||
// Set initial icon on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateThemeIcon(getResolvedTheme());
|
||||
});
|
||||
|
||||
// --- Mobile Sidebar Toggle ---
|
||||
function toggleMobileMenu() {
|
||||
var sidebar = document.getElementById('keywarden-sidebar');
|
||||
var backdrop = document.getElementById('mobile-sidebar-backdrop');
|
||||
var icon = document.getElementById('mobile-menu-icon');
|
||||
if (!sidebar) return;
|
||||
var isOpen = sidebar.classList.toggle('mobile-open');
|
||||
if (backdrop) backdrop.classList.toggle('show', isOpen);
|
||||
if (icon) icon.className = isOpen ? 'ti ti-x' : 'ti ti-menu-2';
|
||||
}
|
||||
function closeMobileMenu() {
|
||||
var sidebar = document.getElementById('keywarden-sidebar');
|
||||
var backdrop = document.getElementById('mobile-sidebar-backdrop');
|
||||
var icon = document.getElementById('mobile-menu-icon');
|
||||
if (sidebar) sidebar.classList.remove('mobile-open');
|
||||
if (backdrop) backdrop.classList.remove('show');
|
||||
if (icon) icon.className = 'ti ti-menu-2';
|
||||
}
|
||||
|
||||
function copyToClipboard(elementId, btn) {
|
||||
var el = document.getElementById(elementId);
|
||||
var text = el.value || el.textContent;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showCopyFeedback(btn);
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-HTTPS: use temporary textarea (password inputs block select/copy)
|
||||
var tmp = document.createElement('textarea');
|
||||
tmp.value = text;
|
||||
tmp.style.position = 'fixed';
|
||||
tmp.style.opacity = '0';
|
||||
document.body.appendChild(tmp);
|
||||
tmp.focus();
|
||||
tmp.select();
|
||||
tmp.setSelectionRange(0, 99999);
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(tmp);
|
||||
showCopyFeedback(btn);
|
||||
}
|
||||
}
|
||||
function showCopyFeedback(btn) {
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = orig;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// --- CSRF Protection ---
|
||||
// Reads the _csrf cookie and injects a hidden field into every POST form.
|
||||
// Also provides a helper for fetch/AJAX calls.
|
||||
(function() {
|
||||
function getCsrfToken() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
// Inject hidden _csrf field into all POST forms
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = getCsrfToken();
|
||||
form.prepend(input);
|
||||
}
|
||||
});
|
||||
// Expose globally for fetch/AJAX calls
|
||||
window._csrfToken = getCsrfToken;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
129
web/templates/login.html
Normal file
129
web/templates/login.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<title>Login - {{appName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<!-- Preload icon font to prevent re-decode lag on tab restore -->
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
||||
<script>
|
||||
(function() {
|
||||
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* Critical inline styles: prevent white flash between page navigations */
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
|
||||
html[data-bs-theme="light"],
|
||||
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
|
||||
/* Text selection colors */
|
||||
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
</style>
|
||||
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
|
||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
|
||||
</head>
|
||||
<body class="d-flex flex-column">
|
||||
<div class="page page-center">
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4">
|
||||
<h1><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
{{if .MFAPending}}
|
||||
<!-- MFA Verification Step -->
|
||||
<h2 class="h2 text-center mb-4">
|
||||
<i class="ti ti-shield-lock"></i> MFA Verification
|
||||
</h2>
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger">
|
||||
<i class="ti ti-alert-circle"></i> {{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="text-secondary text-center mb-3">Enter the 6-digit code from your authenticator app.</p>
|
||||
<form action="/login/mfa" method="post" autocomplete="off">
|
||||
<input type="hidden" name="mfa_token" value="{{.MFAToken}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">MFA Code</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
|
||||
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
|
||||
required pattern="[0-9]{6}" maxlength="6" autofocus
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-shield-check"></i> Verify
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<a href="/login" class="text-secondary"><i class="ti ti-arrow-left"></i> Back to login</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Normal Login -->
|
||||
<h2 class="h2 text-center mb-4">Login</h2>
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger">
|
||||
<i class="ti ti-alert-circle"></i> {{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
<form action="/login" method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
|
||||
<input type="text" name="username" class="form-control" placeholder="Username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
|
||||
<input type="password" name="password" class="form-control" placeholder="Password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-login"></i> Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-secondary mt-3">
|
||||
© 2026 Keywarden | AGPLv3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tabler.min.js"></script>
|
||||
<script>
|
||||
// --- CSRF Protection ---
|
||||
(function() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
|
||||
var token = m ? decodeURIComponent(m[1]) : '';
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = token;
|
||||
form.prepend(input);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
133
web/templates/mfa_required.html
Normal file
133
web/templates/mfa_required.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<title>MFA Setup Required - {{appName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script>
|
||||
(function() {
|
||||
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
|
||||
html[data-bs-theme="light"],
|
||||
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
|
||||
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
|
||||
</head>
|
||||
<body class="d-flex flex-column">
|
||||
<div class="page page-center">
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4">
|
||||
<h1><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
<h2 class="h2 text-center mb-4">
|
||||
<i class="ti ti-shield-check"></i> MFA Setup Required
|
||||
</h2>
|
||||
|
||||
<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">Two-Factor Authentication is required</h4>
|
||||
<div class="text-secondary">Your administrator requires all users to set up two-factor authentication. Please configure MFA to continue.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-{{.Flash.Type}}">
|
||||
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="mb-4">
|
||||
<h4>Step 1: Scan QR Code</h4>
|
||||
<p class="text-secondary">
|
||||
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<div class="text-center my-4">
|
||||
<div id="qrcode" class="d-inline-block p-3 bg-white border rounded"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-secondary mb-1">Or enter this secret manually:</p>
|
||||
<code class="fs-4 user-select-all">{{.MFASecret}}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div>
|
||||
<h4>Step 2: Verify Code</h4>
|
||||
<p class="text-secondary">
|
||||
Enter the 6-digit code from your authenticator app to confirm setup.
|
||||
</p>
|
||||
<form action="/mfa/setup" method="post" autocomplete="off">
|
||||
<input type="hidden" name="mfa_secret" value="{{.MFASecret}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Verification Code</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
|
||||
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
|
||||
required pattern="[0-9]{6}" maxlength="6" autocomplete="off" autofocus
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="ti ti-shield-check"></i> Enable MFA
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
new QRCode(document.getElementById("qrcode"), {
|
||||
text: "{{.MFAUri}}",
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
|
||||
// --- CSRF Protection ---
|
||||
(function() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
|
||||
var token = m ? decodeURIComponent(m[1]) : '';
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = token;
|
||||
form.prepend(input);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
web/templates/mfa_setup.html
Normal file
81
web/templates/mfa_setup.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-shield-check"></i> Setup Two-Factor Authentication</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .MFARequired}}
|
||||
<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">MFA is required</h4>
|
||||
<div class="text-secondary">Your administrator requires all users to set up two-factor authentication. Please configure MFA to continue using the application.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-4">
|
||||
<h4>Step 1: Scan QR Code</h4>
|
||||
<p class="text-secondary">
|
||||
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<div class="text-center my-4">
|
||||
<div id="qrcode" class="d-inline-block p-3 bg-white border rounded"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-secondary mb-1">Or enter this secret manually:</p>
|
||||
<code class="fs-4 user-select-all">{{.MFASecret}}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div>
|
||||
<h4>Step 2: Verify Code</h4>
|
||||
<p class="text-secondary">
|
||||
Enter the 6-digit code from your authenticator app to confirm setup.
|
||||
</p>
|
||||
<form action="/settings/mfa/setup" method="post">
|
||||
<input type="hidden" name="mfa_secret" value="{{.MFASecret}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Verification Code</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
|
||||
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
|
||||
required pattern="[0-9]{6}" maxlength="6" autocomplete="off" autofocus
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
{{if not .MFARequired}}
|
||||
<a href="/settings" class="btn btn-outline-secondary me-2">
|
||||
<i class="ti ti-arrow-left"></i> Cancel
|
||||
</a>
|
||||
{{end}}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-shield-check"></i> Enable MFA
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Generation via JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
new QRCode(document.getElementById("qrcode"), {
|
||||
text: "{{.MFAUri}}",
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
63
web/templates/server_groups.html
Normal file
63
web/templates/server_groups.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-folders"></i> Groups</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/groups/add" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> Create Group
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Servers</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Groups}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-folders me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{.Description}}</td>
|
||||
<td>
|
||||
<span class="badge bg-blue-lt">{{.ServerCount}} server{{if ne .ServerCount 1}}s{{end}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<a href="/groups/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit / Manage Servers">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/groups/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this group? The hosts themselves will not be deleted.')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete Group">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-folders-off" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No groups yet. Create one to organize your hosts.</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
29
web/templates/server_groups_add.html
Normal file
29
web/templates/server_groups_add.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-plus"></i> Create Group</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/groups/add" method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Group Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Production Servers" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Optional description..."></textarea>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-folders"></i> Create Group
|
||||
</button>
|
||||
<a href="/groups" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
110
web/templates/server_groups_edit.html
Normal file
110
web/templates/server_groups_edit.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<!-- Edit Group Info -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Group</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{$group := .Group}}
|
||||
<form action="/groups/{{$group.ID}}/edit" method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Group Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{$group.Name}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2">{{$group.Description}}</textarea>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Changes
|
||||
</button>
|
||||
<a href="/groups" class="btn btn-outline-secondary ms-2">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Server to Group -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-server"></i> Add Host to Group</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/groups/{{$group.ID}}/add-server" method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Select Host</label>
|
||||
<select name="server_id" class="form-select" required>
|
||||
<option value="">Choose a host...</option>
|
||||
{{range .AllServers}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="ti ti-plus"></i> Add Host
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Members -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-list"></i> Hosts in this Group</h3>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>User</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .GroupServers}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-server me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{.Hostname}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/groups/{{$group.ID}}/remove-server" class="d-inline" onsubmit="return confirm('Remove this host from the group?')">
|
||||
<input type="hidden" name="server_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove from Group">
|
||||
<i class="ti ti-x"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-server-off" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No hosts in this group yet. Add hosts above.</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
126
web/templates/servers.html
Normal file
126
web/templates/servers.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-server"></i> Hosts</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/servers/add" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> Add Host
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>User</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Servers}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="ti ti-server me-2 text-primary"></i>
|
||||
<strong>{{.Name}}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{.Hostname}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Description}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
<button class="btn btn-sm btn-icon btn-outline-info test-reach-btn" title="Test Reachability (TCP)" data-server-id="{{.ID}}">
|
||||
<i class="ti ti-plug"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-icon btn-outline-success test-auth-btn" title="Test SSH Login" data-server-id="{{.ID}}">
|
||||
<i class="ti ti-key"></i>
|
||||
</button>
|
||||
<a href="/servers/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/servers/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this host?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">
|
||||
<i class="ti ti-server-off" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No hosts configured. Add one to start deploying keys.</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function testServer(btn, url, origClass, origTitle) {
|
||||
var serverId = btn.getAttribute('data-server-id');
|
||||
var origHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="ti ti-loader ti-spin"></i>';
|
||||
btn.disabled = true;
|
||||
|
||||
var form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
// Add CSRF token
|
||||
var csrfMatch = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
|
||||
if (csrfMatch) form.append('_csrf', decodeURIComponent(csrfMatch[1]));
|
||||
|
||||
fetch(url, { method: 'POST', body: form })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
btn.classList.remove(origClass);
|
||||
btn.classList.add('btn-success');
|
||||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||||
} else {
|
||||
btn.classList.remove(origClass);
|
||||
btn.classList.add('btn-outline-danger');
|
||||
btn.innerHTML = '<i class="ti ti-x"></i>';
|
||||
}
|
||||
btn.title = data.message;
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = origHTML;
|
||||
btn.className = btn.className.replace(/btn-success|btn-outline-danger|btn-info/g, '');
|
||||
btn.classList.add('btn', 'btn-sm', 'btn-icon', origClass);
|
||||
btn.disabled = false;
|
||||
btn.title = origTitle;
|
||||
}, 4000);
|
||||
})
|
||||
.catch(function() {
|
||||
btn.innerHTML = origHTML;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Reachability test (TCP port check)
|
||||
document.querySelectorAll('.test-reach-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
testServer(this, '/servers/test', 'btn-outline-info', 'Test Reachability (TCP)');
|
||||
});
|
||||
});
|
||||
|
||||
// SSH Auth test (actual SSH login with first key)
|
||||
document.querySelectorAll('.test-auth-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
testServer(this, '/servers/test-auth', 'btn-outline-success', 'Test SSH Login');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
66
web/templates/servers_add.html
Normal file
66
web/templates/servers_add.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-plus"></i> Add Host</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/servers/add" method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Server Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Web Server 01" required>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label required">Hostname / IP</label>
|
||||
<input type="text" name="hostname" class="form-control" placeholder="e.g. 192.168.1.100 or server.example.com" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="22" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">SSH Username</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="e.g. root" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Optional description..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Groups</label>
|
||||
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
|
||||
{{range .Data}}
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="checkbox" name="group_ids" value="{{.ID}}" class="form-selectgroup-input">
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3">
|
||||
<span class="form-selectgroup-check"></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .Description}}<br><small class="text-secondary">{{.Description}}</small>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{{else}}
|
||||
<div class="text-secondary">
|
||||
<small>No groups available. <a href="/groups/add">Create a group</a> first.</small>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-server"></i> Add Host
|
||||
</button>
|
||||
<a href="/servers" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
69
web/templates/servers_edit.html
Normal file
69
web/templates/servers_edit.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Host</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{$server := .Server}}
|
||||
<form action="/servers/{{$server.ID}}/edit" method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Server Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{$server.Name}}" required>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label required">Hostname / IP</label>
|
||||
<input type="text" name="hostname" class="form-control" value="{{$server.Hostname}}" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="{{$server.Port}}" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">SSH Username</label>
|
||||
<input type="text" name="username" class="form-control" value="{{$server.Username}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2">{{$server.Description}}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Groups</label>
|
||||
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
|
||||
{{range .Data}}
|
||||
<label class="form-selectgroup-item flex-fill">
|
||||
<input type="checkbox" name="group_ids" value="{{.ID}}" class="form-selectgroup-input"
|
||||
{{if .Selected}}checked{{end}}
|
||||
>
|
||||
<div class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<div class="me-3">
|
||||
<span class="form-selectgroup-check"></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .Description}}<br><small class="text-secondary">{{.Description}}</small>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{{else}}
|
||||
<div class="text-secondary">
|
||||
<small>No groups available. <a href="/groups/add">Create a group</a> first.</small>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Changes
|
||||
</button>
|
||||
<a href="/servers" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
215
web/templates/settings.html
Normal file
215
web/templates/settings.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<!-- Theme Settings -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-palette"></i> Appearance</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/settings/theme" method="post">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Theme</label>
|
||||
<select name="theme" class="form-select" style="width: 250px;">
|
||||
<option value="auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto")}}selected{{end}}>Automatic (System)</option>
|
||||
<option value="light" {{if and .User (eq .User.Theme "light")}}selected{{end}}>Light</option>
|
||||
<option value="dark" {{if and .User (eq .User.Theme "dark")}}selected{{end}}>Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Picture -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-camera"></i> Profile Picture</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="avatar avatar-xl rounded-circle bg-primary-lt" id="avatar-preview-container">
|
||||
{{with .User}}
|
||||
{{if .AvatarBase64}}
|
||||
<img src="/avatar/{{.ID}}" id="avatar-preview" style="width:100%;height:100%;object-fit:cover;border-radius:50%;" alt="Avatar">
|
||||
{{else}}
|
||||
<i class="ti ti-user" style="font-size: 2.5rem;" id="avatar-placeholder"></i>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<form action="/settings/avatar" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-2">
|
||||
<input type="file" name="avatar" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" onchange="previewAvatar(this)">
|
||||
<small class="form-hint">Max. 2 MB. PNG, JPG, GIF or WebP.</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="ti ti-upload"></i> Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{with .User}}
|
||||
{{if .AvatarBase64}}
|
||||
<form action="/settings/avatar" method="post" class="mt-2">
|
||||
<input type="hidden" name="remove_avatar" value="1">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="ti ti-trash"></i> Remove Picture
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Settings -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-lock"></i> Change Password</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/settings" method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Current Password</label>
|
||||
<input type="password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
{{if .PasswordPolicy}}
|
||||
<div class="mb-3">
|
||||
<small class="form-hint">
|
||||
Password requirements: min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
|
||||
</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-lock"></i> Change Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MFA Settings -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-shield-check"></i> Two-Factor Authentication (MFA)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{with .User}}
|
||||
{{if .MFAEnabled}}
|
||||
<div class="alert alert-success">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-shield-check icon alert-icon"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">MFA is enabled</h4>
|
||||
<div class="text-secondary">Your account is protected with two-factor authentication.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if not $.MFARequired}}
|
||||
<form action="/settings/mfa/disable" method="post" onsubmit="return confirm('Are you sure you want to disable MFA? This will reduce your account security.')">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="ti ti-shield-off"></i> Disable MFA
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="text-secondary">
|
||||
<i class="ti ti-info-circle"></i> MFA is enforced by your administrator and cannot be disabled.
|
||||
</div>
|
||||
{{end}}
|
||||
{{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">MFA is not enabled</h4>
|
||||
<div class="text-secondary">Add an extra layer of security to your account.{{if $.MFARequired}} <strong>MFA is required by your administrator.</strong>{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/settings/mfa/setup" class="btn btn-primary">
|
||||
<i class="ti ti-shield-check"></i> Enable MFA
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Notifications -->
|
||||
{{if .EmailEnabled}}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-mail"></i> Email Notifications</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{with .User}}
|
||||
<p class="text-secondary mb-3">
|
||||
Receive email notifications for certain events. Notifications are sent to <strong>{{.Email}}</strong>.
|
||||
</p>
|
||||
<form action="/settings/email/notify" method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input type="hidden" name="email_notify_login" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="email_notify_login" value="1" {{if .EmailNotifyLogin}}checked{{end}} onchange="this.form.submit()">
|
||||
<span class="form-check-label">Login notification</span>
|
||||
<span class="form-check-description">Send an email every time someone logs into your account.</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function previewAvatar(input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
var container = document.getElementById('avatar-preview-container');
|
||||
var existing = document.getElementById('avatar-preview');
|
||||
var placeholder = document.getElementById('avatar-placeholder');
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
if (existing) {
|
||||
existing.src = e.target.result;
|
||||
} else {
|
||||
var img = document.createElement('img');
|
||||
img.id = 'avatar-preview';
|
||||
img.src = e.target.result;
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%;';
|
||||
img.alt = 'Avatar';
|
||||
container.appendChild(img);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
76
web/templates/system_info.html
Normal file
76
web/templates/system_info.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<!-- System Information -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-info-circle"></i> System Information</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{with .SystemInfo}}
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Runtime Environment</div>
|
||||
<div class="datagrid-content">
|
||||
{{if eq .Runtime "Docker"}}
|
||||
<span class="badge bg-blue-lt"><i class="ti ti-brand-docker"></i> Docker</span>
|
||||
{{else}}
|
||||
<span class="badge bg-cyan-lt">Native</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Architecture</div>
|
||||
<div class="datagrid-content"><span class="badge bg-purple-lt">{{.Arch}}</span></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Operating System</div>
|
||||
<div class="datagrid-content">{{.OS}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Hostname</div>
|
||||
<div class="datagrid-content"><code>{{.Hostname}}</code></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Memory (Allocated)</div>
|
||||
<div class="datagrid-content">{{.MemAlloc}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Memory (System)</div>
|
||||
<div class="datagrid-content">{{.MemSys}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">CPU Cores</div>
|
||||
<div class="datagrid-content">{{.NumCPU}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Goroutines</div>
|
||||
<div class="datagrid-content">{{.NumGoroutine}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Go Version</div>
|
||||
<div class="datagrid-content">{{.GoVersion}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Uptime</div>
|
||||
<div class="datagrid-content">{{.Uptime}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Encryption</div>
|
||||
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">License</div>
|
||||
<div class="datagrid-content">AGPLv3</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Repository</div>
|
||||
<div class="datagrid-content"><a href="https://git.techniverse.net/scriptos/keywarden" target="_blank">git.techniverse.net/scriptos/keywarden</a></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
100
web/templates/users.html
Normal file
100
web/templates/users.html
Normal file
@@ -0,0 +1,100 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-users"></i> User Management</h3>
|
||||
<div class="card-actions">
|
||||
<a href="/users/add" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i> Add User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>MFA</th>
|
||||
<th>Last Login</th>
|
||||
<th>Created</th>
|
||||
<th class="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>
|
||||
<i class="ti ti-user"></i> {{.Username}}
|
||||
</td>
|
||||
<td class="text-secondary">{{.Email}}</td>
|
||||
<td>
|
||||
{{if eq .Role "owner"}}
|
||||
<span class="badge bg-purple-lt">Owner</span>
|
||||
{{else if eq .Role "admin"}}
|
||||
<span class="badge bg-red-lt">Admin</span>
|
||||
{{else}}
|
||||
<span class="badge bg-blue-lt">User</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .LockedUntil}}
|
||||
<span class="badge bg-danger-lt"><i class="ti ti-lock"></i> Locked</span>
|
||||
{{else if .MustChangePassword}}
|
||||
<span class="badge bg-warning-lt"><i class="ti ti-alert-triangle"></i> Password Change</span>
|
||||
{{else}}
|
||||
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Active</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .MFAEnabled}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary-lt"><i class="ti ti-shield-off"></i> Disabled</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary">
|
||||
{{if .LastLoginAt}}
|
||||
{{.LastLoginAt.Format "2006-01-02 15:04"}}
|
||||
{{else}}
|
||||
<span class="text-muted">Never</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<div class="btn-list flex-nowrap">
|
||||
{{if .LockedUntil}}
|
||||
<form method="POST" action="/users/{{.ID}}/unlock" class="d-inline" title="Unlock Account">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-warning">
|
||||
<i class="ti ti-lock-open"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-secondary">No users found.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
105
web/templates/users_add.html
Normal file
105
web/templates/users_add.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-user-plus"></i> Add New User</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/users/add" method="post" autocomplete="off" id="addUserForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Username</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
|
||||
<input type="text" name="username" class="form-control" placeholder="Username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Email</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
|
||||
<input type="email" name="email" class="form-control" placeholder="user@example.com" required>
|
||||
</div>
|
||||
</div>
|
||||
{{if .EmailEnabled}}
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="send_invitation" value="1" id="sendInvitation">
|
||||
<span class="form-check-label">Send invitation email</span>
|
||||
<span class="form-check-description">The user will receive an email with a link to set their password and complete registration. No manual password required.</span>
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3" id="passwordField">
|
||||
<label class="form-label required">Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
|
||||
<input type="password" name="password" class="form-control" placeholder="Password" id="passwordInput" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
{{if .PasswordPolicy}}
|
||||
<small class="form-hint">
|
||||
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
|
||||
</small>
|
||||
{{else}}
|
||||
<small class="form-hint">Minimum 8 characters.</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="user" selected>User</option>
|
||||
{{with $.User}}
|
||||
{{if eq .Role "owner"}}
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3" id="mustChangeField">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="must_change_password" value="1" checked>
|
||||
<span class="form-check-label">Initial password</span>
|
||||
<span class="form-check-description">User must change their password on next login.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<a href="/users" class="btn btn-outline-secondary me-2">
|
||||
<i class="ti ti-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-user-plus"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var cb = document.getElementById('sendInvitation');
|
||||
if (!cb) return;
|
||||
|
||||
var pwField = document.getElementById('passwordField');
|
||||
var pwInput = document.getElementById('passwordInput');
|
||||
var mustChangeField = document.getElementById('mustChangeField');
|
||||
|
||||
function toggle() {
|
||||
if (cb.checked) {
|
||||
pwField.style.display = 'none';
|
||||
pwInput.removeAttribute('required');
|
||||
pwInput.value = '';
|
||||
mustChangeField.style.display = 'none';
|
||||
} else {
|
||||
pwField.style.display = '';
|
||||
pwInput.setAttribute('required', 'required');
|
||||
mustChangeField.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
cb.addEventListener('change', toggle);
|
||||
toggle();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
104
web/templates/users_edit.html
Normal file
104
web/templates/users_edit.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-user-edit"></i> Edit User</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .EditUser}}
|
||||
<form action="/users/{{.EditUser.ID}}/edit" method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Username</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
|
||||
<input type="text" name="username" class="form-control" value="{{.EditUser.Username}}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Email</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
|
||||
<input type="email" name="email" class="form-control" value="{{.EditUser.Email}}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<div class="input-icon">
|
||||
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
|
||||
<input type="password" name="password" class="form-control" placeholder="Leave empty to keep current" minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
|
||||
</div>
|
||||
{{if .PasswordPolicy}}
|
||||
<small class="form-hint">
|
||||
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}. Leave empty to keep current.
|
||||
</small>
|
||||
{{else}}
|
||||
<small class="form-hint">Leave empty to keep the current password.</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
|
||||
{{with $.User}}
|
||||
{{if eq .Role "owner"}}
|
||||
<option value="admin" {{if eq $.EditUser.Role "admin"}}selected{{end}}>Admin</option>
|
||||
<option value="owner" {{if eq $.EditUser.Role "owner"}}selected{{end}}>Owner</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">MFA Status</label>
|
||||
<div>
|
||||
{{if .EditUser.MFAEnabled}}
|
||||
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary-lt"><i class="ti ti-shield-off"></i> MFA Disabled</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="must_change_password" value="1" {{if .EditUser.MustChangePassword}}checked{{end}}>
|
||||
<span class="form-check-label">Force password change</span>
|
||||
<span class="form-check-description">User must change their password on next login.</span>
|
||||
</label>
|
||||
</div>
|
||||
{{if .EditUser.LockedUntil}}
|
||||
<div class="mb-3">
|
||||
<div class="alert alert-danger mb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
|
||||
<div class="flex-fill">
|
||||
<strong>Account locked</strong> until {{.EditUser.LockedUntil.Format "2006-01-02 15:04"}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="form-footer">
|
||||
<a href="/users" class="btn btn-outline-secondary me-2">
|
||||
<i class="ti ti-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .EditUser.LockedUntil}}
|
||||
<hr>
|
||||
<form method="POST" action="/users/{{.EditUser.ID}}/unlock" onsubmit="return confirm('Unlock this account?')">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="ti ti-lock-open"></i> Unlock Account
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-secondary">User not found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user