Release: v0.1.0-alpha
Some checks failed
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s

This commit is contained in:
2026-04-05 16:56:16 +02:00
parent 23ff731579
commit fd13e67aef
89 changed files with 18786 additions and 0 deletions

13
web/embed.go Normal file
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

File diff suppressed because one or more lines are too long

9
web/static/favicon.svg Normal file
View 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

File diff suppressed because one or more lines are too long

View 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}}

View 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}}

View 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}}

View 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
View 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
View 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
View 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}}

View 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}}

View 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
View 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}}

View 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}}

View 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">
&copy; 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
View 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}}

View 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}}

View 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-----&#10;...&#10;-----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}}

View 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">&copy; 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
View 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">
&copy; 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>

View 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>

View 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}}

View 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}}

View 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}}

View 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
View 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}}

View 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}}

View 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
View 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}}

View 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
View 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}}

View 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}}

View 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}}