Add dashboard audit log and configuration management

- Implemented dashboard audit logging with InsertAuditLog, GetRecentAuditLogs, GetAuditLogsByIP, and CleanupOldAuditLogs methods.
- Created dashboard configuration management with GetDashboardConfig and SetDashboardConfig methods.
- Added new tables for dashboard_audit_log and dashboard_config in both MySQL and SQLite migrations.
- Updated CMakeLists to include Crow and ASIO for dashboard server functionality.
- Enhanced existing database classes to support new dashboard features, including character, play key, and property management.
- Added new methods for retrieving and managing play keys, properties, and pet names.
- Updated TestSQLDatabase to include stubs for new dashboard-related methods.
- Modified shared and dashboard configuration files for new settings.
This commit is contained in:
Aaron Kimbrell
2026-04-22 11:01:41 -05:00
parent d532a9b063
commit e3467465b4
92 changed files with 9133 additions and 77 deletions

View File

@@ -0,0 +1,162 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-people"></i>
Account Management
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Accounts</h5>
</div>
<div class="card-body">
<table id="accounts-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>GM Level</th>
<th>Banned</th>
<th>Locked</th>
<th>Muted Until</th>
<th>Play Key ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Wait for jQuery + DataTables to be available without copying libraries locally.
// Poll for a limited time and show a helpful error if they fail to load.
function showLibraryError(message) {
const el = document.getElementById('accounts-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
// Use the same pattern as Recent Activity: wait for DOMContentLoaded, check auth, then fetch data
function loadAccounts() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 3) {
showLibraryError('You do not have permission to view accounts. Please log in with sufficient GM level.');
return;
}
API.get('/api/accounts').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#accounts-table')) {
const table = $('#accounts-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
const table = $('#accounts-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'name', render: function(d, t, row) { return `<a href="/accounts/view/${row.id}">${d}</a>`; } },
{ data: 'gm_level', render: function(d) { const badges={0:'secondary',1:'info',2:'primary',3:'success',4:'success',5:'warning',6:'warning',7:'warning',8:'danger',9:'danger'}; return `<span class="badge bg-${badges[d]||'secondary'}">${d}</span>`; } },
{ data: 'banned', render: d => d ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'locked', render: d => d ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'mute_expire', render: function(d) { if (!d || d === 0) return '-'; return new Date(d * 1000).toLocaleString(); } },
{ data: 'play_key_id' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<div class="btn-group btn-group-sm" role="group">
<a href="/accounts/view/${row.id}" class="btn btn-info" title="View"><i class="bi bi-eye"></i></a>
<button data-account-id="${row.id}" class="btn btn-warning js-toggle-lock" title="Lock/Unlock"><i class="bi bi-lock"></i></button>
<button data-account-id="${row.id}" class="btn btn-danger js-toggle-ban" title="Ban/Unban"><i class="bi bi-slash-circle"></i></button>
<button data-account-id="${row.id}" class="btn btn-secondary js-mute-account" title="Mute"><i class="bi bi-mic-mute"></i></button>
</div>`;
} }
],
pageLength: 25,
order: [[0, 'asc']],
processing: true
});
// Delegated event handlers
$('#accounts-table').on('click', '.js-toggle-lock', function() { const id = $(this).data('account-id'); toggleLock(id, table); });
$('#accounts-table').on('click', '.js-toggle-ban', function() { const id = $(this).data('account-id'); toggleBan(id, table); });
$('#accounts-table').on('click', '.js-mute-account', function() { const id = $(this).data('account-id'); muteAccount(id, table); });
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load accounts';
showLibraryError(`Error loading accounts: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadAccounts();
}, { requireApi: true, timeout: 8000 });
async function toggleLock(accountId, table) {
if (!confirm('Are you sure you want to toggle the lock status for this account?')) return;
try {
const result = await API.post(`/api/accounts/${accountId}/lock`);
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account lock status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function toggleBan(accountId, table) {
if (!confirm('Are you sure you want to toggle the ban status for this account?')) return;
try {
const result = await API.post(`/api/accounts/${accountId}/ban`);
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account ban status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function muteAccount(accountId, table) {
const days = prompt('Enter number of days to mute (0 to unmute):');
if (days === null) return;
try {
const result = await API.post(`/api/accounts/${accountId}/mute`, { days: parseInt(days) });
if (result.success) {
if (table && table.ajax) table.ajax.reload();
showAlert('success', 'Account mute status updated');
} else {
showAlert('danger', result.error || 'Failed to update account');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
</script>

View File

@@ -0,0 +1,214 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-person-circle"></i> Account Details</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">Account Info</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">ID</dt><dd class="col-sm-8" id="acct-id">-</dd>
<dt class="col-sm-4">Username</dt><dd class="col-sm-8" id="acct-name">-</dd>
<dt class="col-sm-4">Email</dt><dd class="col-sm-8" id="acct-email">-</dd>
<dt class="col-sm-4">GM Level</dt><dd class="col-sm-8" id="acct-gm">-</dd>
<dt class="col-sm-4">Banned</dt><dd class="col-sm-8" id="acct-banned">-</dd>
<dt class="col-sm-4">Locked</dt><dd class="col-sm-8" id="acct-locked">-</dd>
<dt class="col-sm-4">Mute Expire</dt><dd class="col-sm-8" id="acct-mute">-</dd>
<dt class="col-sm-4">Play Key ID</dt><dd class="col-sm-8" id="acct-playkey">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Administrative Actions</div>
<div class="card-body">
<button class="btn btn-danger mb-2" id="delete-account">Delete Account</button>
<hr>
<div class="mb-3">
<label class="form-label">Set GM Level</label>
<input type="number" id="gm-level-input" class="form-control" min="0" max="9">
<button class="btn btn-primary mt-2" id="set-gm">Update GM Level</button>
</div>
<div class="mb-3">
<label class="form-label">Update Email</label>
<input type="email" id="email-input" class="form-control">
<button class="btn btn-primary mt-2" id="set-email">Update Email</button>
</div>
<div class="mb-3">
<label class="form-label">Reset Password</label>
<input type="password" id="password-input" class="form-control">
<button class="btn btn-primary mt-2" id="reset-password">Reset Password</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Characters</div>
<div class="card-body">
<table id="characters-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Level</th>
<th>Map</th>
<th>Last Login</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Sessions</div>
<div class="card-body">
<table id="sessions-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>IP Address</th>
<th>Login Time</th>
<th>Logout Time</th>
<th>Active</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const accountId = (window.location.pathname.split('/').pop() || '').trim();
async function loadAccount() {
try {
const res = await API.get(`/api/accounts/${accountId}`);
if (res && res.success) {
$('#acct-id').text(res.id);
$('#acct-name').text(res.name);
$('#acct-email').text(res.email || '-');
$('#acct-gm').text(res.gm_level);
$('#acct-banned').html(res.banned ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#acct-locked').html(res.locked ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#acct-mute').text(res.mute_expire && res.mute_expire>0 ? new Date(res.mute_expire*1000).toLocaleString() : '-');
$('#acct-playkey').text(res.play_key_id || '-');
$('#gm-level-input').val(res.gm_level);
$('#email-input').val(res.email || '');
// Load related data
loadCharacters();
loadSessions();
} else {
alert(res.error || 'Failed to load account');
}
} catch (err) { alert(err.message); }
}
async function loadCharacters() {
try {
const res = await API.get(`/api/accounts/${accountId}/characters`);
const data = (res && Array.isArray(res.data)) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#characters-table')) {
const table = $('#characters-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#characters-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'name', render: function(d, t, row) { return `<a href="/characters/view/${row.id}">${d}</a>`; } },
{ data: 'level' },
{ data: 'map_id' },
{ data: 'last_login', render: d => d ? new Date(d * 1000).toLocaleString() : '-' }
],
order: [[0, 'desc']],
pageLength: 10
});
}
} catch (err) {
console.error('Failed to load characters', err);
}
}
async function loadSessions() {
try {
const res = await API.get(`/api/accounts/${accountId}/sessions`);
const data = (res && Array.isArray(res.data)) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#sessions-table')) {
const table = $('#sessions-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#sessions-table').DataTable({
data: data,
columns: [
{ data: 'session_id' },
{ data: 'ip_address' },
{ data: 'login_time', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'logout_time', render: d => d && d>0 ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'active', render: d => d ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>' }
],
order: [[2, 'desc']],
pageLength: 10
});
}
} catch (err) {
console.error('Failed to load sessions', err);
}
}
// Initialize when libraries are ready (API used, jQuery optional)
safeInit(function($) {
loadAccount();
document.getElementById('delete-account').addEventListener('click', async function() {
if (!confirm('Delete this account? This action is irreversible.')) return;
try {
const res = await API.post(`/api/accounts/${accountId}/delete`, {});
if (res && res.success) {
alert('Account deleted');
window.location.href = '/accounts';
} else {
alert(res.error || 'Failed to delete');
}
} catch (err) { alert(err.message); }
});
document.getElementById('set-gm').addEventListener('click', async function() {
const lvl = parseInt(document.getElementById('gm-level-input').value);
try {
const res = await API.post(`/api/accounts/${accountId}/gm-level`, { gm_level: lvl });
if (res && res.success) { alert('GM level updated'); loadAccount(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('set-email').addEventListener('click', async function() {
const email = document.getElementById('email-input').value.trim();
try {
const res = await API.post(`/api/accounts/${accountId}/email`, { email: email });
if (res && res.success) { alert('Email updated'); loadAccount(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('reset-password').addEventListener('click', async function() {
const pw = document.getElementById('password-input').value;
if (!pw || pw.length < 8) { alert('Password must be at least 8 characters'); return; }
try {
const res = await API.post(`/api/accounts/${accountId}/password-reset`, { password: pw });
if (res && res.success) { alert('Password reset'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
}, { requireApi: true, timeout: 8000 });
</script>