aaa538c7f9
tos.md was baked into the repo and read once at startup, so changing the terms required a code change and deploy. It's now a Redis-backed singleton (models/tos.js), editable from a new "Terms of Service" card on the admin Dashboard, with the bundled tos.md used only as a one-time seed for new deployments. - routes/tos.js: GET (any authenticated user) / PUT (app_sso_admin only) via /api/tos. Saving can optionally reset every user's tos_accepted flag so they're asked to re-accept -- off by default, since a wording fix shouldn't re-prompt everyone. - routes/index.js: /tos and /onboarding now render the live content instead of a module-level constant computed once at process start.
443 lines
16 KiB
Plaintext
443 lines
16 KiB
Plaintext
<%- 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') %>
|