f72e8881cf
Auditing mobile/responsive views turned up two classes of bug: 1. Missing .table-responsive wrapper (token.ejs and profile.ejs's other table already had it): admin.ejs (2 tables), invites.ejs (6 columns), notifications.ejs (5 columns), users.ejs (7 columns — the worst case). Without it these overflow the page horizontally on narrow viewports instead of scrolling within the table. 2. groups.ejs's search/sort/count toolbar was a plain `d-flex` (no flex-wrap) containing a search input, a <select> with a hard min-width:175px, and a nowrap count span — on a narrow viewport there's nowhere for that content to go but off-screen. Added flex-wrap and gave the search input a flex-basis so it wraps onto its own line first when space is tight. Verified: EJS compiles for all five templates, npm test 192/192 pass, and fetched each route from a running instance to confirm the fixes are present in the served HTML.
173 lines
5.3 KiB
Plaintext
173 lines
5.3 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<script type="text/javascript">
|
|
app.auth.forceLogin('app_sso_admin');
|
|
|
|
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').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();
|
|
}
|
|
|
|
$(document).ready(function() {
|
|
loadDashboard();
|
|
});
|
|
</script>
|
|
|
|
<div id="dashboard" class="row" style="display:none">
|
|
<div class="col-12">
|
|
|
|
<!-- Header -->
|
|
<div class="d-flex justify-content-between align-items-center mb-3 mt-2">
|
|
<h4 class="mb-0"><i class="fa-solid fa-screwdriver-wrench"></i> Admin Dashboard</h4>
|
|
<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>
|
|
|
|
<%- include('impersonate_modal') %>
|
|
<%- include('bottom') %>
|