feat: settings UI renders per-user edit rights and owner controls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
This commit is contained in:
2026-07-09 16:52:44 -04:00
co-authored by Claude Fable 5
parent ddb1b2b875
commit ca2868a888
+38 -13
View File
@@ -572,11 +572,12 @@
<option value="CRITICAL">CRITICAL only</option> <option value="CRITICAL">CRITICAL only</option>
</select> </select>
</div> </div>
{% if not current_user or current_user.admin %}
<div class="ch-form-row"> <div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"> <label>Owner <span style="font-weight:normal;color:#888">(empty = global)</span></label>
<input type="checkbox" id="ch-private"> Private — visible only to you <input type="text" id="ch-owner" placeholder="(global)" autocomplete="off">
</label>
</div> </div>
{% endif %}
<div id="ch-modal-status" class="ch-status"></div> <div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer"> <div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button> <button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
@@ -594,8 +595,10 @@
{% for section in sections %} {% for section in sections %}
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a> <a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
{% endfor %} {% endfor %}
{% if not current_user or current_user.admin %}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;"> <hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a> <a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
{% endif %}
</div> </div>
</nav> </nav>
@@ -709,12 +712,18 @@
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td> <td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
{% if h.is_owner %}
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td> <td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td> <td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td> <td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
{% else %}
<td><span class="val-tag">{{ h.owner | e }}</span></td>
<td>{% for m in h.managers %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
<td>{% for m in h.monitors %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
{% endif %}
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td> <td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td> <td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td> <td>{% if h.is_owner %}<button class="btn-danger" onclick="toggleDeleteRow(this)"></button>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -748,12 +757,13 @@
<span class="channel-name-text">{{ ch.name | e }}</span> <span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span> <span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %} {% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %} {% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
{% if ch.editable %}
<span class="channel-header-actions"> <span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button> <button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button> <button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span> </span>
{% endif %}
</div> </div>
<div class="channel-fields"> <div class="channel-fields">
{% for f in ch.fields %} {% for f in ch.fields %}
@@ -790,13 +800,20 @@
{% endfor %} {% endfor %}
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0"> <div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
{% for tc in section.threshold_configs %} {% for tc in section.threshold_configs %}
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"> <div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"{% if not tc.editable %} data-readonly="true"{% endif %}>
<div class="thresh-cfg-header"> <div class="thresh-cfg-header">
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span> <span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
{% if tc.name != 'default' %} {% if (not current_user or current_user.admin) and tc.name != 'default' %}
<input type="text" class="field-input thresh-owner" value="{{ tc.owner or '' }}"
placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">
{% elif tc.owner %}
<span class="ch-owner-badge" style="margin-left:10px">{{ tc.owner | e }}</span>
{% endif %}
{% if tc.editable and tc.name != 'default' %}
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button> <button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
{% endif %} {% endif %}
</div> </div>
<fieldset {% if not tc.editable %}disabled{% endif %} style="border:none;margin:0;padding:0;min-width:0">
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<table class="crud-table thresh-metric-table"> <table class="crud-table thresh-metric-table">
<thead><tr> <thead><tr>
@@ -845,6 +862,7 @@
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px" <button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button> onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div> </div>
</fieldset>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -928,6 +946,7 @@
const _allChannels = {{ all_channel_names | tojson }}; const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }}; const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }}; const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
const _isAdmin = {{ 'true' if (not current_user or current_user.admin) else 'false' }};
// ---- Channel CRUD ---- // ---- Channel CRUD ----
let _channelSchemas = {}; let _channelSchemas = {};
@@ -981,7 +1000,8 @@
document.getElementById('ch-type').value = ''; document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = ''; document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING'; document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false; const ownerInp = document.getElementById('ch-owner');
if (ownerInp) ownerInp.value = '';
if (name) { if (name) {
// Load existing channel data via API // Load existing channel data via API
@@ -993,7 +1013,7 @@
document.getElementById('ch-type').value = ch.type; document.getElementById('ch-type').value = ch.type;
onChTypeChange(); onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING'; document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('ch-private').checked = ch.private || false; if (ownerInp) ownerInp.value = ch.owner || '';
(ch.fields || []).forEach(f => { (ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key); const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || ''; if (inp) inp.value = f.value || '';
@@ -1012,14 +1032,15 @@
const name = document.getElementById('ch-name').value.trim(); const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value; const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value; const minLevel = document.getElementById('ch-min-level').value;
const isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status'); const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = ''; statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; } if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; } if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate }; const body = { name, type, min_level: minLevel };
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) body.owner = ownerInp.value.trim();
if (_channelSchemas[type]) { if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => { (_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key); const inp = document.getElementById('chf-' + sf.key);
@@ -1177,8 +1198,11 @@
watch: row.querySelector('.host-watch').checked, watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked, dyndns: row.querySelector('.host-dyndns').checked,
}; };
const owner = row.querySelector('.host-owner').value.trim(); const ownerInput = row.querySelector('.host-owner');
if (owner) entry.owner = owner; if (ownerInput) {
const owner = ownerInput.value.trim();
if (owner) entry.owner = owner;
}
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value); const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers; if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value); const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
@@ -1618,6 +1642,7 @@
card.innerHTML = ` card.innerHTML = `
<div class="thresh-cfg-header"> <div class="thresh-cfg-header">
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px"> <input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
${_isAdmin ? '<input type="text" class="field-input thresh-owner" placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">' : ''}
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button> <button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
</div> </div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">