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,102 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-info-circle"></i>
About DarkflameServer Dashboard
</h1>
</div>
</div>
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Dashboard Information</h5>
</div>
<div class="card-body">
<h4 class="mb-3">DarkflameServer Web Dashboard</h4>
<p class="lead">
A modern C++ web interface for managing your Darkflame Universe server.
</p>
<hr class="my-4">
<h5>Features</h5>
<ul>
<li><strong>Account Management:</strong> Create, modify, ban, lock, and mute player accounts</li>
<li><strong>Character Management:</strong> View, rescue, and manage player characters</li>
<li><strong>Moderation Tools:</strong> Approve pet names, manage properties, and review bug reports</li>
<li><strong>Mail System:</strong> Send in-game mail to players with item attachments</li>
<li><strong>Play Keys:</strong> Manage registration keys for new accounts</li>
<li><strong>Activity Logs:</strong> Monitor player activity and track logins/logouts</li>
<li><strong>Audit Trail:</strong> Track all administrative actions for accountability</li>
</ul>
<hr class="my-4">
<h5>Technology Stack</h5>
<ul>
<li><strong>Backend:</strong> C++ with Crow web framework</li>
<li><strong>Frontend:</strong> Bootstrap 5, jQuery, DataTables</li>
<li><strong>Templates:</strong> Mustache templating engine</li>
<li><strong>Database:</strong> MySQL/MariaDB or SQLite</li>
</ul>
<hr class="my-4">
<h5>GM Levels</h5>
<dl class="row">
<dt class="col-sm-3">Level 0</dt>
<dd class="col-sm-9"><span class="badge bg-secondary">Civilian</span> - Regular player</dd>
<dt class="col-sm-3">Level 1</dt>
<dd class="col-sm-9"><span class="badge bg-info">Forum Moderator</span> - Forum moderation only</dd>
<dt class="col-sm-3">Level 2</dt>
<dd class="col-sm-9"><span class="badge bg-primary">Junior Moderator</span> - Basic moderation tools</dd>
<dt class="col-sm-3">Level 3</dt>
<dd class="col-sm-9"><span class="badge bg-success">Moderator</span> - Full moderation access</dd>
<dt class="col-sm-3">Level 4</dt>
<dd class="col-sm-9"><span class="badge bg-success">Senior Moderator</span> - Advanced moderation</dd>
<dt class="col-sm-3">Level 5</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Lead Moderator</span> - Moderation leadership</dd>
<dt class="col-sm-3">Level 6</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Junior Developer</span> - Development access</dd>
<dt class="col-sm-3">Level 7</dt>
<dd class="col-sm-9"><span class="badge bg-warning">Inactive Developer</span> - Limited dev access</dd>
<dt class="col-sm-3">Level 8</dt>
<dd class="col-sm-9"><span class="badge bg-danger">Developer</span> - Full development access</dd>
<dt class="col-sm-3">Level 9</dt>
<dd class="col-sm-9"><span class="badge bg-danger">Operator</span> - Full system access</dd>
</dl>
<hr class="my-4">
<h5>About Darkflame Universe</h5>
<p>
DarkflameServer is an open-source server emulator for LEGO Universe,
a massively multiplayer online game that was officially discontinued in 2012.
The Darkflame Universe project aims to preserve and revive this beloved game
for fans to continue enjoying.
</p>
<div class="d-grid gap-2 d-md-flex justify-content-md-start mt-4">
<a href="https://github.com/DarkflameUniverse/DarkflameServer" target="_blank" class="btn btn-primary">
<i class="bi bi-github"></i> GitHub Repository
</a>
<a href="https://github.com/DarkflameUniverse/DarkflameServer/tree/main/docs" target="_blank" class="btn btn-secondary">
<i class="bi bi-book"></i> Documentation
</a>
</div>
</div>
</div>
</div>
</div>

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>

View File

@@ -0,0 +1,151 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-bug"></i> Bug Reports</h1>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<div class="list-group" id="report-filter">
<button class="list-group-item list-group-item-action active" data-status="all">All</button>
<button class="list-group-item list-group-item-action" data-status="unresolved">Unresolved</button>
<button class="list-group-item list-group-item-action" data-status="resolved">Resolved</button>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">Reports</div>
<div class="card-body">
<table id="bugreports-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Character</th>
<th>Submitted</th>
<th>Resolved</th>
<th>Summary</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal for resolving -->
<div class="modal" tabindex="-1" id="resolveModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Resolve Bug Report</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Resolution Message</label>
<textarea id="resolution-text" class="form-control" rows="4"></textarea>
</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" id="resolve-confirm">Resolve</button>
</div>
</div>
</div>
</div>
<script>
let currentStatus = 'all';
let currentResolveId = 0;
function loadTable() {
API.get('/api/bugreports', { status: currentStatus }).then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#bugreports-table')) {
const table = $('#bugreports-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#bugreports-table').DataTable({
data: data,
destroy: true,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'resolved_time', render: d => d && d>0 ? new Date(d * 1000).toLocaleString() : '-' },
{ data: 'body', render: d => d ? d.substring(0,120) : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
let actions = '';
if (!row.resolved_time || row.resolved_time == 0) {
actions += `<button class="btn btn-sm btn-success" onclick="openResolve(${row.id})">Resolve</button>`;
}
actions += ` <button class="btn btn-sm btn-info" onclick="viewReport(${row.id})">View</button>`;
return actions;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => {
alert(err && err.message ? err.message : 'Failed to load bug reports');
});
}
// Initialize when libraries are ready
safeInit(function($) {
loadTable();
// Filter clicks
$('#report-filter button').on('click', function() {
$('#report-filter button').removeClass('active');
$(this).addClass('active');
currentStatus = $(this).data('status');
loadTable();
});
// Resolve confirm
$('#resolve-confirm').on('click', async function() {
const resolution = $('#resolution-text').val().trim();
if (!resolution) { alert('Resolution message required'); return; }
try {
const res = await API.post(`/api/bugreports/${currentResolveId}/resolve`, { resolution: resolution });
if (res && res.success) {
$('#resolveModal').modal('hide');
loadTable();
alert('Bug report resolved');
} else {
alert(res.error || 'Failed to resolve');
}
} catch (err) {
alert(err.message);
}
});
}, { requireApi: true, timeout: 8000 });
function openResolve(id) {
currentResolveId = id;
$('#resolution-text').val('');
var modal = new bootstrap.Modal(document.getElementById('resolveModal'));
modal.show();
}
async function viewReport(id) {
try {
const res = await API.get(`/api/bugreports/${id}`);
if (res && res.success) {
const text = `ID: ${res.id}\nCharacter: ${res.character_name}\nSubmitted: ${res.submitted?new Date(res.submitted*1000).toLocaleString():''}\n\n${res.body}`;
alert(text);
} else {
alert(res.error || 'Failed to get report');
}
} catch (err) {
alert(err.message);
}
}
</script>

View File

@@ -0,0 +1,163 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-person-badge"></i>
Character Management
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Characters</h5>
</div>
<div class="card-body">
<table id="characters-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Account</th>
<th>Name</th>
<th>Pending Name</th>
<th>Needs Rename</th>
<th>Last Login</th>
<th>Permission Map</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
function showLibraryError(message) {
const el = document.getElementById('characters-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadCharacters() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 3) {
showLibraryError('You do not have permission to view characters. Please log in with sufficient GM level.');
return;
}
API.get('/api/characters').then(res => {
const data = 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 {
const table = $('#characters-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'account_name', render: function(d, t, row) {
return row.account_id ? `<a href="/accounts/view/${row.account_id}">${d || row.account_id}</a>` : (d || '-');
}},
{ data: 'name', render: function(d, t, row) {
return `<a href="/characters/view/${row.id}">${d}</a>`;
}},
{ data: 'pending_name', render: d => d || '-' },
{ data: 'needs_rename', render: d => d ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>' },
{ data: 'last_login', render: function(d) {
if (!d || d === 0) return 'Never';
return new Date(d * 1000).toLocaleString();
}},
{ data: 'permission_map', render: d => d || '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<div class="btn-group btn-group-sm" role="group">
<a href="/characters/view/${row.id}" class="btn btn-info" title="View"><i class="bi bi-eye"></i></a>
<button data-char-id="${row.id}" class="btn btn-warning js-rescue-char" title="Rescue Character"><i class="bi bi-life-preserver"></i></button>
<button data-char-id="${row.id}" class="btn btn-danger js-delete-char" title="Delete Character"><i class="bi bi-trash"></i></button>
</div>`;
}}
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
// Delegated event handlers
$('#characters-table').on('click', '.js-rescue-char', function() {
const id = $(this).data('char-id');
rescueCharacter(id, table);
});
$('#characters-table').on('click', '.js-delete-char', function() {
const id = $(this).data('char-id');
deleteCharacter(id, table);
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load characters';
showLibraryError(`Error loading characters: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadCharacters();
}, { requireApi: true, timeout: 8000 });
async function rescueCharacter(charId, table) {
if (!confirm('Are you sure you want to rescue this character? This will move them to a safe location.')) return;
try {
const result = await API.post(`/api/characters/${charId}/rescue`);
if (result.success) {
showAlert('success', 'Character rescued successfully');
if (table && table.ajax) table.ajax.reload();
} else {
showAlert('danger', result.error || 'Failed to rescue character');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
async function deleteCharacter(charId, table) {
const confirmMsg = 'Are you sure you want to DELETE this character? This action is irreversible!';
if (!confirm(confirmMsg)) return;
const doubleConfirm = prompt('Type "DELETE" to confirm:');
if (doubleConfirm !== 'DELETE') {
showAlert('info', 'Deletion cancelled');
return;
}
try {
const result = await API.post(`/api/characters/${charId}/delete`);
if (result.success) {
showAlert('success', 'Character deleted');
if (table && table.ajax) table.ajax.reload();
} else {
showAlert('danger', result.error || 'Failed to delete character');
}
} catch (error) {
showAlert('danger', error.message || error);
}
}
</script>

View File

@@ -0,0 +1,314 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-person-badge"></i> Character Details</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">Character Info</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-5">ID</dt><dd class="col-sm-7" id="char-id">-</dd>
<dt class="col-sm-5">Name</dt><dd class="col-sm-7" id="char-name">-</dd>
<dt class="col-sm-5">Pending Name</dt><dd class="col-sm-7" id="char-pending-name">-</dd>
<dt class="col-sm-5">Account</dt><dd class="col-sm-7" id="char-account">-</dd>
<dt class="col-sm-5">Level</dt><dd class="col-sm-7" id="char-level">-</dd>
<dt class="col-sm-5">Universe Score</dt><dd class="col-sm-7" id="char-uscore">-</dd>
<dt class="col-sm-5">Current Zone</dt><dd class="col-sm-7" id="char-zone">-</dd>
<dt class="col-sm-5">Last Login</dt><dd class="col-sm-7" id="char-last-login">-</dd>
<dt class="col-sm-5">Created</dt><dd class="col-sm-7" id="char-created">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Restrictions</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-5">Mail Restricted</dt><dd class="col-sm-7" id="char-mail-restricted">-</dd>
<dt class="col-sm-5">Trade Restricted</dt><dd class="col-sm-7" id="char-trade-restricted">-</dd>
<dt class="col-sm-5">Chat Restricted</dt><dd class="col-sm-7" id="char-chat-restricted">-</dd>
<dt class="col-sm-5">Needs Rename</dt><dd class="col-sm-7" id="char-needs-rename">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Administrative Actions</div>
<div class="card-body">
<button class="btn btn-warning mb-2 w-100" id="rescue-char">
<i class="bi bi-life-preserver"></i> Rescue Character
</button>
<button class="btn btn-primary mb-2 w-100" id="approve-name">
<i class="bi bi-check-circle"></i> Approve Pending Name
</button>
<button class="btn btn-danger mb-2 w-100" id="delete-char">
<i class="bi bi-trash"></i> Delete Character
</button>
<hr>
<div class="mb-3">
<label class="form-label">Toggle Mail Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-mail">Toggle Mail</button>
</div>
<div class="mb-3">
<label class="form-label">Toggle Trade Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-trade">Toggle Trade</button>
</div>
<div class="mb-3">
<label class="form-label">Toggle Chat Restriction</label>
<button class="btn btn-secondary w-100" id="toggle-chat">Toggle Chat</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="stats-tab" data-bs-toggle="tab" href="#stats" role="tab">Stats</a>
</li>
<li class="nav-item">
<a class="nav-link" id="inventory-tab" data-bs-toggle="tab" href="#inventory" role="tab">Inventory</a>
</li>
<li class="nav-item">
<a class="nav-link" id="activity-tab" data-bs-toggle="tab" href="#activity" role="tab">Activity</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="stats" role="tabpanel">
<h6>Character Statistics</h6>
<div id="char-stats-content">
<dl class="row">
<dt class="col-sm-6">Total Currency Collected</dt><dd class="col-sm-6" id="stat-currency">-</dd>
<dt class="col-sm-6">Total Bricks Collected</dt><dd class="col-sm-6" id="stat-bricks">-</dd>
<dt class="col-sm-6">Total Smashables</dt><dd class="col-sm-6" id="stat-smashables">-</dd>
<dt class="col-sm-6">Total Quick Builds</dt><dd class="col-sm-6" id="stat-quickbuilds">-</dd>
<dt class="col-sm-6">Total Enemies Smashed</dt><dd class="col-sm-6" id="stat-enemies">-</dd>
<dt class="col-sm-6">Total Rockets Used</dt><dd class="col-sm-6" id="stat-rockets">-</dd>
<dt class="col-sm-6">Total Missions Completed</dt><dd class="col-sm-6" id="stat-missions">-</dd>
<dt class="col-sm-6">Total Pets Tamed</dt><dd class="col-sm-6" id="stat-pets">-</dd>
</dl>
</div>
</div>
<div class="tab-pane fade" id="inventory" role="tabpanel">
<h6>Inventory Items</h6>
<div id="char-inventory-content">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Item ID</th>
<th>Count</th>
<th>Slot</th>
</tr>
</thead>
<tbody id="inventory-tbody">
<tr><td colspan="3" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="activity" role="tabpanel">
<h6>Recent Activity</h6>
<div id="char-activity-content">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Timestamp</th>
<th>Activity</th>
<th>Map</th>
</tr>
</thead>
<tbody id="activity-tbody">
<tr><td colspan="3" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const characterId = (window.location.pathname.split('/').pop() || '').trim();
async function loadCharacter() {
try {
const res = await API.get(`/api/characters/${characterId}`);
if (res && res.success) {
$('#char-id').text(res.id);
$('#char-name').text(res.name);
$('#char-pending-name').text(res.pending_name || '-');
if (res.account_id) {
$('#char-account').html(`<a href="/accounts/view/${res.account_id}">${res.account_name || res.account_id}</a>`);
} else {
$('#char-account').text('-');
}
$('#char-level').text(res.level || 0);
$('#char-uscore').text(res.uscore || 0);
$('#char-zone').text(res.zone_id || '-');
$('#char-last-login').text(res.last_login && res.last_login > 0 ? new Date(res.last_login * 1000).toLocaleString() : 'Never');
$('#char-created').text(res.created_on ? new Date(res.created_on * 1000).toLocaleString() : '-');
// Restrictions
$('#char-mail-restricted').html(res.mail_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-trade-restricted').html(res.trade_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-chat-restricted').html(res.chat_restricted ? '<span class="badge bg-danger">Yes</span>' : '<span class="badge bg-success">No</span>');
$('#char-needs-rename').html(res.needs_rename ? '<span class="badge bg-warning">Yes</span>' : '<span class="badge bg-success">No</span>');
// Load related data
loadCharacterStats();
loadCharacterActivity();
} else {
alert(res.error || 'Failed to load character');
}
} catch (err) {
alert(err.message || 'Error loading character');
}
}
async function loadCharacterStats() {
try {
const res = await API.get(`/api/characters/${characterId}/stats`);
if (res && res.success) {
$('#stat-currency').text(res.total_currency_collected || 0);
$('#stat-bricks').text(res.total_bricks_collected || 0);
$('#stat-smashables').text(res.total_smashables || 0);
$('#stat-quickbuilds').text(res.total_quickbuilds_completed || 0);
$('#stat-enemies').text(res.total_enemies_smashed || 0);
$('#stat-rockets').text(res.total_rockets_used || 0);
$('#stat-missions').text(res.total_missions_completed || 0);
$('#stat-pets').text(res.total_pets_tamed || 0);
}
} catch (err) {
console.error('Failed to load character stats', err);
}
}
async function loadCharacterActivity() {
try {
const res = await API.get(`/api/characters/${characterId}/activity`);
const data = (res && Array.isArray(res.data)) ? res.data : [];
const tbody = $('#activity-tbody');
tbody.empty();
if (data.length === 0) {
tbody.append('<tr><td colspan="3" class="text-center">No activity found</td></tr>');
} else {
data.forEach(activity => {
const row = $('<tr>');
row.append($('<td>').text(new Date(activity.timestamp * 1000).toLocaleString()));
row.append($('<td>').text(activity.activity));
row.append($('<td>').text(activity.map_id));
tbody.append(row);
});
}
} catch (err) {
console.error('Failed to load character activity', err);
$('#activity-tbody').html('<tr><td colspan="3" class="text-center text-danger">Failed to load activity</td></tr>');
}
}
// Load inventory when the tab is clicked
$('#inventory-tab').on('shown.bs.tab', async function() {
try {
const res = await API.get(`/api/characters/${characterId}/inventory`);
const data = (res && Array.isArray(res.data)) ? res.data : [];
const tbody = $('#inventory-tbody');
tbody.empty();
if (data.length === 0) {
tbody.append('<tr><td colspan="3" class="text-center">No items found</td></tr>');
} else {
data.forEach(item => {
const row = $('<tr>');
row.append($('<td>').text(item.item_id));
row.append($('<td>').text(item.count || 1));
row.append($('<td>').text(item.slot || '-'));
tbody.append(row);
});
}
} catch (err) {
console.error('Failed to load inventory', err);
$('#inventory-tbody').html('<tr><td colspan="3" class="text-center text-danger">Failed to load inventory</td></tr>');
}
});
// Initialize when libraries are ready
safeInit(function($) {
loadCharacter();
document.getElementById('rescue-char').addEventListener('click', async function() {
if (!confirm('Rescue this character to a safe location?')) return;
try {
const res = await API.post(`/api/characters/${characterId}/rescue`, {});
if (res && res.success) {
alert('Character rescued');
loadCharacter();
} else {
alert(res.error || 'Failed to rescue character');
}
} catch (err) { alert(err.message); }
});
document.getElementById('approve-name').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/approve-name`, {});
if (res && res.success) {
alert('Name approved');
loadCharacter();
} else {
alert(res.error || 'Failed to approve name');
}
} catch (err) { alert(err.message); }
});
document.getElementById('delete-char').addEventListener('click', async function() {
const confirmMsg = 'DELETE this character? This action is irreversible!';
if (!confirm(confirmMsg)) return;
const doubleConfirm = prompt('Type "DELETE" to confirm:');
if (doubleConfirm !== 'DELETE') return;
try {
const res = await API.post(`/api/characters/${characterId}/delete`, {});
if (res && res.success) {
alert('Character deleted');
window.location.href = '/characters';
} else {
alert(res.error || 'Failed to delete');
}
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-mail').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-mail`, {});
if (res && res.success) { alert('Mail restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-trade').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-trade`, {});
if (res && res.success) { alert('Trade restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
document.getElementById('toggle-chat').addEventListener('click', async function() {
try {
const res = await API.post(`/api/characters/${characterId}/toggle-chat`, {});
if (res && res.success) { alert('Chat restriction toggled'); loadCharacter(); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
});
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -0,0 +1,293 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">Dashboard</h1>
</div>
</div>
{{#is_authenticated}}
<div class="row">
<!-- Account Info Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-circle text-primary"></i>
Your Account
</h5>
<p class="card-text">
<strong>Username:</strong> {{username}}<br>
<strong>Account ID:</strong> {{account_id}}<br>
<strong>GM Level:</strong> {{gm_level}} ({{gm_level_name}})
</p>
</div>
</div>
</div>
{{#is_gm_3_plus}}
<!-- Server Stats Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-server text-success"></i>
Server Status
</h5>
<div id="server-stats">
<p class="card-text">
<strong>Master:</strong> <span id="master-status" class="badge bg-secondary">Loading...</span><br>
<strong>Connected Clients:</strong> <span id="client-count">-</span><br>
<strong>Packets Sent:</strong> <span id="packets-sent">-</span><br>
<strong>Packets Received:</strong> <span id="packets-received">-</span>
</p>
</div>
</div>
</div>
</div>
<!-- Accounts Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-people text-info"></i>
Accounts
</h5>
<p class="card-text">
<strong>Total Accounts:</strong> <span id="total-accounts">-</span><br>
<strong>Banned:</strong> <span id="banned-accounts">-</span><br>
<strong>Locked:</strong> <span id="locked-accounts">-</span>
</p>
<a href="/accounts" class="btn btn-sm btn-primary">Manage Accounts</a>
</div>
</div>
</div>
<!-- Characters Card -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card stats-card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-badge text-warning"></i>
Characters
</h5>
<p class="card-text">
<strong>Total Characters:</strong> <span id="total-characters">-</span><br>
<strong>Pending Names:</strong> <span id="pending-names">-</span>
</p>
<a href="/characters" class="btn btn-sm btn-primary">Manage Characters</a>
</div>
</div>
</div>
{{/is_gm_3_plus}}
</div>
{{#is_gm_3_plus}}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-activity"></i>
Recent Activity
</h5>
</div>
<div class="card-body">
<table id="recent-activity-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Time</th>
<th>Character</th>
<th>Activity</th>
<th>Map</th>
</tr>
</thead>
<tbody>
<!-- Populated via API -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{{/is_gm_3_plus}}
<!-- Character Cards for All Authenticated Users -->
<div class="row mt-4">
<div class="col-12">
<h3 class="mb-3">
<i class="bi bi-person-badge"></i>
Your Characters
</h3>
<hr>
</div>
</div>
<div class="row" id="character-cards-container">
<!-- Character cards will be populated via JavaScript -->
<div class="col-12 text-center">
<p class="text-muted">Loading characters...</p>
</div>
</div>
{{/is_authenticated}}
{{^is_authenticated}}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-body text-center">
<h3>Welcome to DarkflameServer Dashboard</h3>
<p class="lead">Please log in to access the dashboard.</p>
<a href="/login" class="btn btn-primary btn-lg">Login</a>
</div>
</div>
</div>
</div>
{{/is_authenticated}}
<script>
{{#is_gm_3_plus}}
// Load dashboard stats
async function loadDashboardStats() {
try {
// Server stats
const serverStats = await API.get('/api/stats/server');
if (serverStats) {
updateServerStats(serverStats);
}
// Account stats
const accountStats = await API.get('/api/stats/accounts');
if (accountStats) {
updateAccountStats(accountStats);
}
// Character stats
const characterStats = await API.get('/api/stats/characters');
if (characterStats) {
updateCharacterStats(characterStats);
}
// Recent activity
const activities = await API.get('/api/stats/recent-activity');
if (activities && activities.data) {
updateRecentActivity(activities.data);
}
} catch (error) {
console.error('Error loading dashboard stats:', error);
}
}
// Update server stats on UI
function updateServerStats(data) {
document.getElementById('master-status').textContent = data.master_connected ? 'Connected' : 'Disconnected';
document.getElementById('master-status').className = data.master_connected ? 'badge bg-success' : 'badge bg-danger';
document.getElementById('client-count').textContent = data.connected_clients || 0;
document.getElementById('packets-sent').textContent = data.packets_sent || 0;
document.getElementById('packets-received').textContent = data.packets_received || 0;
}
// Update account stats on UI
function updateAccountStats(data) {
document.getElementById('total-accounts').textContent = data.total || 0;
document.getElementById('banned-accounts').textContent = data.banned || 0;
document.getElementById('locked-accounts').textContent = data.locked || 0;
}
// Update character stats on UI
function updateCharacterStats(data) {
document.getElementById('total-characters').textContent = data.total || 0;
document.getElementById('pending-names').textContent = data.pending_names || 0;
}
// Update recent activity table
function updateRecentActivity(data) {
// Avoid reinitialising the DataTable on repeated calls (e.g. interval refreshs).
// If the table already exists, update its data and redraw. Otherwise initialize it.
if ($.fn.DataTable.isDataTable('#recent-activity-table')) {
const table = $('#recent-activity-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
const table = $('#recent-activity-table').DataTable({
data: data,
columns: [
{ data: 'timestamp' },
{ data: 'character_name' },
{ data: 'activity' },
{ data: 'map_id' }
],
pageLength: 10,
order: [[0, 'desc']]
});
}
}
// Initial load
document.addEventListener('DOMContentLoaded', loadDashboardStats);
// Auto-refresh stats every 30 seconds
setInterval(loadDashboardStats, 30000);
{{/is_gm_3_plus}}
{{#is_authenticated}}
// Load user's characters for character cards
async function loadUserCharacters() {
try {
const res = await API.get('/api/user/characters');
const characters = (res && Array.isArray(res.data)) ? res.data : (res || []);
const container = document.getElementById('character-cards-container');
container.innerHTML = '';
if (characters.length === 0) {
container.innerHTML = `
<div class="col-12 text-center">
<p class="text-muted">You don't have any characters yet. Log in to the game to create one!</p>
</div>
`;
return;
}
characters.forEach(char => {
const card = document.createElement('div');
card.className = 'col-md-6 col-lg-4 mb-4';
card.innerHTML = `
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-person-circle"></i>
${char.name}
</h5>
<p class="card-text">
<strong>Level:</strong> ${char.level || 0}<br>
<strong>Universe Score:</strong> ${char.uscore || 0}<br>
<strong>Current Zone:</strong> ${char.zone_id || 'Unknown'}<br>
<strong>Last Login:</strong> ${char.last_login && char.last_login > 0 ? new Date(char.last_login * 1000).toLocaleString() : 'Never'}
</p>
${char.pending_name ? `<span class="badge bg-warning mb-2">Pending Name: ${char.pending_name}</span><br>` : ''}
${char.needs_rename ? '<span class="badge bg-danger mb-2">Needs Rename</span><br>' : ''}
<a href="/characters/view/${char.id}" class="btn btn-sm btn-primary mt-2">View Details</a>
</div>
</div>
`;
container.appendChild(card);
});
} catch (error) {
console.error('Error loading user characters:', error);
const container = document.getElementById('character-cards-container');
container.innerHTML = `
<div class="col-12">
<div class="alert alert-warning">
Failed to load your characters. Please try refreshing the page.
</div>
</div>
`;
}
}
// Load character cards on page load
document.addEventListener('DOMContentLoaded', loadUserCharacters);
{{/is_authenticated}}
</script>

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - DarkflameServer Dashboard</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<!-- Custom CSS consolidated -->
<link rel="stylesheet" href="/static/css/dashboard.css">
{{#extra_head}}
{{{extra_head}}}
{{/extra_head}}
</head>
<body>
{{#show_navbar}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-grid-3x3-gap-fill"></i>
DarkflameServer Dashboard
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link{{#nav_home}} active{{/nav_home}}" href="/">
<i class="bi bi-house-door"></i> Home
</a>
</li>
{{#is_gm_3_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_accounts}} active{{/nav_accounts}}" href="/accounts">
<i class="bi bi-people"></i> Accounts
</a>
</li>
<li class="nav-item">
<a class="nav-link{{#nav_characters}} active{{/nav_characters}}" href="/characters">
<i class="bi bi-person-badge"></i> Characters
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-check"></i> Moderation
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/moderation/pending">Pending Pets</a></li>
<li><a class="dropdown-item" href="/properties">Properties</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link{{#nav_mail}} active{{/nav_mail}}" href="/mail/send">
<i class="bi bi-envelope"></i> Mail
</a>
</li>
{{/is_gm_3_plus}}
{{#is_gm_5_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_playkeys}} active{{/nav_playkeys}}" href="/playkeys">
<i class="bi bi-key"></i> Play Keys
</a>
</li>
{{/is_gm_5_plus}}
{{#is_gm_8_plus}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-journal-text"></i> Logs
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/logs/activities">Activity Logs</a></li>
<li><a class="dropdown-item" href="/logs/commands">Command Logs</a></li>
<li><a class="dropdown-item" href="/logs/audits">Audit Logs</a></li>
</ul>
</li>
{{/is_gm_8_plus}}
<li class="nav-item">
<a class="nav-link{{#nav_bugs}} active{{/nav_bugs}}" href="/bugs">
<i class="bi bi-bug"></i> Bug Reports
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{username}}
{{#gm_level_name}}
<span class="badge bg-primary">{{gm_level_name}}</span>
{{/gm_level_name}}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/about">About</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{{/show_navbar}}
<main class="{{#show_navbar}}container-fluid mt-4{{/show_navbar}}">
{{#flash_messages}}
<div class="alert alert-{{type}} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{{/flash_messages}}
{{{content}}}
</main>
<footer class="footer mt-auto py-3 bg-dark border-top">
<div class="container text-center">
<span class="text-muted">DarkflameServer Dashboard &copy; 2025 | Powered by Crow C++</span>
</div>
</footer>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<!-- Shared helper: wait for jQuery/DataTables (keeps pages resilient to CDN timing) -->
<script src="/static/js/wait-for-jq-dt.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Custom JS -->
<script src="/static/js/api.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/login.js"></script>
{{#extra_scripts}}
{{{extra_scripts}}}
{{/extra_scripts}}
</body>
</html>

View File

@@ -0,0 +1,31 @@
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow-lg mt-5">
<div class="card-header bg-primary text-white text-center">
<h4 class="mb-0">
<i class="bi bi-shield-lock"></i>
DarkflameServer Dashboard
</h4>
</div>
<div class="card-body">
<form id="login-form">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right"></i>
Login
</button>
</div>
</form>
<div id="login-message" class="mt-3" style="display: none;"></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-activity"></i>
Activity Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Player Activity</h5>
</div>
<div class="card-body">
<table id="activity-log-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Time</th>
<th>Character</th>
<th>Activity</th>
<th>Map ID</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Initialize when libraries are ready
safeInit(function($) {
$('#activity-log-table').DataTable({
processing: true,
serverSide: true,
ajax: {
url: '/api/activity-log',
type: 'GET'
},
columns: [
{
data: 'timestamp',
render: function(data, type, row) {
if (type === 'display' || type === 'filter') {
const date = new Date(data * 1000);
return date.toLocaleString();
}
return data;
}
},
{
data: 'character_name',
render: function(data, type, row) {
return `<a href="/characters/view/${row.character_id}">${data}</a>`;
}
},
{
data: 'activity_name'
},
{
data: 'map_id'
}
],
order: [[0, 'desc']],
pageLength: 25
});
}, { requireApi: false, timeout: 8000 });
</script>

View File

@@ -0,0 +1,139 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-journal-check"></i>
Audit Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Dashboard Audit Trail</h5>
</div>
<div class="card-body">
<table id="audits-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function showLibraryError(message) {
const el = document.getElementById('audits-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadAuditLogs() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 8) {
showLibraryError('You do not have permission to view audit logs. GM Level 8+ required.');
return;
}
API.get('/api/logs/audits').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#audits-table')) {
const table = $('#audits-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#audits-table').DataTable({
data: data,
columns: [
{
data: 'timestamp',
render: function(d) {
if (!d || d === 0) return '-';
return new Date(d * 1000).toLocaleString();
}
},
{
data: 'admin_username',
render: function(d, t, row) {
if (row.admin_account_id) {
return `<a href="/accounts/view/${row.admin_account_id}">${d || row.admin_account_id}</a>`;
}
return d || '-';
}
},
{
data: 'action',
render: function(d) {
// Color-code actions
const badges = {
'ban': 'danger',
'unban': 'success',
'lock': 'warning',
'unlock': 'success',
'mute': 'warning',
'unmute': 'success',
'delete': 'danger',
'create': 'success',
'update': 'info',
'gm_level_change': 'primary'
};
const action = d.toLowerCase();
const badgeClass = badges[action] || 'secondary';
return `<span class="badge bg-${badgeClass}">${d}</span>`;
}
},
{
data: 'target_type',
render: function(d, t, row) {
if (!d) return '-';
if (d === 'account' && row.target_id) {
return `<a href="/accounts/view/${row.target_id}">Account ${row.target_id}</a>`;
} else if (d === 'character' && row.target_id) {
return `<a href="/characters/view/${row.target_id}">Character ${row.target_id}</a>`;
}
return `${d} ${row.target_id || ''}`;
}
},
{ data: 'details', render: d => d || '-' }
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load audit logs';
showLibraryError(`Error loading audit logs: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadAuditLogs();
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -0,0 +1,106 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="bi bi-terminal"></i>
Command Logs
</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Recent Commands</h5>
</div>
<div class="card-body">
<table id="commands-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Character</th>
<th>Command</th>
<th>Arguments</th>
</tr>
</thead>
<tbody>
<!-- Populated via DataTables Ajax -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function showLibraryError(message) {
const el = document.getElementById('commands-table');
if (el) {
const wrapper = document.createElement('div');
wrapper.className = 'alert alert-danger';
wrapper.textContent = message;
el.replaceWith(wrapper);
} else {
alert(message);
}
}
function loadCommandLogs() {
API.get('/api/auth/status').then(status => {
if (!status || !status.authenticated || status.gm_level < 8) {
showLibraryError('You do not have permission to view command logs. GM Level 8+ required.');
return;
}
API.get('/api/logs/commands').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#commands-table')) {
const table = $('#commands-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
} else {
$('#commands-table').DataTable({
data: data,
columns: [
{
data: 'timestamp',
render: function(d) {
if (!d || d === 0) return '-';
return new Date(d * 1000).toLocaleString();
}
},
{
data: 'character_name',
render: function(d, t, row) {
if (row.character_id) {
return `<a href="/characters/view/${row.character_id}">${d || row.character_id}</a>`;
}
return d || '-';
}
},
{ data: 'command' },
{ data: 'arguments', render: d => d || '-' }
],
pageLength: 25,
order: [[0, 'desc']],
processing: true
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load command logs';
showLibraryError(`Error loading command logs: ${msg}`);
});
}).catch(err => {
showLibraryError(`Error checking authentication: ${err && err.message ? err.message : err}`);
});
}
// Initialize when jQuery/DataTables and API are ready
safeInit(function($) {
loadCommandLogs();
}, { requireApi: true, timeout: 8000 });
</script>

View File

@@ -0,0 +1,80 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-envelope"></i> Send Mail</h1>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">Compose Mail</div>
<div class="card-body">
<form id="send-mail-form">
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="send-to-all">
<label class="form-check-label" for="send-to-all">Send to all characters</label>
</div>
<div class="mb-3">
<label class="form-label">Recipient Character ID (leave blank if sending to all)</label>
<input type="number" id="recipient-id" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" id="subject" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Message</label>
<textarea id="body" class="form-control" rows="6" required></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Attachment LOT (optional)</label>
<input type="number" id="attachment-lot" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Attachment Count</label>
<input type="number" id="attachment-count" class="form-control" value="1" min="1">
</div>
</div>
<button type="submit" class="btn btn-primary">Send Mail</button>
</form>
<div id="mail-result" class="mt-3"></div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('send-mail-form');
form.addEventListener('submit', async function(e) {
e.preventDefault();
const sendToAll = document.getElementById('send-to-all').checked;
const recipientId = parseInt(document.getElementById('recipient-id').value) || 0;
const subject = document.getElementById('subject').value.trim();
const body = document.getElementById('body').value.trim();
const lot = parseInt(document.getElementById('attachment-lot').value) || 0;
const count = parseInt(document.getElementById('attachment-count').value) || 1;
const payload = { subject: subject, body: body };
if (sendToAll) payload.send_to_all = true;
else payload.recipient_id = recipientId;
if (lot > 0) {
payload.attachment_lot = lot;
payload.attachment_count = count;
}
try {
const res = await API.post('/api/mail/send', payload);
if (res && res.success) {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-success">Sent to ${res.recipients} recipient(s)</div>`;
form.reset();
} else {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-danger">${res.error || 'Failed to send mail'}</div>`;
}
} catch (err) {
document.getElementById('mail-result').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
});
});
</script>

View File

@@ -0,0 +1,85 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-paw"></i> Pet Name Moderation</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Pending Pet Names</div>
<div class="card-body">
<table id="pets-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Character</th>
<th>Pet Name</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let petsTable = null;
function loadPets() {
API.get('/api/moderation/pets').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#pets-table')) {
const table = $('#pets-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
petsTable = table;
} else {
petsTable = $('#pets-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'pet_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-success" onclick="approvePet(${row.id})">Approve</button>
<button class="btn btn-sm btn-danger" onclick="rejectPet(${row.id})">Reject</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => { alert(err && err.message ? err.message : 'Failed to load pets'); });
}
// Initialize when libraries are ready
safeInit(function($) {
loadPets();
}, { requireApi: true, timeout: 8000 });
window.approvePet = async function(id) {
if (!confirm('Approve this pet name?')) return;
try {
const res = await API.post(`/api/moderation/pets/${id}/approve`);
if (res && res.success) { loadPets(); alert('Approved'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
window.rejectPet = async function(id) {
if (!confirm('Reject this pet name?')) return;
try {
const res = await API.post(`/api/moderation/pets/${id}/reject`);
if (res && res.success) { loadPets(); alert('Rejected'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
</script>

View File

@@ -0,0 +1,82 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-house"></i> Property Moderation</h1>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Pending Properties</div>
<div class="card-body">
<table id="properties-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Owner (Character)</th>
<th>Property Name</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let propertiesTable = null;
function loadProperties() {
API.get('/api/moderation/properties').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#properties-table')) {
const table = $('#properties-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
propertiesTable = table;
} else {
propertiesTable = $('#properties-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'character_name' },
{ data: 'property_name' },
{ data: 'submitted', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-success" onclick="approveProperty(${row.id})">Approve</button>
<button class="btn btn-sm btn-danger" onclick="rejectProperty(${row.id})">Reject</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
}
}).catch(err => { alert(err && err.message ? err.message : 'Failed to load properties'); });
}
// Initialize when libraries are ready
safeInit(function($) { loadProperties(); }, { requireApi: true, timeout: 8000 });
window.approveProperty = async function(id) {
if (!confirm('Approve this property?')) return;
try {
const res = await API.post(`/api/moderation/properties/${id}/approve`);
if (res && res.success) { loadProperties(); alert('Approved'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
window.rejectProperty = async function(id) {
if (!confirm('Reject this property?')) return;
try {
const res = await API.post(`/api/moderation/properties/${id}/reject`);
if (res && res.success) { loadProperties(); alert('Rejected'); } else { alert(res.error || 'Failed'); }
} catch (err) { alert(err.message); }
};
</script>

View File

@@ -0,0 +1,155 @@
<div class="row">
<div class="col-12">
<h1 class="mb-4"><i class="bi bi-key"></i> Play Keys</h1>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">Create Play Keys</div>
<div class="card-body">
<form id="create-keys-form" class="row g-2">
<div class="col-auto">
<label class="form-label">Count</label>
<input type="number" id="key-count" class="form-control" value="1" min="1" max="100">
</div>
<div class="col-auto">
<label class="form-label">Uses</label>
<input type="number" id="key-uses" class="form-control" value="1" min="1">
</div>
<div class="col-6">
<label class="form-label">Notes</label>
<input type="text" id="key-notes" class="form-control" placeholder="Optional notes">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
<div id="created-keys" class="mt-3"></div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">Existing Play Keys</div>
<div class="card-body">
<table id="playkeys-table" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Key</th>
<th>Uses</th>
<th>Times Used</th>
<th>Active</th>
<th>Notes</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let playkeysTable = null;
function loadPlaykeys() {
API.get('/api/playkeys').then(res => {
const data = Array.isArray(res.data) ? res.data : (res || []);
if ($.fn.DataTable.isDataTable('#playkeys-table')) {
const table = $('#playkeys-table').DataTable();
table.clear();
table.rows.add(data);
table.draw(false);
playkeysTable = table;
} else {
playkeysTable = $('#playkeys-table').DataTable({
data: data,
columns: [
{ data: 'id' },
{ data: 'key_string' },
{ data: 'key_uses' },
{ data: 'times_used' },
{ data: 'active', render: d => d ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>' },
{ data: 'notes' },
{ data: 'created_at', render: d => d ? new Date(d * 1000).toLocaleString() : '-' },
{ data: null, orderable: false, render: function(data, type, row) {
return `
<button class="btn btn-sm btn-danger" onclick="deleteKey(${row.id})">Delete</button>
<button class="btn btn-sm btn-info" onclick="viewKey(${row.id})">View</button>
`;
} }
],
order: [[0, 'desc']],
pageLength: 25
});
// Create keys form handler
$('#create-keys-form').on('submit', async function(e) {
e.preventDefault();
const count = parseInt($('#key-count').val()) || 1;
const uses = parseInt($('#key-uses').val()) || 1;
const notes = $('#key-notes').val() || '';
try {
const res = await API.post('/api/playkeys/create', { count: count, uses: uses, notes: notes });
if (res && res.success) {
$('#created-keys').html(`<div class="alert alert-success">Created ${res.count} key(s): <pre>${JSON.stringify(res.keys)}</pre></div>`);
loadPlaykeys();
} else {
$('#created-keys').html(`<div class="alert alert-danger">${res.error || 'Failed to create keys'}</div>`);
}
} catch (err) {
$('#created-keys').html(`<div class="alert alert-danger">${err.message}</div>`);
}
});
}
}).catch(err => {
const msg = err && err.message ? err.message : 'Failed to load play keys';
document.getElementById('created-keys').innerHTML = `<div class="alert alert-danger">${msg}</div>`;
});
}
// Use safeInit to ensure jQuery/DataTables and API are present
safeInit(function($) {
loadPlaykeys();
}, { requireApi: true, timeout: 8000 });
async function deleteKey(id) {
if (!confirm('Delete this play key?')) return;
try {
const res = await API.delete(`/api/playkeys/${id}`);
if (res && res.success) {
loadPlaykeys();
alert('Play key deleted');
} else {
alert(res.error || 'Failed to delete key');
}
} catch (err) {
alert(err.message);
}
}
async function viewKey(id) {
try {
const res = await API.get(`/api/playkeys/${id}`);
if (res && res.success) {
const info = `ID: ${res.id}\nKey: ${res.key_string}\nUses: ${res.key_uses}\nTimes used: ${res.times_used}\nActive: ${res.active}\nNotes: ${res.notes}`;
alert(info);
} else {
alert(res.error || 'Failed to get key');
}
} catch (err) {
alert(err.message);
}
}
</script>

View File

@@ -0,0 +1,31 @@
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">Register</div>
<div class="card-body">
<form id="register-form">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="mb-3">
<label for="play_key" class="form-label">Play Key</label>
<input type="text" class="form-control" id="play_key" placeholder="XXXX-XXXX-XXXX-XXXX" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Create Account</button>
</div>
</form>
<div id="register-alert" class="mt-3" style="display:none;"></div>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/register.js"></script>