feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf

- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:47:37 -04:00
parent 5ba2ace835
commit ecd21c4984
11 changed files with 325 additions and 111 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.17.1",
"version": "1.17.2",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+11 -1
View File
@@ -66,7 +66,17 @@ module.exports = {
});
scan.on('error', function(error) {
reject(error);
// node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
}
});
scan.startScan();
+3 -1
View File
@@ -21,6 +21,7 @@ const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
@@ -35,7 +36,8 @@ router.get('/', async (req, res) => {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {}
oauth: conf.oauth || {},
voipms: conf.voipms || {}
});
res.json(editable);
});
+33 -2
View File
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => {
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' });
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
// Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret +
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
pluginType,
category: manifest.category,
name,
slug,
slug: finalSlug,
enabled,
cron: cron || '0 * * * *',
config,
+13 -3
View File
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
return res;
}
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
// a list grant on a directory path) propagate on the next vault-page visit
// without an operator re-running setup.sh. Skipping on an existing policy
// would strand the old, narrower HCL forever.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) return;
if (existing.status !== 404) {
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
}
@@ -80,7 +83,11 @@ async function mintToken(policies) {
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
// contents of the namespace: `.../*` covers nested paths but NOT the
// directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
}
@@ -109,7 +116,10 @@ async function getOrCreateAdminToken(uid) {
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
}
+111 -4
View File
@@ -4,6 +4,7 @@
$(document).ready(function() {
loadConf();
loadTos();
});
async function loadConf() {
@@ -28,6 +29,13 @@
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
}
}
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
}
@@ -53,6 +61,11 @@
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
@@ -74,6 +87,50 @@
el.type = 'password';
}
}
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
</script>
<div class="container py-4">
@@ -82,10 +139,10 @@
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings and OAuth parameters.
These are stored securely in OpenBao and take effect immediately. Secret fields
(the SMTP password and OAuth JWT secret) are masked — leave them unchanged to
keep the stored value.
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p>
</div>
<div>
@@ -164,6 +221,56 @@
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
</div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3">
<label class="form-label">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="mb-3">
<label class="form-label">DID (sender number)</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
<div class="mb-3">
<label class="form-label">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control" id="tos-content" rows="8"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
-64
View File
@@ -162,50 +162,10 @@
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
loadMetrics();
});
</script>
@@ -385,30 +345,6 @@
</div>
</div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
+63 -13
View File
@@ -128,12 +128,17 @@
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
function configFormHtml(type, prefix, values) {
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {};
var html = '';
schema.forEach(function(f) {
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
@@ -149,6 +154,53 @@
return html;
}
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
@@ -179,10 +231,9 @@
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Slug <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('np-', '0 * * * *') +
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
});
@@ -197,13 +248,11 @@
var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim();
var slug = document.getElementById('np-slug').value.trim();
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
var cron = cronFromForm('np-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-');
try {
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config });
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins();
@@ -224,9 +273,10 @@
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</div>',
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
@@ -238,7 +288,7 @@
var p = pluginsById[id];
if (!p) return;
var name = document.getElementById('ed-name').value.trim();
var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *';
var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-');
try {
+23 -22
View File
@@ -9,6 +9,7 @@
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid);
$('#profile-uid-header').text(user.uid);
$.scope.user.update(user);
};
@@ -241,7 +242,7 @@
<div class="card-header shadow d-flex justify-content-between align-items-center">
<div>
<i class="fa-regular fa-id-card"></i>
Profile: <strong>{{user.uid}}</strong>
Profile: <strong id="profile-uid-header"></strong>
</div>
<div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
@@ -278,7 +279,7 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
</button>
</li>
</ul>
@@ -329,27 +330,27 @@
<p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p>
</div>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
</div>
</div>
</div>
</div>