feat: actionable metrics, LDAP log parsing, UI updates
This commit is contained in:
@@ -1,442 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin('app_sso_admin');
|
||||
|
||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const stats = await app.api.get('user/stats');
|
||||
|
||||
// Stat cards
|
||||
document.getElementById('stat-total').textContent = stats.totalUsers;
|
||||
document.getElementById('stat-active').textContent = stats.activeUsers;
|
||||
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
|
||||
document.getElementById('stat-groups').textContent = stats.totalGroups;
|
||||
|
||||
// Recent signups
|
||||
stats.recentSignups.forEach(function(u) {
|
||||
u.createTimestamp = moment(u.createTimestamp, 'YYYYMMDDHHmmssZ').fromNow();
|
||||
});
|
||||
$.scope.recentSignups.push(...stats.recentSignups);
|
||||
|
||||
// Inactive users
|
||||
$.scope.inactiveUsers.push(...stats.inactiveList);
|
||||
|
||||
document.getElementById('dashboard-overview').style.display = '';
|
||||
} catch(e) {
|
||||
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
|
||||
location.replace('/');
|
||||
} else {
|
||||
console.error('Dashboard load error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activateUser(uid) {
|
||||
try {
|
||||
await app.api.put('user/' + uid + '/active', { active: true });
|
||||
$.scope.inactiveUsers.remove('uid', uid);
|
||||
document.getElementById('stat-inactive').textContent =
|
||||
parseInt(document.getElementById('stat-inactive').textContent) - 1;
|
||||
document.getElementById('stat-active').textContent =
|
||||
parseInt(document.getElementById('stat-active').textContent) + 1;
|
||||
} catch(e) {
|
||||
alert('Failed to activate user.');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportUsers() {
|
||||
const resp = await fetch('/api/user/export', {
|
||||
headers: { 'auth-token': localStorage.getItem('APIToken') }
|
||||
});
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'users.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── Notifications (compose + history) ────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const data = await app.api.get('notification');
|
||||
const list = data.results || [];
|
||||
list.forEach(function(n) {
|
||||
n.created_on_fmt = moment(n.created_on).fromNow();
|
||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||
});
|
||||
$.scope.notificationHistory.push(...list);
|
||||
} catch(e) {
|
||||
if (e && e.status === 401) location.replace('/');
|
||||
else console.error('Failed to load notification history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFilterLabel(type, value, active_only) {
|
||||
const suffix = active_only ? ' (active only)' : '';
|
||||
if (type === 'group') return 'Groups: ' + value + suffix;
|
||||
if (type === 'users') return 'Specific users';
|
||||
if (type === 'all_active') return 'All active users';
|
||||
if (type === 'all') return 'All users';
|
||||
return type;
|
||||
}
|
||||
|
||||
function toggleFilterInputs() {
|
||||
// No radio is checked by default (see f-all-active below) — a "send to
|
||||
// everyone" option must be a deliberate choice, not whatever happens to
|
||||
// be pre-selected when someone's just trying the form out.
|
||||
const checked = document.querySelector('input[name="notif-filter"]:checked');
|
||||
const type = checked ? checked.value : null;
|
||||
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
|
||||
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
|
||||
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function sendNotification() {
|
||||
const subject = document.getElementById('notif-subject').value.trim();
|
||||
const message = document.getElementById('notif-message').value.trim();
|
||||
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
|
||||
const groupValue = document.getElementById('notif-group').value.trim();
|
||||
const usersValue = document.getElementById('notif-users').value.trim();
|
||||
const activeOnly = document.getElementById('notif-active-only').checked;
|
||||
const msgEl = document.getElementById('notif-result');
|
||||
const $compose = $('#notif-subject').closest('.card-body');
|
||||
|
||||
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
||||
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
||||
const filterType = filterCheck.value;
|
||||
|
||||
let filter_value = '';
|
||||
if (filterType === 'group') filter_value = groupValue;
|
||||
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
|
||||
|
||||
// Broadcasting to everyone is easy to trigger by accident while just
|
||||
// trying the form out — make it a deliberate, confirmed action.
|
||||
if (filterType === 'all' || filterType === 'all_active') {
|
||||
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
||||
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
msgEl.className = 'alert alert-info mt-2';
|
||||
msgEl.textContent = 'Sending…';
|
||||
msgEl.style.display = '';
|
||||
|
||||
try {
|
||||
const result = await app.api.post('notification', {
|
||||
subject,
|
||||
message,
|
||||
filter_type: filterType,
|
||||
filter_value,
|
||||
active_only: activeOnly,
|
||||
});
|
||||
msgEl.className = 'alert alert-success mt-2';
|
||||
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
|
||||
|
||||
const n = result.results;
|
||||
n.created_on_fmt = 'just now';
|
||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||
$.scope.notificationHistory.unshift([n]);
|
||||
|
||||
// Reset form
|
||||
document.getElementById('notif-subject').value = '';
|
||||
document.getElementById('notif-message').value = '';
|
||||
} catch(e) {
|
||||
msgEl.className = 'alert alert-danger mt-2';
|
||||
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) { alert('Terms of Service text cannot be empty.'); 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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="row mb-3 mt-2">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Dashboard</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-overview" class="row" style="display:none">
|
||||
<div class="col-12">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="fa-solid fa-users"></i> Overview</h5>
|
||||
<button class="btn btn-outline-secondary shadow" onclick="exportUsers()">
|
||||
<i class="fa-solid fa-file-csv"></i> Export Users CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow text-center">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold" id="stat-total">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow text-center border-success">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow text-center border-danger">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow text-center border-info">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Recent signups -->
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-user-plus"></i> Recent Signups
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th>User</th><th>Email</th><th>Joined</th></tr>
|
||||
</thead>
|
||||
<tbody jq-repeat="recentSignups">
|
||||
<tr>
|
||||
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
|
||||
<td><small>{{mail}}</small></td>
|
||||
<td><small class="text-muted">{{createTimestamp}}</small></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inactive users -->
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-lock"></i> Inactive Users
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th>User</th><th>Email</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody jq-repeat="inactiveUsers" jq-index-key="uid">
|
||||
<tr>
|
||||
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
|
||||
<td><small>{{mail}}</small></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-success" onclick="activateUser('{{uid}}')">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3"><i class="fa-solid fa-paper-plane"></i> Notifications</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Compose -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-pencil"></i> Compose
|
||||
</div>
|
||||
<div class="card-header shadow actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input type="text" class="form-control shadow" id="notif-subject" placeholder="Maintenance window tonight" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
|
||||
<textarea class="form-control shadow" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Send to</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-all-active">All active users</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-all">All users (including inactive)</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-group">Group members</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-users">Specific users</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notif-group-row" class="mb-3" style="display:none">
|
||||
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
|
||||
<input type="text" class="form-control shadow" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
|
||||
</div>
|
||||
<div id="notif-users-row" class="mb-3" style="display:none">
|
||||
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
|
||||
<input type="text" class="form-control shadow" id="notif-users" placeholder="wmantly, jsmith" />
|
||||
</div>
|
||||
<div id="notif-active-row" class="mb-3" style="display:none">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
|
||||
<label class="form-check-label" for="notif-active-only">Active members only</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary shadow" onclick="sendNotification()">
|
||||
<i class="fa-solid fa-paper-plane"></i> Send
|
||||
</button>
|
||||
<div id="notif-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> History
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sent</th>
|
||||
<th>Subject</th>
|
||||
<th>Filter</th>
|
||||
<th class="text-center">✓</th>
|
||||
<th class="text-center">✗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
|
||||
<tr>
|
||||
<td><small class="text-muted">{{created_on_fmt}}</small></td>
|
||||
<td><small>{{subject}}</small></td>
|
||||
<td><small class="text-muted">{{filter_label}}</small></td>
|
||||
<td class="text-center text-success"><small>{{sent_count}}</small></td>
|
||||
<td class="text-center text-danger"><small>{{failed_count}}</small></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3"><i class="fa-solid fa-file-contract"></i> Terms of Service</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-pencil"></i> Editor
|
||||
<small class="text-muted float-end" 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" id="tos-content" rows="16"></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" onclick="saveTos()">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('impersonate_modal') %>
|
||||
<%- include('bottom') %>
|
||||
@@ -0,0 +1,819 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<i class="fa-solid fa-server"></i> Directory Management
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
||||
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="kind">Kind</option>
|
||||
<option value="env">Environment</option>
|
||||
</select>
|
||||
<div class="btn-group btn-group-sm shadow-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
|
||||
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
|
||||
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
|
||||
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||
<i class="fas fa-plus"></i> Add Resource
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-3">Kind</th>
|
||||
<th>Name</th>
|
||||
<th>Env</th>
|
||||
<th>Host</th>
|
||||
<th>IP / Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resources-list" jq-repeat="resources">
|
||||
<tr>
|
||||
<td class="ps-3 text-nowrap">
|
||||
{{{indentHtml}}}
|
||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||
</td>
|
||||
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></td>
|
||||
<td>
|
||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||
</td>
|
||||
<td><span class="badge bg-light text-dark border">{{hostName}}</span></td>
|
||||
<td>
|
||||
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
||||
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Resource Modal -->
|
||||
<div class="modal fade" id="resourceModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="actionMessage mb-3" style="display:none"></div>
|
||||
<input type="hidden" id="res-id">
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" id="res-name" class="form-control shadow-sm">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Slug</label>
|
||||
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Kind</label>
|
||||
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
||||
<option value="site">Site</option>
|
||||
<option value="host">Host</option>
|
||||
<option value="service">Service (App)</option>
|
||||
<option value="oauth">OAuth Integration</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Sub Type</label>
|
||||
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" id="site-details-container" style="display: none;">
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
||||
<label class="form-check-label" for="res-is-current-site">
|
||||
Mark as Current Site
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
||||
<div class="col-12">
|
||||
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
||||
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
||||
<option value="">-- Select Parent --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">IP Address</label>
|
||||
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Host / URI Address</label>
|
||||
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" id="host-details-container" style="display: none;">
|
||||
<div class="col-4">
|
||||
<label class="form-label">VMID</label>
|
||||
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">MAC Address</label>
|
||||
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">OS / Kernel</label>
|
||||
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Internal Port</label>
|
||||
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">External Port</label>
|
||||
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
||||
<small class="text-muted">Same as Internal if empty</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" id="service-details-container" style="display: none;">
|
||||
<div class="col-4">
|
||||
<label class="form-label">Git Repo</label>
|
||||
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Install Path</label>
|
||||
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Systemd Service</label>
|
||||
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="oauth-details-container" style="display: none;">
|
||||
<hr>
|
||||
<h5>OAuth Configuration</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||
</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
||||
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
||||
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
||||
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
||||
</button>
|
||||
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="res-is-production">
|
||||
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4" id="external-container" style="display: none;">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="res-is-external">
|
||||
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4" id="public-container" style="display: none;">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="res-is-public">
|
||||
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="edit-only-section" style="display: none;">
|
||||
<h5>Associated LDAP Groups</h5>
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="fa-solid fa-users text-muted me-2"></i>
|
||||
<strong>{{groupCn}}</strong>
|
||||
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="input-group shadow-sm mt-2">
|
||||
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
||||
<datalist id="ldap-groups-datalist"></datalist>
|
||||
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
||||
<option value="member">Member</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5>Relationships (Graph Edges)</h5>
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{{#isParent}}
|
||||
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
||||
{{/isParent}}
|
||||
{{^isParent}}
|
||||
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
||||
{{/isParent}}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="input-group shadow-sm mt-2">
|
||||
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
||||
<option value="parent">Has child</option>
|
||||
<option value="child">Is child of</option>
|
||||
</select>
|
||||
<select class="form-select" id="new-edge-target">
|
||||
<option value="">-- Select Resource --</option>
|
||||
</select>
|
||||
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
||||
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveResource()">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save Resource
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
||||
|
||||
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
|
||||
var resourcesById = {};
|
||||
var allGroups = [];
|
||||
var allEdges = [];
|
||||
var rawResources = [];
|
||||
|
||||
$(document).ready(async function() {
|
||||
await loadResources();
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
try {
|
||||
const [resResources, resGroups, resEdges] = await Promise.all([
|
||||
app.api.get('directory-admin/resources'),
|
||||
app.api.get('directory-admin/groups'),
|
||||
app.api.get('directory-admin/edges')
|
||||
]);
|
||||
|
||||
resourcesById = {};
|
||||
|
||||
for (const r of resResources.results) {
|
||||
r.metadata = r.metadata || {};
|
||||
resourcesById[r.id] = r;
|
||||
}
|
||||
|
||||
allGroups = resGroups.results;
|
||||
allEdges = resEdges.results;
|
||||
|
||||
rawResources = [];
|
||||
for (const r of resResources.results) {
|
||||
// Compute hostName from edges
|
||||
r.hostName = '—';
|
||||
r.parentId = null;
|
||||
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||
if (parentEdge) {
|
||||
r.parentId = parentEdge.parentId;
|
||||
const parent = resourcesById[parentEdge.parentId];
|
||||
if (parent) r.hostName = parent.name;
|
||||
}
|
||||
rawResources.push(r);
|
||||
}
|
||||
|
||||
renderTable();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to load data');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const filter = $('#search-filter').val().toLowerCase();
|
||||
const sort = $('#sort-by').val();
|
||||
const viewMode = $('input[name="viewMode"]:checked').val();
|
||||
|
||||
let filtered = rawResources.filter(r => {
|
||||
if (!filter) return true;
|
||||
return (r.name || '').toLowerCase().includes(filter) ||
|
||||
(r.slug || '').toLowerCase().includes(filter) ||
|
||||
(r.kind || '').toLowerCase().includes(filter) ||
|
||||
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
|
||||
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
|
||||
(r.hostName || '').toLowerCase().includes(filter);
|
||||
});
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
|
||||
if (sort === 'env') {
|
||||
const ae = a.metadata?.isProduction ? 0 : 1;
|
||||
const be = b.metadata?.isProduction ? 0 : 1;
|
||||
return ae - be || a.name.localeCompare(b.name);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
let finalRenderList = [];
|
||||
|
||||
if (viewMode === 'tree') {
|
||||
const map = {};
|
||||
const roots = [];
|
||||
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
||||
|
||||
filtered.forEach(r => {
|
||||
const node = map[r.id];
|
||||
if (node.parentId && map[node.parentId]) {
|
||||
map[node.parentId].children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const flatten = (nodes, depth) => {
|
||||
nodes.forEach(n => {
|
||||
let indentHtml = '';
|
||||
for(let i = 0; i < depth; i++) {
|
||||
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
||||
}
|
||||
if (depth > 0) {
|
||||
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
||||
}
|
||||
n.indentHtml = indentHtml;
|
||||
finalRenderList.push(n);
|
||||
if (n.children.length > 0) {
|
||||
flatten(n.children, depth + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
flatten(roots, 0);
|
||||
} else {
|
||||
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
|
||||
}
|
||||
|
||||
$.scope.resources.empty();
|
||||
for (const r of finalRenderList) {
|
||||
$.scope.resources.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFormFields() {
|
||||
const kind = $('#res-kind').val();
|
||||
if (kind === 'host') {
|
||||
$('#host-parent-container').show();
|
||||
$('#host-details-container').show();
|
||||
$('#service-ports-container').hide();
|
||||
$('#service-details-container').hide();
|
||||
$('#oauth-details-container').hide();
|
||||
$('#external-container').hide();
|
||||
$('#public-container').hide();
|
||||
$('#site-details-container').hide();
|
||||
} else if (kind === 'service') {
|
||||
$('#host-parent-container').show();
|
||||
$('#host-details-container').hide();
|
||||
$('#service-ports-container').show();
|
||||
$('#service-details-container').show();
|
||||
$('#oauth-details-container').hide();
|
||||
$('#external-container').show();
|
||||
$('#public-container').show();
|
||||
$('#site-details-container').hide();
|
||||
} else if (kind === 'oauth') {
|
||||
$('#host-parent-container').show();
|
||||
$('#host-details-container').hide();
|
||||
$('#service-ports-container').hide();
|
||||
$('#service-details-container').hide();
|
||||
$('#oauth-details-container').show();
|
||||
$('#external-container').hide();
|
||||
$('#public-container').hide();
|
||||
$('#site-details-container').hide();
|
||||
} else { // site
|
||||
$('#host-parent-container').hide();
|
||||
$('#host-details-container').hide();
|
||||
$('#service-ports-container').hide();
|
||||
$('#service-details-container').hide();
|
||||
$('#oauth-details-container').hide();
|
||||
$('#external-container').hide();
|
||||
$('#public-container').hide();
|
||||
$('#site-details-container').show();
|
||||
}
|
||||
populateHostDropdown($('#res-host-id').val());
|
||||
}
|
||||
|
||||
$('#res-name, #res-kind').on('input change', function() {
|
||||
const id = $('#res-id').val();
|
||||
if (!id && $('#res-name').val()) {
|
||||
const name = $('#res-name').val();
|
||||
const kind = $('#res-kind').val();
|
||||
let prefix = '';
|
||||
if (kind === 'service') prefix = 'app_';
|
||||
if (kind === 'host') prefix = 'host_';
|
||||
if (kind === 'site') prefix = 'site_';
|
||||
|
||||
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
$('#res-slug').val(slug);
|
||||
}
|
||||
});
|
||||
|
||||
function openAddModal(parentId, parentKind) {
|
||||
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
|
||||
$('#res-id').val('');
|
||||
$('#res-name').val('');
|
||||
$('#res-slug').val('');
|
||||
|
||||
let defaultKind = 'service';
|
||||
if (parentKind === 'site') defaultKind = 'host';
|
||||
if (parentKind === 'host') defaultKind = 'service';
|
||||
$('#res-kind').val(defaultKind);
|
||||
|
||||
if (!parentId && defaultKind === 'service') {
|
||||
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||
if (currentSite) parentId = currentSite.id;
|
||||
}
|
||||
if (!parentId && defaultKind === 'host') {
|
||||
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||
if (currentSite) parentId = currentSite.id;
|
||||
}
|
||||
|
||||
$('#res-description').val('');
|
||||
$('#res-ip').val('');
|
||||
$('#res-address').val('');
|
||||
$('#res-subtype').val('');
|
||||
$('#res-vmid').val('');
|
||||
$('#res-mac').val('');
|
||||
$('#res-os').val('');
|
||||
$('#res-port').val('');
|
||||
$('#res-external-port').val('');
|
||||
$('#res-git-repo').val('');
|
||||
$('#res-install-path').val('');
|
||||
$('#res-systemd').val('');
|
||||
$('#res-is-production').prop('checked', false);
|
||||
$('#res-is-external').prop('checked', false);
|
||||
$('#res-is-public').prop('checked', false);
|
||||
$('#res-is-current-site').prop('checked', false);
|
||||
$('#edit-only-section').hide();
|
||||
|
||||
populateHostDropdown(parentId || '');
|
||||
toggleFormFields();
|
||||
|
||||
resourceModal.show();
|
||||
}
|
||||
|
||||
var ldapGroupsCache = null;
|
||||
async function loadLdapGroups() {
|
||||
if (ldapGroupsCache) return;
|
||||
try {
|
||||
const res = await app.api.get('group');
|
||||
ldapGroupsCache = res.results;
|
||||
const $datalist = $('#ldap-groups-datalist');
|
||||
$datalist.empty();
|
||||
for (const cn of ldapGroupsCache) {
|
||||
$datalist.append($('<option>').val(cn));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load LDAP groups', err);
|
||||
}
|
||||
}
|
||||
|
||||
function populateHostDropdown(selectedId) {
|
||||
const kind = $('#res-kind').val();
|
||||
const $target = $('#res-host-id');
|
||||
$target.empty().append('<option value="">-- Select Parent --</option>');
|
||||
Object.values(resourcesById).forEach(r => {
|
||||
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
|
||||
|
||||
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
|
||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
|
||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||
}
|
||||
});
|
||||
if (selectedId) $target.val(selectedId);
|
||||
}
|
||||
|
||||
function refreshGroupsUI(resourceId) {
|
||||
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||
$.scope.groups.empty();
|
||||
for (const g of myGroups) {
|
||||
$.scope.groups.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshEdgesUI(resourceId) {
|
||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||
$.scope.edges.empty();
|
||||
for (const e of myEdges) {
|
||||
const isParent = e.parentId === resourceId;
|
||||
const targetId = isParent ? e.childId : e.parentId;
|
||||
const target = resourcesById[targetId];
|
||||
if (!target) continue;
|
||||
|
||||
$.scope.edges.push({
|
||||
id: e.id,
|
||||
isParent: isParent,
|
||||
relation: e.relation,
|
||||
targetName: target.name + ' (' + target.slug + ')'
|
||||
});
|
||||
}
|
||||
|
||||
const $target = $('#new-edge-target');
|
||||
$target.empty().append('<option value="">-- Select Resource --</option>');
|
||||
Object.values(resourcesById).forEach(r => {
|
||||
if (r.id !== resourceId) {
|
||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openEditModal(id) {
|
||||
const r = resourcesById[id];
|
||||
if (!r) return;
|
||||
|
||||
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
|
||||
$('#res-id').val(r.id);
|
||||
$('#res-name').val(r.name);
|
||||
$('#res-slug').val(r.slug);
|
||||
$('#res-kind').val(r.kind);
|
||||
$('#res-description').val(r.description || '');
|
||||
$('#res-ip').val(r.metadata.ip || '');
|
||||
$('#res-address').val(r.metadata.address || '');
|
||||
$('#res-subtype').val(r.metadata.subType || '');
|
||||
$('#res-vmid').val(r.metadata.vmid || '');
|
||||
$('#res-mac').val(r.metadata.macAddress || '');
|
||||
let osKernel = '';
|
||||
if (r.metadata.os) osKernel += r.metadata.os;
|
||||
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
|
||||
$('#res-os').val(osKernel);
|
||||
$('#res-port').val(r.metadata.port || '');
|
||||
$('#res-external-port').val(r.metadata.externalPort || '');
|
||||
$('#res-git-repo').val(r.metadata.gitRepo || '');
|
||||
$('#res-install-path').val(r.metadata.installPath || '');
|
||||
$('#res-systemd').val(r.metadata.systemdService || '');
|
||||
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
|
||||
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
|
||||
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
|
||||
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
|
||||
|
||||
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
|
||||
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
|
||||
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
|
||||
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
|
||||
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
|
||||
|
||||
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
|
||||
else $('#oauth-rotate-container').hide();
|
||||
|
||||
// Find parent host
|
||||
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
||||
toggleFormFields();
|
||||
|
||||
$('#edit-only-section').show();
|
||||
|
||||
refreshGroupsUI(r.id);
|
||||
refreshEdgesUI(r.id);
|
||||
await loadLdapGroups();
|
||||
|
||||
resourceModal.show();
|
||||
}
|
||||
|
||||
async function saveResource() {
|
||||
const id = $('#res-id').val();
|
||||
const data = {
|
||||
name: $('#res-name').val(),
|
||||
slug: $('#res-slug').val(),
|
||||
kind: $('#res-kind').val(),
|
||||
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
|
||||
description: $('#res-description').val(),
|
||||
metadata: {
|
||||
subType: $('#res-subtype').val(),
|
||||
ip: $('#res-ip').val(),
|
||||
address: $('#res-address').val(),
|
||||
vmid: $('#res-vmid').val(),
|
||||
macAddress: $('#res-mac').val(),
|
||||
os: $('#res-os').val(),
|
||||
port: $('#res-port').val(),
|
||||
externalPort: $('#res-external-port').val() || $('#res-port').val(),
|
||||
gitRepo: $('#res-git-repo').val(),
|
||||
installPath: $('#res-install-path').val(),
|
||||
systemdService: $('#res-systemd').val(),
|
||||
isProduction: $('#res-is-production').is(':checked'),
|
||||
isExternalReachable: $('#res-is-external').is(':checked'),
|
||||
isPublic: $('#res-is-public').is(':checked'),
|
||||
isCurrentSite: $('#res-is-current-site').is(':checked')
|
||||
}
|
||||
};
|
||||
|
||||
if (data.kind === 'oauth') {
|
||||
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
|
||||
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
|
||||
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
|
||||
data.token_lifetime = {
|
||||
access_token: Number($('#res-access-ttl').val()) || 3600,
|
||||
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (id) {
|
||||
res = await app.api.put('directory-admin/resources/' + id, data);
|
||||
} else {
|
||||
res = await app.api.post('directory-admin/resources', data);
|
||||
}
|
||||
|
||||
resourceModal.hide();
|
||||
await loadResources();
|
||||
|
||||
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
||||
app.util.alert('OAuth Secret', 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Failed to save');
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateSecret() {
|
||||
const id = $('#res-id').val();
|
||||
if (!id) return;
|
||||
if (!confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.')) return;
|
||||
|
||||
try {
|
||||
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
||||
app.util.alert('Secret Rotated', 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>', 'success');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Failed to rotate secret');
|
||||
}
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
const resourceId = $('#res-id').val();
|
||||
const groupCn = $('#new-group-cn').val().trim();
|
||||
const accessLevel = $('#new-group-level').val();
|
||||
|
||||
if (!groupCn) return alert('Group CN is required');
|
||||
try {
|
||||
const res = await app.api.post('directory-admin/groups', {
|
||||
resourceId,
|
||||
groupCn,
|
||||
accessLevel
|
||||
});
|
||||
allGroups.push(res.results);
|
||||
refreshGroupsUI(resourceId);
|
||||
$('#new-group-cn').val('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to add group');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup(id) {
|
||||
try {
|
||||
await app.api.delete('directory-admin/groups/' + id);
|
||||
allGroups = allGroups.filter(g => g.id !== id);
|
||||
refreshGroupsUI($('#res-id').val());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to remove group');
|
||||
}
|
||||
}
|
||||
|
||||
async function addEdge() {
|
||||
const resourceId = $('#res-id').val();
|
||||
const dir = $('#new-edge-dir').val();
|
||||
const targetId = $('#new-edge-target').val();
|
||||
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
||||
|
||||
if (!targetId) return alert('Select a target resource');
|
||||
|
||||
const data = { relation };
|
||||
if (dir === 'parent') {
|
||||
data.parentId = resourceId;
|
||||
data.childId = targetId;
|
||||
} else {
|
||||
data.parentId = targetId;
|
||||
data.childId = resourceId;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await app.api.post('directory-admin/edges', data);
|
||||
allEdges.push(res.results);
|
||||
refreshEdgesUI(resourceId);
|
||||
$('#new-edge-target').val('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to add edge');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEdge(id) {
|
||||
try {
|
||||
await app.api.delete('directory-admin/edges/' + id);
|
||||
allEdges = allEdges.filter(e => e.id !== id);
|
||||
refreshEdgesUI($('#res-id').val());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to remove edge');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource(id) {
|
||||
if (!confirm('Are you sure you want to delete this resource? All relationships will be destroyed.')) return;
|
||||
try {
|
||||
await app.api.delete('directory-admin/resources/' + id);
|
||||
await loadResources();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to delete');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
@@ -0,0 +1,447 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin('app_sso_admin');
|
||||
|
||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const stats = await app.api.get('user/stats');
|
||||
|
||||
// Stat cards
|
||||
document.getElementById('stat-total').textContent = stats.totalUsers;
|
||||
document.getElementById('stat-active').textContent = stats.activeUsers;
|
||||
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
|
||||
document.getElementById('stat-groups').textContent = stats.totalGroups;
|
||||
|
||||
try {
|
||||
const dirRes = await app.api.get('directory-admin/resources');
|
||||
const resources = dirRes.results || [];
|
||||
document.getElementById('stat-hosts').textContent = resources.filter(r => r.kind === 'host').length;
|
||||
document.getElementById('stat-services').textContent = resources.filter(r => r.kind === 'service').length;
|
||||
document.getElementById('stat-oauth').textContent = resources.filter(r => r.kind === 'oauth').length;
|
||||
document.getElementById('directory-stats-row').style.display = '';
|
||||
} catch (err) {}
|
||||
|
||||
document.getElementById('dashboard-overview').style.display = '';
|
||||
} catch(e) {
|
||||
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
|
||||
location.replace('/');
|
||||
} else {
|
||||
console.error('Dashboard load error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportUsers() {
|
||||
const resp = await fetch('/api/user/export', {
|
||||
headers: { 'auth-token': localStorage.getItem('APIToken') }
|
||||
});
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'users.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const data = await app.api.get('metrics/executive');
|
||||
if (data && data.results) {
|
||||
const renderList = (items, id) => {
|
||||
const el = document.getElementById(id);
|
||||
el.innerHTML = '';
|
||||
if (!items || items.length === 0) {
|
||||
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||
return;
|
||||
}
|
||||
items.forEach(item => {
|
||||
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${item.value}
|
||||
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||
</li>`;
|
||||
});
|
||||
};
|
||||
renderList(data.results.ips, 'metrics-ips');
|
||||
renderList(data.results.users, 'metrics-users');
|
||||
renderList(data.results.services, 'metrics-services');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load metrics:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications (compose + history) ────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const data = await app.api.get('notification');
|
||||
const list = data.results || [];
|
||||
list.forEach(function(n) {
|
||||
n.created_on_fmt = moment(n.created_on).fromNow();
|
||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||
});
|
||||
$.scope.notificationHistory.push(...list);
|
||||
} catch(e) {
|
||||
if (e && e.status === 401) location.replace('/');
|
||||
else console.error('Failed to load notification history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFilterLabel(type, value, active_only) {
|
||||
const suffix = active_only ? ' (active only)' : '';
|
||||
if (type === 'group') return 'Groups: ' + value + suffix;
|
||||
if (type === 'users') return 'Specific users';
|
||||
if (type === 'all_active') return 'All active users';
|
||||
if (type === 'all') return 'All users';
|
||||
return type;
|
||||
}
|
||||
|
||||
function toggleFilterInputs() {
|
||||
// No radio is checked by default (see f-all-active below) — a "send to
|
||||
// everyone" option must be a deliberate choice, not whatever happens to
|
||||
// be pre-selected when someone's just trying the form out.
|
||||
const checked = document.querySelector('input[name="notif-filter"]:checked');
|
||||
const type = checked ? checked.value : null;
|
||||
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
|
||||
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
|
||||
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function sendNotification() {
|
||||
const subject = document.getElementById('notif-subject').value.trim();
|
||||
const message = document.getElementById('notif-message').value.trim();
|
||||
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
|
||||
const groupValue = document.getElementById('notif-group').value.trim();
|
||||
const usersValue = document.getElementById('notif-users').value.trim();
|
||||
const activeOnly = document.getElementById('notif-active-only').checked;
|
||||
const msgEl = document.getElementById('notif-result');
|
||||
const $compose = $('#notif-subject').closest('.card-body');
|
||||
|
||||
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
||||
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
||||
const filterType = filterCheck.value;
|
||||
|
||||
let filter_value = '';
|
||||
if (filterType === 'group') filter_value = groupValue;
|
||||
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
|
||||
|
||||
// Broadcasting to everyone is easy to trigger by accident while just
|
||||
// trying the form out — make it a deliberate, confirmed action.
|
||||
if (filterType === 'all' || filterType === 'all_active') {
|
||||
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
||||
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
msgEl.className = 'alert alert-info mt-2';
|
||||
msgEl.textContent = 'Sending…';
|
||||
msgEl.style.display = '';
|
||||
|
||||
try {
|
||||
const result = await app.api.post('notification', {
|
||||
subject,
|
||||
message,
|
||||
filter_type: filterType,
|
||||
filter_value,
|
||||
active_only: activeOnly,
|
||||
});
|
||||
msgEl.className = 'alert alert-success mt-2';
|
||||
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
|
||||
|
||||
const n = result.results;
|
||||
n.created_on_fmt = 'just now';
|
||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||
$.scope.notificationHistory.unshift([n]);
|
||||
|
||||
// Reset form
|
||||
document.getElementById('notif-subject').value = '';
|
||||
document.getElementById('notif-message').value = '';
|
||||
} catch(e) {
|
||||
msgEl.className = 'alert alert-danger mt-2';
|
||||
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) { alert('Terms of Service text cannot be empty.'); 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>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="card shadow mb-4" id="dashboard-overview" style="display:none">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div><i class="fa-solid fa-chart-pie"></i> Overview Stats</div>
|
||||
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="exportUsers()">
|
||||
<i class="fa-solid fa-file-csv"></i> Export Users CSV
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body bg-light">
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold" id="stat-total">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center border-success">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center border-danger">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center border-info">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1" id="directory-stats-row" style="display:none">
|
||||
<div class="col-4">
|
||||
<div class="card shadow-sm text-center border-secondary">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-secondary" id="stat-hosts">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-server"></i> Hosts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="card shadow-sm text-center border-secondary">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-secondary" id="stat-services">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-layer-group"></i> Services</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="card shadow-sm text-center border-secondary">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-secondary" id="stat-oauth">—</div>
|
||||
<div class="text-muted small"><i class="fa-solid fa-plug"></i> OAuth Integrations</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Card -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div><i class="fa-solid fa-paper-plane"></i> Notifications</div>
|
||||
</div>
|
||||
<ul class="nav nav-tabs px-3 pt-2 border-bottom-0" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-compose" type="button" role="tab"><i class="fa-solid fa-pencil"></i> Compose</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content border-top">
|
||||
<div class="tab-pane fade show active p-4" id="tab-compose" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input type="text" class="form-control shadow-sm" id="notif-subject" placeholder="Maintenance window tonight" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
|
||||
<textarea class="form-control shadow-sm" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Send to</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-all-active">All active users</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-all">All users (including inactive)</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-group">Group members</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
|
||||
<label class="form-check-label" for="f-users">Specific users</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notif-group-row" class="mb-3" style="display:none">
|
||||
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
|
||||
<input type="text" class="form-control shadow-sm" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
|
||||
</div>
|
||||
<div id="notif-users-row" class="mb-3" style="display:none">
|
||||
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
|
||||
<input type="text" class="form-control shadow-sm" id="notif-users" placeholder="wmantly, jsmith" />
|
||||
</div>
|
||||
<div id="notif-active-row" class="mb-3" style="display:none">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
|
||||
<label class="form-check-label" for="notif-active-only">Active members only</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary shadow-sm" onclick="sendNotification()">
|
||||
<i class="fa-solid fa-paper-plane"></i> Send
|
||||
</button>
|
||||
<div id="notif-result" style="display:none" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-history" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-3">Sent</th>
|
||||
<th>Subject</th>
|
||||
<th>Filter</th>
|
||||
<th class="text-center">✓</th>
|
||||
<th class="text-center pe-3">✗</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
|
||||
<tr>
|
||||
<td class="ps-3"><small class="text-muted">{{created_on_fmt}}</small></td>
|
||||
<td><small>{{subject}}</small></td>
|
||||
<td><small class="text-muted">{{filter_label}}</small></td>
|
||||
<td class="text-center text-success"><small>{{sent_count}}</small></td>
|
||||
<td class="text-center text-danger pe-3"><small>{{failed_count}}</small></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<div><i class="fa-solid fa-chart-line"></i> Actionable Metrics (Last 7 Days)</div>
|
||||
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="loadMetrics()">
|
||||
<i class="fa-solid fa-rotate-right"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100 border-danger">
|
||||
<div class="card-header bg-danger text-white"><i class="fa-solid fa-shield-halved"></i> Top Failed IPs</div>
|
||||
<ul class="list-group list-group-flush" id="metrics-ips">
|
||||
<li class="list-group-item text-muted">Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100 border-warning">
|
||||
<div class="card-header bg-warning text-dark"><i class="fa-solid fa-user-xmark"></i> Top Failed Accounts</div>
|
||||
<ul class="list-group list-group-flush" id="metrics-users">
|
||||
<li class="list-group-item text-muted">Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100 border-success">
|
||||
<div class="card-header bg-success text-white"><i class="fa-solid fa-plug"></i> Top Services Used</div>
|
||||
<ul class="list-group list-group-flush" id="metrics-services">
|
||||
<li class="list-group-item text-muted">Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('impersonate_modal') %>
|
||||
<%- include('bottom') %>
|
||||
@@ -121,7 +121,7 @@
|
||||
tableAJAX();
|
||||
});
|
||||
</script>
|
||||
<div class="row" style="display:none;">
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="input-group" style="flex: 1 1 200px;">
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<!-- Edit OAuth client modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
||||
<input type="hidden" id="edit-client-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" id="edit-name" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" id="edit-description" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||
</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="edit-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="edit-allowed_groups"></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
|
||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
|
||||
|
||||
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
||||
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
|
||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
|
||||
// Widget handles + a lookup of the latest client data (for the edit modal).
|
||||
var createScopes, createGroups, editScopes, editGroups;
|
||||
var clientsById = {};
|
||||
|
||||
function showSecret(secret, title){
|
||||
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
|
||||
document.getElementById('secretValue').value = secret;
|
||||
secretModal.show();
|
||||
}
|
||||
|
||||
function copySecret(){
|
||||
copyField('secretValue');
|
||||
}
|
||||
|
||||
// Copy the value of an input by id; briefly flips the button icon to a check.
|
||||
function copyField(id, btn){
|
||||
var el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
el.select();
|
||||
el.setSelectionRange(0, 99999);
|
||||
document.execCommand('copy');
|
||||
if(btn){
|
||||
var $i = $(btn).find('i');
|
||||
var prev = $i.attr('class');
|
||||
$i.attr('class', 'fa-solid fa-check');
|
||||
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTTL(seconds){
|
||||
if(seconds < 3600) return seconds + 's';
|
||||
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
||||
return (seconds / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function processClient(client){
|
||||
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
||||
client.scopes_display = (client.scopes || []).join(' ');
|
||||
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
|
||||
client.has_group_restriction = (client.allowed_groups || []).length > 0;
|
||||
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
|
||||
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function tableAJAX(){
|
||||
let data = await app.oauthClient.list();
|
||||
$.scope.oauthClientCard.empty();
|
||||
$.each(data.results, function(_, client){
|
||||
$.scope.oauthClientCard.push(processClient(client));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteClient(client_id, name, btn){
|
||||
const $card = $(btn).closest('.card');
|
||||
$card.addClass('table-warning');
|
||||
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
||||
$card.removeClass('table-warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('oauth/client/' + client_id, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
$.scope.oauthClientCard.remove('client_id', client_id);
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateSecret(client_id, name, btn){
|
||||
const $card = $(btn).closest('.card');
|
||||
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
||||
if (!confirmed) return;
|
||||
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
showSecret(data.client_secret, 'Client Secret');
|
||||
});
|
||||
}
|
||||
|
||||
// Open the edit modal pre-filled from the client's current values.
|
||||
function editClient(client_id){
|
||||
var c = clientsById[client_id];
|
||||
if(!c) return;
|
||||
$('#edit-client-id').val(client_id);
|
||||
$('#edit-name').val(c.name || '');
|
||||
$('#edit-description').val(c.description || '');
|
||||
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
||||
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
||||
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
||||
|
||||
// (Re)build the tag widgets fresh each open so they reflect this client.
|
||||
editScopes = app.ui.tagInput('#edit-scopes', {
|
||||
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
||||
separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
||||
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
||||
});
|
||||
editModal.show();
|
||||
}
|
||||
|
||||
function saveEdit(btn){
|
||||
var $msg = $('#editModal .actionMessage');
|
||||
var payload = {
|
||||
client_id: $('#edit-client-id').val(),
|
||||
name: $('#edit-name').val(),
|
||||
description: $('#edit-description').val(),
|
||||
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
||||
scopes: editScopes.get(),
|
||||
allowed_groups: editGroups.get(),
|
||||
token_lifetime: {
|
||||
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
||||
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
||||
},
|
||||
};
|
||||
app.oauthClient.update(payload, function(error, data){
|
||||
if(error){
|
||||
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
||||
return;
|
||||
}
|
||||
editModal.hide();
|
||||
tableAJAX();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
tableAJAX();
|
||||
|
||||
// Initialise the create-form tag widgets.
|
||||
createScopes = app.ui.tagInput('#create-scopes', {
|
||||
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
||||
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
||||
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
||||
});
|
||||
|
||||
// After a successful create, reset the widgets too (form reset ignores them).
|
||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
||||
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
|
||||
<i class="fa-solid fa-key"></i> OAuth Apps
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
|
||||
<i class="fa-solid fa-network-wired"></i> LDAP
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm border-info">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
OpenID Connect Endpoints
|
||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2 text-muted small">
|
||||
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
||||
It advertises the authorization, token, and userinfo endpoints automatically.
|
||||
</p>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-2">Issuer</dt>
|
||||
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
||||
<dt class="col-sm-2">Discovery URL</dt>
|
||||
<dd class="col-sm-10">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
||||
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Register OAuth Client
|
||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
||||
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
<code>*</code> matches one hostname label and <code>**</code> matches any
|
||||
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
||||
covers every host theta42/proxy fronts under example.com without registering
|
||||
each one individually.
|
||||
</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="create-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="create-allowed_groups"></div>
|
||||
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-dark">
|
||||
<i class="fa-solid fa-plus"></i> Register
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8" style="background-color: initial; border: none">
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
||||
<div class="card-header">
|
||||
<h5>
|
||||
<i class="fa-solid fa-server"></i>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<small class="text-muted font-monospace">{{ client_id }}</small>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
{{ #description }}
|
||||
<p>{{ description }}</p>
|
||||
{{ /description }}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Client ID</dt>
|
||||
<dd class="col-sm-9">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
<dt class="col-sm-3">Redirect URIs</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{{ #redirect_uris }}
|
||||
<li><code>{{ . }}</code></li>
|
||||
{{ /redirect_uris }}
|
||||
</ul>
|
||||
</dd>
|
||||
<dt class="col-sm-3">Scopes</dt>
|
||||
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
|
||||
<dt class="col-sm-3">Access</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{ #has_group_restriction }}
|
||||
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
|
||||
<code>{{ allowed_groups_display }}</code>
|
||||
{{ /has_group_restriction }}
|
||||
{{ ^has_group_restriction }}
|
||||
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
|
||||
{{ /has_group_restriction }}
|
||||
</dd>
|
||||
<dt class="col-sm-3">Access Token</dt>
|
||||
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
||||
<dt class="col-sm-3">Refresh Token</dt>
|
||||
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
||||
<dt class="col-sm-3">Created by</dt>
|
||||
<dd class="col-sm-9">{{ created_by }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button"
|
||||
onclick="editClient('{{client_id}}')"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
||||
class="btn btn-warning btn-sm">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
||||
class="btn btn-danger btn-sm float-end">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
|
||||
<p class="text-muted">
|
||||
Everything a 3rd-party app or host needs to bind this directory, filled in
|
||||
for <b><%= ssoUrl %></b>.
|
||||
</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-warning">
|
||||
<div class="card-header bg-warning bg-opacity-10">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
LDAPS hostname: keep LDAP binds off the public internet
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">
|
||||
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
|
||||
the TLS client verifies the server name against the certificate.
|
||||
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
|
||||
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
|
||||
OAuth issuer host — convenient, but that implies clients reach it through
|
||||
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
|
||||
for LDAP simple binds; instead pick an internal-only hostname and set
|
||||
<code>conf.ldap.ldapsHost</code>.<% } %>
|
||||
</p>
|
||||
<ul class="small mb-2">
|
||||
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
|
||||
use <code>ldaps://sso-manager:636</code> (the internal service name).
|
||||
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
|
||||
<li><strong>LAN host:</strong> create an internal DNS record like
|
||||
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
|
||||
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
|
||||
A wildcard for <code>*.internal.example.com</code> works well.</li>
|
||||
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
|
||||
lockdown — never exposed to the open internet.</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
|
||||
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
|
||||
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
|
||||
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
|
||||
runbook, including how to set <code>ldapsHost</code> in
|
||||
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-circle-info"></i> Connection details
|
||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
For a single app's own "LDAP authentication" settings — see
|
||||
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
||||
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
||||
</p>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">LDAPS URL</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
<% if (ldapsHostExplicit) { %>
|
||||
<small class="field-help text-muted d-block">
|
||||
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
|
||||
</small>
|
||||
<% } %>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Base DN</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">User search base</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Group search base</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">User filter</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Username attribute</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Example bind DN</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
<small class="field-help text-muted d-block">
|
||||
A read-only bind account — create one from
|
||||
<a href="/users">Users > Service Accounts</a> (don't reuse a real
|
||||
person's login or the admin DN).
|
||||
</small>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
||||
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
||||
and run this on the host. Fill in a service account's password
|
||||
(create one from <a href="/users">Users > Service Accounts</a>) and,
|
||||
if you want this host's access/sudo groups auto-registered, an
|
||||
<a href="/">API token</a> from your Profile.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
||||
<i class="fa-solid fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
var lines = [
|
||||
'git clone https://github.com/theta42/ldap-client.git',
|
||||
'cd ldap-client',
|
||||
'cat > ldap.vars << \'EOF\'',
|
||||
'# LDAPS host advertised on the Integrations page. If this is an internal-only',
|
||||
'# hostname, make sure it resolves from this host and the cert SAN matches it.',
|
||||
'export ldap_host="<%= ldapHost %>"',
|
||||
'export ldap_base_dn="<%= baseDn %>"',
|
||||
'',
|
||||
'# A read-only service account -- create one under Users > Service',
|
||||
'# Accounts, then fill in its password below.',
|
||||
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
||||
'export ldap_bind_password="CHANGE-ME"',
|
||||
'',
|
||||
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
||||
'# Manager. Create a personal access token under Profile > API Tokens',
|
||||
'# and paste it here; leave blank to skip.',
|
||||
'export sso_url="<%= ssoUrl %>"',
|
||||
'export sso_token=""',
|
||||
'',
|
||||
'# Optional: set this if you run ldap-client against more than one site.',
|
||||
'export ldap_location=""',
|
||||
'',
|
||||
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
||||
'EOF',
|
||||
'',
|
||||
'sudo ./index.sh',
|
||||
];
|
||||
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
@@ -1,306 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_invite']);
|
||||
|
||||
let allGroups = [];
|
||||
let currentUserUid = null;
|
||||
let isAdmin = false;
|
||||
|
||||
async function init() {
|
||||
const [user, groupData] = await Promise.all([
|
||||
app.api.get('user/me'),
|
||||
app.api.get('group/'),
|
||||
]);
|
||||
currentUserUid = user.uid;
|
||||
isAdmin = (user.memberOf || []).some(dn => dn.startsWith('cn=app_sso_admin,'));
|
||||
allGroups = groupData.results || [];
|
||||
|
||||
// Populate create-form group select
|
||||
populateGroupSelect('create-groups', allGroups, []);
|
||||
|
||||
loadInvites();
|
||||
}
|
||||
|
||||
function fuzzyMatch(query, text) {
|
||||
query = query.toLowerCase();
|
||||
text = text.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let i = 0; i < text.length && qi < query.length; i++) {
|
||||
if (text[i] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
}
|
||||
|
||||
function filterGroups(inputEl, selectId) {
|
||||
const query = inputEl.value;
|
||||
[...document.getElementById(selectId).options].forEach(function(opt) {
|
||||
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
|
||||
});
|
||||
}
|
||||
|
||||
function populateGroupSelect(id, groups, selected) {
|
||||
const sel = document.getElementById(id);
|
||||
sel.innerHTML = '';
|
||||
groups.forEach(function(cn) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cn;
|
||||
opt.textContent = cn;
|
||||
opt.selected = selected.includes(cn);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function inviteStatus(t) {
|
||||
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
|
||||
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
|
||||
return { label: 'Pending', cls: 'text-success' };
|
||||
}
|
||||
|
||||
function groupsDisplay(groups) {
|
||||
try {
|
||||
const arr = JSON.parse(groups || '[]');
|
||||
return arr.length ? arr.join(', ') : '—';
|
||||
} catch(_) { return '—'; }
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
try {
|
||||
const data = await app.api.get('user/invite');
|
||||
const tbody = document.getElementById('invite-tbody');
|
||||
tbody.innerHTML = '';
|
||||
(data.results || [])
|
||||
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
|
||||
.forEach(function(t) {
|
||||
const status = inviteStatus(t);
|
||||
const canEdit = isAdmin || t.created_by === currentUserUid;
|
||||
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
|
||||
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.token = t.token;
|
||||
tr.innerHTML = `
|
||||
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
|
||||
<td><small>${t.created_by}</small></td>
|
||||
<td><small>${mail}</small></td>
|
||||
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
|
||||
<td><small class="${status.cls}">${status.label}</small></td>
|
||||
<td class="text-nowrap">
|
||||
${canEdit && isPending ? `
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
|
||||
<i class="fa-solid fa-ban"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${isPending ? `
|
||||
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch(e) {
|
||||
if (e && e.status === 401) location.replace('/');
|
||||
else console.error('Failed to load invites:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createInvite() {
|
||||
const mail = document.getElementById('create-email').value.trim();
|
||||
const groups = [...document.getElementById('create-groups').selectedOptions].map(o => o.value);
|
||||
const result = document.getElementById('create-result');
|
||||
result.style.display = 'none';
|
||||
try {
|
||||
const data = await app.api.post('user/invite', { mail, groups });
|
||||
result.style.display = '';
|
||||
if (data.mail_sent) {
|
||||
result.className = 'alert alert-success mt-2';
|
||||
result.textContent = `Invite sent to ${mail}`;
|
||||
} else {
|
||||
result.className = 'alert alert-info mt-2';
|
||||
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
|
||||
}
|
||||
document.getElementById('create-email').value = '';
|
||||
[...document.getElementById('create-groups').options].forEach(o => o.selected = false);
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
result.style.display = '';
|
||||
result.className = 'alert alert-danger mt-2';
|
||||
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(tokenId) {
|
||||
// Find cached data from the DOM isn't reliable — re-fetch
|
||||
app.api.get('user/invite').then(function(data) {
|
||||
const t = (data.results || []).find(x => x.token === tokenId);
|
||||
if (!t) return;
|
||||
|
||||
document.getElementById('edit-token').value = tokenId;
|
||||
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
|
||||
|
||||
const selected = JSON.parse(t.groups || '[]');
|
||||
populateGroupSelect('edit-groups', allGroups, selected);
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const tokenId = document.getElementById('edit-token').value;
|
||||
const mail = document.getElementById('edit-email').value.trim();
|
||||
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
|
||||
const result = document.getElementById('edit-result');
|
||||
result.style.display = 'none';
|
||||
try {
|
||||
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
|
||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
result.style.display = '';
|
||||
result.className = 'alert alert-danger mt-2';
|
||||
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvite(tokenId, btn) {
|
||||
$thisRow = $(btn).closest('tr');
|
||||
$thisRow.addClass('table-warning');
|
||||
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||
|
||||
if(!confirmation){
|
||||
$thisRow.removeClass('table-warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// lets not use `confirm``
|
||||
// if (!confirm('Revoke this invite token?')) return;
|
||||
try {
|
||||
await app.api.delete(`user/invite/${tokenId}`);
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
alert('Failed to revoke invite.');
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(tokenId, btn) {
|
||||
const link = `${location.origin}/login/invite/${tokenId}`;
|
||||
navigator.clipboard.writeText(link).then(function() {
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = orig, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
|
||||
<div class="d-flex align-items-center mb-3 mt-2">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-envelope-open-text"></i> Invites</h4>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Create invite -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-plus"></i> Create Invite
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Email <small class="text-muted">(optional)</small></label>
|
||||
<input type="email" id="create-email" class="form-control shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'create-groups')" />
|
||||
<select id="create-groups" class="form-select shadow" multiple size="5"></select>
|
||||
</div>
|
||||
<button class="btn btn-primary shadow" onclick="createInvite()">
|
||||
<i class="fa-solid fa-paper-plane"></i> Send Invite
|
||||
</button>
|
||||
<div id="create-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite list -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-list"></i> Invite Tokens</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadInvites()">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none;"></div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>By</th>
|
||||
<th>Email</th>
|
||||
<th>Groups</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="invite-tbody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-token" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
||||
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
||||
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
||||
</div>
|
||||
<div id="edit-result" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('impersonate_modal') %>
|
||||
<%- include('bottom') %>
|
||||
@@ -0,0 +1,136 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<style>
|
||||
/* App Portal styling using Bootstrap defaults */
|
||||
.portal-banner {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 3rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.portal-banner h1 {
|
||||
font-weight: 700;
|
||||
}
|
||||
.carousel-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.service-card {
|
||||
min-width: 280px;
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
||||
}
|
||||
.service-card .card-body {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="portal-banner text-center">
|
||||
<h1>SSO Portal</h1>
|
||||
<p class="lead">Explore and access all your services in one place.</p>
|
||||
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
||||
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
||||
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
||||
<div class="card shadow-sm service-card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-success">Access Granted</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
||||
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-secondary">Request Access</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
||||
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
||||
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin();
|
||||
|
||||
$(document).ready(async function() {
|
||||
try {
|
||||
let res = await app.api.get('discovery/me');
|
||||
let allAccessible = res.results || [];
|
||||
|
||||
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
||||
|
||||
let myServices = [];
|
||||
let otherServices = [];
|
||||
let hosts = [];
|
||||
|
||||
allAccessible.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
||||
if (r.kind === 'host') hosts.push(r);
|
||||
});
|
||||
|
||||
if (allRes && allRes.results) {
|
||||
allRes.results.forEach(r => {
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
||||
otherServices.push(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...myServices);
|
||||
|
||||
$.scope.otherservices.empty();
|
||||
$.scope.otherservices.push(...otherServices);
|
||||
|
||||
$.scope.hosts.empty();
|
||||
$.scope.hosts.push(...hosts);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load discovery data:', e);
|
||||
}
|
||||
});
|
||||
|
||||
function requestAccess(id) {
|
||||
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
|
||||
}
|
||||
</script>
|
||||
@@ -89,6 +89,47 @@
|
||||
renderPersonalGroupMembers(currentUser);
|
||||
}
|
||||
|
||||
async function renderMyServices(){
|
||||
try{
|
||||
let res = await app.api.get('discovery/me');
|
||||
res.results.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
|
||||
});
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...res.results);
|
||||
}catch(error){
|
||||
console.error('renderMyServices error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMyMetrics(){
|
||||
try{
|
||||
let uid = currentUser.uid;
|
||||
let res = await app.api.get(`metrics/user/${uid}`);
|
||||
if (res && res.results) {
|
||||
const renderList = (items, id) => {
|
||||
const el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
el.innerHTML = '';
|
||||
if (!items || items.length === 0) {
|
||||
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||
return;
|
||||
}
|
||||
items.forEach(item => {
|
||||
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${item.value}
|
||||
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||
</li>`;
|
||||
});
|
||||
};
|
||||
renderList(res.results.services, 'metrics-user-services');
|
||||
}
|
||||
}catch(error){
|
||||
console.error('renderMyMetrics error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function determinUser(){
|
||||
if(location.pathname.includes('/users/')){
|
||||
let uid = location.pathname.replace('/users/', '');
|
||||
@@ -160,6 +201,11 @@
|
||||
renderProfile(currentUser);
|
||||
renderUserGroups(currentUser);
|
||||
renderPersonalGroupMembers(currentUser);
|
||||
renderMyServices();
|
||||
if(!isOwnProfile) {
|
||||
renderMyMetrics();
|
||||
$('#user-metrics-card').show();
|
||||
}
|
||||
$('#personal-group-uid-label').text(currentUser.uid);
|
||||
|
||||
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
||||
@@ -397,6 +443,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
My Services
|
||||
<div class="float-end">
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
<th>Address / IP</th>
|
||||
<th>Description</th>
|
||||
<th>Kind</th>
|
||||
</thead>
|
||||
<tbody jq-repeat="myservices">
|
||||
<tr>
|
||||
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
|
||||
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
|
||||
<td>{{description}}</td>
|
||||
<td>
|
||||
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
|
||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
|
||||
<div class="card-header shadow bg-success text-white">
|
||||
<i class="fa-solid fa-chart-line"></i>
|
||||
Security & Usage Stats (Last 7 Days)
|
||||
<div class="float-end">
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row m-0">
|
||||
<div class="col-12 p-3">
|
||||
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
|
||||
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
|
||||
<li class="list-group-item text-muted">Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-people-group"></i>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid" style="margin-top: 80px;">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-lg mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-network-wired"></i> Sites & Replication
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4">
|
||||
This page shows the status of Multi-Master LDAP replication peers.
|
||||
<br/>Your Server ID: <strong><%= myId %></strong>
|
||||
</p>
|
||||
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Site LDAP URL</th>
|
||||
<th>Replication Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (sites.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
|
||||
</tr>
|
||||
<% } else { %>
|
||||
<% sites.forEach(function(site) { %>
|
||||
<tr>
|
||||
<td class="align-middle"><strong><%= site.url %></strong></td>
|
||||
<td class="align-middle">
|
||||
<% if (site.status === 'Online') { %>
|
||||
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
|
||||
<% } else { %>
|
||||
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+9
-20
@@ -28,7 +28,7 @@
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
@@ -44,33 +44,22 @@
|
||||
Groups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
|
||||
<a class="nav-link" href="/integrations">
|
||||
<i class="fa-solid fa-plug"></i>
|
||||
Integrations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
||||
<a class="nav-link" href="/invites">
|
||||
<i class="fa-solid fa-envelope-open-text"></i>
|
||||
Invites
|
||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
|
||||
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
|
||||
Directory
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/sites">
|
||||
<i class="fa-solid fa-network-wired"></i>
|
||||
Sites
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/dashboard">
|
||||
<a class="nav-link" href="/executive">
|
||||
<i class="fa-solid fa-gauge-high"></i>
|
||||
Dashboard
|
||||
Executive
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||
|
||||
@@ -64,6 +64,7 @@ async function fetchUsernameSuggestions() {
|
||||
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||
// fields out of both form serialization and validation.
|
||||
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=mobile]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
|
||||
@@ -166,3 +167,18 @@ async function fetchUsernameSuggestions() {
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
||||
</form>
|
||||
<script>
|
||||
$(document).ready(async function() {
|
||||
const $loc = $('[name="location"]');
|
||||
if (!$loc.val()) {
|
||||
try {
|
||||
const dirRes = await app.api.get('directory-admin/resources');
|
||||
const resources = dirRes.results || [];
|
||||
const site = resources.find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||
if (site) {
|
||||
$loc.val(site.name);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+373
-174
@@ -23,6 +23,132 @@
|
||||
renderUsers();
|
||||
});
|
||||
}
|
||||
|
||||
function inviteStatus(t) {
|
||||
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
|
||||
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
|
||||
return { label: 'Pending', cls: 'text-success' };
|
||||
}
|
||||
|
||||
function groupsDisplay(groups) {
|
||||
try {
|
||||
const arr = JSON.parse(groups || '[]');
|
||||
return arr.length ? arr.join(', ') : '—';
|
||||
} catch(_) { return '—'; }
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
try {
|
||||
const data = await app.api.get('user/invite');
|
||||
const tbody = document.getElementById('invite-tbody');
|
||||
tbody.innerHTML = '';
|
||||
(data.results || [])
|
||||
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
|
||||
.forEach(function(t) {
|
||||
const status = inviteStatus(t);
|
||||
const canEdit = true; // all users on this page are admins
|
||||
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
|
||||
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.token = t.token;
|
||||
tr.innerHTML = `
|
||||
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
|
||||
<td><small>${t.created_by}</small></td>
|
||||
<td><small>${mail}</small></td>
|
||||
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
|
||||
<td><small class="${status.cls}">${status.label}</small></td>
|
||||
<td class="text-nowrap text-end">
|
||||
${canEdit && isPending ? `
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
|
||||
<i class="fa-solid fa-ban"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${isPending ? `
|
||||
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch(e) {
|
||||
if (e && e.status === 401) location.replace('/');
|
||||
else console.error('Failed to load invites:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateGroupSelect(id, groups, selected) {
|
||||
const sel = document.getElementById(id);
|
||||
sel.innerHTML = '';
|
||||
groups.forEach(function(cn) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cn;
|
||||
opt.textContent = cn;
|
||||
opt.selected = selected.includes(cn);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(tokenId) {
|
||||
app.api.get('user/invite').then(function(data) {
|
||||
const t = (data.results || []).find(x => x.token === tokenId);
|
||||
if (!t) return;
|
||||
document.getElementById('edit-token').value = tokenId;
|
||||
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
|
||||
const selected = JSON.parse(t.groups || '[]');
|
||||
const allGroups = [...document.getElementById('invite-groups').options].map(o => o.value);
|
||||
populateGroupSelect('edit-groups', allGroups, selected);
|
||||
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const tokenId = document.getElementById('edit-token').value;
|
||||
const mail = document.getElementById('edit-email').value.trim();
|
||||
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
|
||||
const result = document.getElementById('edit-result');
|
||||
result.style.display = 'none';
|
||||
try {
|
||||
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
|
||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
result.style.display = '';
|
||||
result.className = 'alert alert-danger mt-2';
|
||||
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvite(tokenId, btn) {
|
||||
$thisRow = $(btn).closest('tr');
|
||||
$thisRow.addClass('table-warning');
|
||||
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||
if(!confirmation){
|
||||
$thisRow.removeClass('table-warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.api.delete(`user/invite/${tokenId}`);
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
alert('Failed to revoke invite.');
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(tokenId, btn) {
|
||||
const link = `${location.origin}/login/invite/${tokenId}`;
|
||||
navigator.clipboard.writeText(link).then(function() {
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = orig, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(uid, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
@@ -84,6 +210,11 @@
|
||||
}
|
||||
document.getElementById('invite-email').value = '';
|
||||
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
||||
loadInvites();
|
||||
setTimeout(() => {
|
||||
bootstrap.Modal.getInstance(document.getElementById("inviteUserModal"))?.hide();
|
||||
result.style.display = 'none';
|
||||
}, 3000);
|
||||
}catch(e){
|
||||
result.style.display = '';
|
||||
result.className = 'alert alert-danger mt-2';
|
||||
@@ -97,199 +228,267 @@
|
||||
$(document).ready(function(){
|
||||
renderUsers();
|
||||
loadInviteGroups();
|
||||
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
|
||||
loadInvites();
|
||||
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success"); bootstrap.Modal.getInstance(document.getElementById("addUserModal"))?.hide();')
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
<h4><i class="fa-solid fa-users"></i> Users</h4>
|
||||
<div class="container mt-4">
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||
<i class="fa-solid fa-user"></i> People
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
|
||||
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-md-4">
|
||||
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
||||
<div class="card-header shadow">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Invite User
|
||||
<span class="float-end">
|
||||
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||
</div>
|
||||
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
|
||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||
</button>
|
||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Add new user
|
||||
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<%- include('user_form', {adminMode: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
User List
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa-solid fa-users"></i>
|
||||
User List
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2 group-required group-required-app_sso_admin" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
|
||||
<i class="fas fa-envelope"></i> Invite User
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||
<i class="fas fa-user-plus"></i> Add User
|
||||
</button>
|
||||
<a href="/docs/accounts" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>eMail</th>
|
||||
<th>Key</th>
|
||||
<th>Active</th>
|
||||
<th>TOS</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="tableAJAX">
|
||||
<tr jq-repeat="userRow">
|
||||
<td>
|
||||
{{ uidNumber }}
|
||||
</td>
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{mail}}
|
||||
</td>
|
||||
<td>
|
||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td>
|
||||
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
||||
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="px-3 pt-3 border-bottom">
|
||||
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||
<i class="fa-solid fa-user"></i> People
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
|
||||
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-invites-btn" data-bs-toggle="tab" data-bs-target="#tab-invites" type="button" role="tab">
|
||||
<i class="fa-solid fa-envelope-open-text"></i> Invites
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>eMail</th>
|
||||
<th>Key</th>
|
||||
<th>Active</th>
|
||||
<th>TOS</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="tableAJAX">
|
||||
<tr jq-repeat="userRow">
|
||||
<td>
|
||||
{{ uidNumber }}
|
||||
</td>
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{mail}}
|
||||
</td>
|
||||
<td>
|
||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td>
|
||||
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
||||
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add User" form.
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th>Description</th>
|
||||
<th>Manager(s)</th>
|
||||
<th>Created</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="serviceAccountRow">
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{uid}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{description}}
|
||||
</td>
|
||||
<td>
|
||||
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||
</td>
|
||||
<td>
|
||||
{{createTimestamp}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-invites" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Created</th>
|
||||
<th>By</th>
|
||||
<th>Email</th>
|
||||
<th>Groups</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="invite-tbody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-gears"></i>
|
||||
Service Accounts
|
||||
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
|
||||
<!-- Invite User Modal -->
|
||||
<div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title" id="inviteUserModalLabel"><i class="fas fa-user-plus"></i> Invite User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||
</div>
|
||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" onclick="sendInvite()" class="btn btn-primary shadow">
|
||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title" id="addUserModalLabel">
|
||||
<i class="fas fa-user-plus"></i> Add new user
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted mb-3">Check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab.</p>
|
||||
<%- include('user_form', {adminMode: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Invite Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-token" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
||||
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th>Description</th>
|
||||
<th>Manager(s)</th>
|
||||
<th>Created</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="serviceAccountRow">
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{uid}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{description}}
|
||||
</td>
|
||||
<td>
|
||||
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||
</td>
|
||||
<td>
|
||||
{{createTimestamp}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
||||
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
||||
</div>
|
||||
<div id="edit-result" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('impersonate_modal') %>
|
||||
<%- include('bottom') %>
|
||||
|
||||
Reference in New Issue
Block a user