- Created complete documentation in docs/ directory - Added PROJECT_OVERVIEW.md with feature highlights and getting started guide - Added ARCHITECTURE.md with system design and technical details - Added SECURITY.md with comprehensive security implementation guide - Added DEVELOPMENT.md with development workflows and best practices - Added DEPLOYMENT.md with production deployment instructions - Added API.md with complete REST API documentation - Added CONTRIBUTING.md with contribution guidelines - Added CHANGELOG.md with version history and migration notes - Reorganized all documentation files into docs/ directory for better organization - Updated README.md with proper documentation links and quick navigation - Enhanced project structure with professional documentation standards
806 lines
35 KiB
PHP
806 lines
35 KiB
PHP
|
|
<?php
|
|
require_once __DIR__ . '/admin/includes/data_providers.php';
|
|
require_once __DIR__ . '/admin/includes/layout.php';
|
|
require_once __DIR__ . '/f_core/f_classes/class.rbac.php';
|
|
require_once __DIR__ . '/f_core/f_classes/class.streamkey.php';
|
|
|
|
$pdo = admin_pdo();
|
|
$availableRoles = admin_fetch_all_roles($pdo);
|
|
$roleLookup = [];
|
|
foreach ($availableRoles as $roleRow) {
|
|
$roleLookup[$roleRow['role_name']] = $roleRow;
|
|
}
|
|
|
|
$flash = $_SESSION['admin_flash'] ?? null;
|
|
unset($_SESSION['admin_flash']);
|
|
|
|
$returnQueryString = ltrim($_SERVER['QUERY_STRING'] ?? '', '?');
|
|
$redirectBase = '/admin_users.php';
|
|
|
|
$rbac = VRBAC::getInstance();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$action = $_POST['action'] ?? '';
|
|
$userId = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
|
|
$token = $_POST['csrf'] ?? '';
|
|
$returnTo = trim($_POST['return_to'] ?? '');
|
|
$redirectUrl = $redirectBase . ($returnTo !== '' ? '?' . $returnTo : '');
|
|
|
|
$setFlashAndRedirect = static function (string $type, string $message, string $url) {
|
|
$_SESSION['admin_flash'] = ['type' => $type, 'message' => $message];
|
|
header("Location: {$url}");
|
|
exit;
|
|
};
|
|
|
|
if (!admin_validate_csrf('admin_users', $token)) {
|
|
$setFlashAndRedirect('error', 'Invalid session token. Please try again.', $redirectUrl);
|
|
}
|
|
|
|
if ($userId <= 0 || !admin_user_exists($pdo, $userId)) {
|
|
$setFlashAndRedirect('error', 'The selected user could not be found.', $redirectUrl);
|
|
}
|
|
|
|
$adminId = $_SESSION['ADMIN_ID'] ?? ($_SESSION['admin_id'] ?? null);
|
|
|
|
try {
|
|
switch ($action) {
|
|
case 'assign_role': {
|
|
$roleName = trim($_POST['role_name'] ?? '');
|
|
if ($roleName === '' || !isset($roleLookup[$roleName])) {
|
|
throw new InvalidArgumentException('Please select a valid role to assign.');
|
|
}
|
|
|
|
if (in_array($roleName, ['guest'], true)) {
|
|
throw new InvalidArgumentException('The selected role cannot be assigned manually.');
|
|
}
|
|
|
|
$existingRoles = admin_get_user_roles($pdo, $userId);
|
|
if (in_array($roleName, $existingRoles, true)) {
|
|
$message = sprintf('User already has the %s role.', $roleName);
|
|
} else {
|
|
$result = $rbac->assignRole($userId, $roleName, $adminId, 'Assigned via admin panel');
|
|
if (!$result) {
|
|
throw new RuntimeException('Failed to assign role. Please try again.');
|
|
}
|
|
$message = sprintf('Role %s assigned successfully.', $roleName);
|
|
}
|
|
$setFlashAndRedirect('success', $message, $redirectUrl);
|
|
break;
|
|
}
|
|
case 'remove_role': {
|
|
$roleName = trim($_POST['role_name'] ?? '');
|
|
if ($roleName === '' || !isset($roleLookup[$roleName])) {
|
|
throw new InvalidArgumentException('Please select a valid role to remove.');
|
|
}
|
|
|
|
if (in_array($roleName, ['guest', 'member', 'super_admin'], true)) {
|
|
throw new InvalidArgumentException('The selected role cannot be removed from this interface.');
|
|
}
|
|
|
|
$existingRoles = admin_get_user_roles($pdo, $userId);
|
|
if (!in_array($roleName, $existingRoles, true)) {
|
|
$message = sprintf('Role %s was already removed.', $roleName);
|
|
} else {
|
|
$result = $rbac->removeRole($userId, $roleName, $adminId, 'Removed via admin panel');
|
|
if (!$result) {
|
|
throw new RuntimeException('Failed to remove role. Please try again.');
|
|
}
|
|
$message = sprintf('Role %s removed successfully.', $roleName);
|
|
}
|
|
$setFlashAndRedirect('success', $message, $redirectUrl);
|
|
break;
|
|
}
|
|
case 'set_active': {
|
|
$activeValue = isset($_POST['active_value']) && (int) $_POST['active_value'] === 1;
|
|
if (!admin_set_user_active($pdo, $userId, $activeValue)) {
|
|
throw new RuntimeException('Unable to update user status. Please try again.');
|
|
}
|
|
$message = $activeValue ? 'User account activated.' : 'User account marked inactive.';
|
|
$setFlashAndRedirect('success', $message, $redirectUrl);
|
|
break;
|
|
}
|
|
case 'enable_streaming': {
|
|
$result = $rbac->assignRole($userId, VRBAC::ROLE_STREAMER, $adminId, 'Streaming access enabled via admin panel');
|
|
if (!$result) {
|
|
throw new RuntimeException('Unable to grant streaming access. Verify the user roles and try again.');
|
|
}
|
|
$setFlashAndRedirect('success', 'Streaming access enabled and stream key prepared.', $redirectUrl);
|
|
break;
|
|
}
|
|
case 'disable_streaming': {
|
|
$result = $rbac->removeRole($userId, VRBAC::ROLE_STREAMER, $adminId, 'Streaming access revoked via admin panel');
|
|
if (!$result) {
|
|
throw new RuntimeException('Unable to revoke streaming access. Verify the current roles and try again.');
|
|
}
|
|
$setFlashAndRedirect('success', 'Streaming access revoked for the user.', $redirectUrl);
|
|
break;
|
|
}
|
|
case 'regenerate_stream_key': {
|
|
VStreamKeyManager::getInstance()->regenerateStreamKey($userId);
|
|
$setFlashAndRedirect('success', 'Stream key regenerated successfully.', $redirectUrl);
|
|
break;
|
|
}
|
|
default:
|
|
throw new InvalidArgumentException('Unknown action requested.');
|
|
}
|
|
} catch (Throwable $e) {
|
|
$setFlashAndRedirect('error', $e->getMessage(), $redirectUrl);
|
|
}
|
|
}
|
|
|
|
$filters = [
|
|
'q' => trim($_GET['q'] ?? ''),
|
|
'status' => $_GET['status'] ?? '',
|
|
'role' => $_GET['role'] ?? '',
|
|
'verified' => $_GET['verified'] ?? '',
|
|
'streaming' => $_GET['streaming'] ?? '',
|
|
];
|
|
|
|
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
|
$perPage = 20;
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
$userStats = admin_fetch_user_stats($pdo);
|
|
$recentUsers = admin_fetch_recent_users($pdo, 20);
|
|
$roleDistribution = admin_fetch_role_distribution($pdo);
|
|
$userGrowth = admin_fetch_user_growth($pdo, 14);
|
|
$topCreators = admin_fetch_top_creators($pdo, 12);
|
|
|
|
$userDirectory = admin_search_users($pdo, $filters, $perPage, $offset);
|
|
$totalUsersMatching = admin_count_users($pdo, $filters);
|
|
$totalPages = max(1, (int) ceil($totalUsersMatching / $perPage));
|
|
|
|
$assignableRoles = array_filter(
|
|
$availableRoles,
|
|
static fn(array $role): bool => !in_array($role['role_name'], ['guest', 'super_admin'], true)
|
|
);
|
|
|
|
$csrfToken = admin_csrf_token('admin_users');
|
|
|
|
admin_page_start('User Operations Center', 'users');
|
|
?>
|
|
|
|
<style>
|
|
.alert {
|
|
margin: 18px auto;
|
|
max-width: 1280px;
|
|
padding: 12px 16px;
|
|
border-radius: 10px;
|
|
font-weight: 500;
|
|
}
|
|
.alert--success {
|
|
background: rgba(34, 197, 94, 0.15);
|
|
color: #166534;
|
|
border: 1px solid rgba(34, 197, 94, 0.35);
|
|
}
|
|
.alert--error {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: #991b1b;
|
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
}
|
|
.filter-card form {
|
|
display: grid;
|
|
gap: 12px;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
}
|
|
.filter-card label {
|
|
display: block;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
.filter-card input[type="text"],
|
|
.filter-card select {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
background: #fff;
|
|
font-size: 0.95rem;
|
|
}
|
|
.filter-card .filter-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: flex-end;
|
|
}
|
|
.filter-card button,
|
|
.filter-card a.reset-link {
|
|
padding: 10px 14px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
background: rgba(99, 102, 241, 0.15);
|
|
color: #4338ca;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
text-align: center;
|
|
}
|
|
.filter-card a.reset-link {
|
|
background: rgba(148, 163, 184, 0.12);
|
|
border-color: rgba(148, 163, 184, 0.3);
|
|
color: #475569;
|
|
text-decoration: none;
|
|
}
|
|
.user-directory-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.user-directory-table thead th {
|
|
text-align: left;
|
|
padding: 12px;
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
color: var(--text-muted);
|
|
}
|
|
.user-directory-table tbody td {
|
|
padding: 14px 12px;
|
|
border-top: 1px solid rgba(99, 102, 241, 0.12);
|
|
vertical-align: top;
|
|
}
|
|
.user-cell__primary {
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
}
|
|
.user-cell__meta {
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.badge-role {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 8px;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
background: rgba(99, 102, 241, 0.12);
|
|
color: #4338ca;
|
|
margin: 2px;
|
|
}
|
|
.badge-role .remove-role-btn {
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
color: inherit;
|
|
padding: 0;
|
|
line-height: 1;
|
|
}
|
|
.inline-form {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin-top: 8px;
|
|
}
|
|
.inline-form select {
|
|
padding: 6px 10px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
background: #fff;
|
|
font-size: 0.85rem;
|
|
}
|
|
.inline-form button {
|
|
padding: 6px 12px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
background: rgba(99, 102, 241, 0.18);
|
|
color: #4338ca;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.action-buttons form button {
|
|
padding: 6px 10px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
background: rgba(255, 255, 255, 0.9);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
}
|
|
.action-buttons form button.danger {
|
|
border-color: rgba(239, 68, 68, 0.35);
|
|
background: rgba(239, 68, 68, 0.14);
|
|
color: #b91c1c;
|
|
}
|
|
.status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
.status-pill--success {
|
|
background: rgba(34, 197, 94, 0.18);
|
|
color: #166534;
|
|
}
|
|
.status-pill--warning {
|
|
background: rgba(234, 179, 8, 0.18);
|
|
color: #854d0e;
|
|
}
|
|
.status-pill--danger {
|
|
background: rgba(239, 68, 68, 0.18);
|
|
color: #991b1b;
|
|
}
|
|
.pagination {
|
|
margin-top: 18px;
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
font-size: 0.9rem;
|
|
}
|
|
.pagination a {
|
|
padding: 6px 10px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
background: rgba(99, 102, 241, 0.1);
|
|
color: #4338ca;
|
|
font-weight: 600;
|
|
}
|
|
@media (max-width: 900px) {
|
|
.user-directory-table thead {
|
|
display: none;
|
|
}
|
|
.user-directory-table tbody td {
|
|
display: block;
|
|
width: 100%;
|
|
border-top: none;
|
|
border-bottom: 1px solid rgba(99, 102, 241, 0.12);
|
|
}
|
|
.user-directory-table tbody tr {
|
|
display: block;
|
|
margin-bottom: 12px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: var(--radius-md);
|
|
padding: 8px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<?php if ($flash): ?>
|
|
<div class="alert alert--<?= admin_escape($flash['type']) ?>">
|
|
<?= admin_escape($flash['message']) ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<section class="stats-grid">
|
|
<article class="stat-card">
|
|
<div class="stat-card__icon">👥</div>
|
|
<div class="stat-card__label">Total Users</div>
|
|
<div class="stat-card__value"><?= admin_format_number($userStats['total']) ?></div>
|
|
<div class="stat-card__meta">
|
|
<span><strong><?= admin_format_number($userStats['today']) ?></strong> joined today</span>
|
|
<span><strong><?= admin_format_number($userStats['active']) ?></strong> active</span>
|
|
</div>
|
|
</article>
|
|
<article class="stat-card">
|
|
<div class="stat-card__icon">✅</div>
|
|
<div class="stat-card__label">Verified Accounts</div>
|
|
<div class="stat-card__value"><?= admin_format_number($userStats['verified']) ?></div>
|
|
<div class="stat-card__meta">
|
|
<span><?= admin_format_number($userStats['verified'] / max($userStats['total'], 1) * 100, 1) ?>% verification rate</span>
|
|
</div>
|
|
</article>
|
|
<article class="stat-card">
|
|
<div class="stat-card__icon">🎯</div>
|
|
<div class="stat-card__label">Retention Focus</div>
|
|
<div class="stat-card__value"><?= admin_format_number($userStats['active'] - $userStats['today']) ?></div>
|
|
<div class="stat-card__meta">
|
|
<span>Active beyond first day</span>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="card filter-card">
|
|
<div class="card__header">
|
|
<h2>User Directory Filters</h2>
|
|
<?php if ($totalUsersMatching > 0): ?>
|
|
<span><?= admin_format_number($totalUsersMatching) ?> result<?= $totalUsersMatching === 1 ? '' : 's' ?></span>
|
|
<?php endif; ?>
|
|
</div>
|
|
<form method="get" action="/admin_users.php">
|
|
<div>
|
|
<label for="filter-q">Search</label>
|
|
<input id="filter-q" type="text" name="q" placeholder="Username or email"
|
|
value="<?= admin_escape($filters['q']) ?>">
|
|
</div>
|
|
<div>
|
|
<label for="filter-status">Status</label>
|
|
<select id="filter-status" name="status">
|
|
<option value="">All statuses</option>
|
|
<option value="active" <?= $filters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
|
|
<option value="inactive" <?= $filters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="filter-verified">Verification</label>
|
|
<select id="filter-verified" name="verified">
|
|
<option value="">All users</option>
|
|
<option value="yes" <?= $filters['verified'] === 'yes' ? 'selected' : '' ?>>Verified only</option>
|
|
<option value="no" <?= $filters['verified'] === 'no' ? 'selected' : '' ?>>Unverified only</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="filter-streaming">Streaming Access</label>
|
|
<select id="filter-streaming" name="streaming">
|
|
<option value="">All users</option>
|
|
<option value="enabled" <?= $filters['streaming'] === 'enabled' ? 'selected' : '' ?>>Streaming enabled</option>
|
|
<option value="disabled" <?= $filters['streaming'] === 'disabled' ? 'selected' : '' ?>>Streaming disabled</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="filter-role">Role</label>
|
|
<select id="filter-role" name="role">
|
|
<option value="">Any role</option>
|
|
<?php foreach ($availableRoles as $role): ?>
|
|
<option value="<?= admin_escape($role['role_name']) ?>" <?= $filters['role'] === $role['role_name'] ? 'selected' : '' ?>>
|
|
<?= admin_escape($role['display_name'] ?: $role['role_name']) ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="filter-actions">
|
|
<button type="submit">Apply filters</button>
|
|
<a class="reset-link" href="/admin_users.php">Reset</a>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>User Directory</h2>
|
|
<span>Showing page <?= $page ?> of <?= $totalPages ?></span>
|
|
</div>
|
|
<?php if (empty($userDirectory)): ?>
|
|
<div class="empty-state">
|
|
No users matched the current filters. Adjust your filters and try again.
|
|
</div>
|
|
<?php else: ?>
|
|
<div style="overflow-x: auto;">
|
|
<table class="user-directory-table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Roles</th>
|
|
<th>Streaming</th>
|
|
<th>Engagement</th>
|
|
<th>Account Controls</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($userDirectory as $user): ?>
|
|
<?php
|
|
$roleList = [];
|
|
if (!empty($user['roles'])) {
|
|
$roleList = array_filter(array_map('trim', explode(',', (string) $user['roles'])));
|
|
}
|
|
$hasStreamerRole = in_array(VRBAC::ROLE_STREAMER, $roleList, true);
|
|
$isActive = (int) ($user['usr_active'] ?? 0) === 1;
|
|
$isVerified = (int) ($user['usr_verified'] ?? 0) === 1;
|
|
$streamEnabled = (int) ($user['streaming_enabled'] ?? 0) === 1;
|
|
$activeStreams = (int) ($user['active_streams'] ?? 0);
|
|
$totalStreams = (int) ($user['total_streams'] ?? 0);
|
|
$currentStreamKey = (string) ($user['live_key'] ?? '');
|
|
$keyPreview = $currentStreamKey !== '' ? substr($currentStreamKey, 0, 16) . '...' : '—';
|
|
?>
|
|
<tr>
|
|
<td>
|
|
<div class="user-cell__primary">
|
|
#<?= admin_format_number($user['usr_id']) ?> · <?= admin_escape($user['usr_user']) ?>
|
|
<?php if ($isVerified): ?>
|
|
<span class="status-pill status-pill--success">Verified</span>
|
|
<?php endif; ?>
|
|
<?php if (!$isActive): ?>
|
|
<span class="status-pill status-pill--warning">Inactive</span>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div class="user-cell__meta">
|
|
<span>Email: <?= admin_escape($user['usr_email']) ?></span>
|
|
<span>Joined: <?= admin_format_datetime($user['usr_joindate']) ?></span>
|
|
<span>Last login: <?= admin_format_datetime($user['usr_lastlogin'], 'M j, Y H:i') ?></span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>
|
|
<?php if (empty($roleList)): ?>
|
|
<span class="status-pill status-pill--warning">No active roles</span>
|
|
<?php else: ?>
|
|
<?php foreach ($roleList as $roleName): ?>
|
|
<span class="badge-role">
|
|
<?= admin_escape($roleName) ?>
|
|
<?php if (!in_array($roleName, ['guest', 'member', 'super_admin'], true)): ?>
|
|
<form method="post" style="display:inline;" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="remove_role">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="role_name" value="<?= admin_escape($roleName) ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<button type="submit" class="remove-role-btn" title="Remove role">×</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</span>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php if (!empty($assignableRoles)): ?>
|
|
<form method="post" class="inline-form" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="assign_role">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<select name="role_name">
|
|
<option value="">Add role…</option>
|
|
<?php foreach ($assignableRoles as $role): ?>
|
|
<?php if (in_array($role['role_name'], $roleList, true)) { continue; } ?>
|
|
<option value="<?= admin_escape($role['role_name']) ?>">
|
|
<?= admin_escape($role['display_name'] ?: $role['role_name']) ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td>
|
|
<div class="user-cell__meta">
|
|
<span>Status: <?= $streamEnabled ? 'Enabled' : 'Disabled' ?></span>
|
|
<span>Role gated: <?= $hasStreamerRole ? 'yes' : 'no' ?></span>
|
|
<span>Streams: <?= $activeStreams ?>/<?= $totalStreams ?></span>
|
|
<span>Key: <?= $streamEnabled && $currentStreamKey !== '' ? admin_escape($keyPreview) : '—' ?></span>
|
|
<span>Updated: <?= admin_format_datetime($user['stream_key_regenerated_at'], 'M j, Y H:i') ?></span>
|
|
</div>
|
|
<div class="action-buttons" style="margin-top: 8px;">
|
|
<?php if ($streamEnabled): ?>
|
|
<form method="post" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="regenerate_stream_key">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<button type="submit">Regenerate key</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
<?php if ($hasStreamerRole): ?>
|
|
<form method="post" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="disable_streaming">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<button type="submit" class="danger">Disable streaming</button>
|
|
</form>
|
|
<?php else: ?>
|
|
<form method="post" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="enable_streaming">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<button type="submit">Enable streaming</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="user-cell__meta">
|
|
<span>Token balance: <?= admin_format_number($user['usr_tokencount'] ?? 0, 2) ?></span>
|
|
<span>Total logins: <?= admin_format_number($user['usr_logins'] ?? 0) ?></span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<form method="post" action="/admin_users.php">
|
|
<input type="hidden" name="csrf" value="<?= admin_escape($csrfToken) ?>">
|
|
<input type="hidden" name="action" value="set_active">
|
|
<input type="hidden" name="user_id" value="<?= (int) $user['usr_id'] ?>">
|
|
<input type="hidden" name="active_value" value="<?= $isActive ? 0 : 1 ?>">
|
|
<input type="hidden" name="return_to" value="<?= admin_escape($returnQueryString) ?>">
|
|
<button type="submit" class="<?= $isActive ? 'danger' : '' ?>">
|
|
<?= $isActive ? 'Mark inactive' : 'Activate user' ?>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php if ($totalPages > 1): ?>
|
|
<div class="pagination">
|
|
<?php if ($page > 1): ?>
|
|
<?php
|
|
$prevQuery = http_build_query(array_merge(array_filter($filters, static fn($v) => $v !== '' && $v !== null), ['page' => $page - 1]));
|
|
?>
|
|
<a href="/admin_users.php?<?= admin_escape($prevQuery) ?>">« Prev</a>
|
|
<?php endif; ?>
|
|
<span>Page <?= $page ?> of <?= $totalPages ?></span>
|
|
<?php if ($page < $totalPages): ?>
|
|
<?php
|
|
$nextQuery = http_build_query(array_merge(array_filter($filters, static fn($v) => $v !== '' && $v !== null), ['page' => $page + 1]));
|
|
?>
|
|
<a href="/admin_users.php?<?= admin_escape($nextQuery) ?>">Next »</a>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
</section>
|
|
<div class="grid grid--two">
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>Role Distribution</h2>
|
|
<a class="admin-button admin-button--ghost" href="/f_modules/m_backend/members.php">Open Legacy Manager</a>
|
|
</div>
|
|
<?php if (empty($roleDistribution)): ?>
|
|
<div class="empty-state">No role data available yet.</div>
|
|
<?php else: ?>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Role</th>
|
|
<th>Total Users</th>
|
|
<th>Share</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($roleDistribution as $row): ?>
|
|
<?php
|
|
$total = (int) ($row['total'] ?? 0);
|
|
$share = $userStats['total'] > 0 ? ($total / $userStats['total']) * 100 : 0;
|
|
?>
|
|
<tr>
|
|
<td><?= admin_escape($row['role'] ?: 'undefined') ?></td>
|
|
<td><?= admin_format_number($total) ?></td>
|
|
<td><?= admin_format_number($share, 1) ?>%</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>Sign-up Trend (Last 14 Days)</h2>
|
|
</div>
|
|
<?php if (empty($userGrowth)): ?>
|
|
<div class="empty-state">Insufficient data to chart growth.</div>
|
|
<?php else: ?>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>New Accounts</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($userGrowth as $row): ?>
|
|
<tr>
|
|
<td><?= admin_escape($row['date']) ?></td>
|
|
<td><?= admin_format_number((int) ($row['total'] ?? 0)) ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="grid grid--two">
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>Recent Sign-ups</h2>
|
|
</div>
|
|
<?php if (empty($recentUsers)): ?>
|
|
<div class="empty-state">No recent registrations found.</div>
|
|
<?php else: ?>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Email</th>
|
|
<th>Joined</th>
|
|
<th>Status</th>
|
|
<th>Tokens</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($recentUsers as $recent): ?>
|
|
<tr>
|
|
<td>#<?= admin_escape((string) $recent['usr_id']) ?> · <?= admin_escape($recent['usr_user']) ?></td>
|
|
<td><?= admin_escape($recent['usr_email']) ?></td>
|
|
<td><?= admin_format_datetime($recent['usr_joindate']) ?></td>
|
|
<td>
|
|
<span class="badge <?= $recent['usr_active'] ? 'badge--success' : 'badge--warning' ?>">
|
|
<?= $recent['usr_active'] ? 'Active' : 'Inactive' ?>
|
|
</span>
|
|
<?php if ($recent['usr_verified']): ?>
|
|
<span class="badge badge--success">Verified</span>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td><?= admin_format_number($recent['token_balance'], 2) ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>Creator Snapshot</h2>
|
|
<a class="admin-button admin-button--ghost" href="/admin_token_dashboard.php">Token analytics</a>
|
|
</div>
|
|
<?php if (empty($topCreators)): ?>
|
|
<div class="empty-state">No creator metrics ready yet.</div>
|
|
<?php else: ?>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Videos</th>
|
|
<th>Tokens Earned</th>
|
|
<th>Token Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($topCreators as $creator): ?>
|
|
<tr>
|
|
<td>#<?= admin_escape((string) ($creator['usr_id'] ?? $creator['user_id'] ?? '')) ?> · <?= admin_escape($creator['usr_user'] ?? ($creator['username'] ?? 'unknown')) ?></td>
|
|
<td><?= admin_format_number($creator['usr_v_count'] ?? 0) ?></td>
|
|
<td><?= admin_format_number($creator['total_tokens_earned'] ?? 0, 2) ?></td>
|
|
<td><?= admin_format_number($creator['token_balance'] ?? 0, 2) ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="card">
|
|
<div class="card__header">
|
|
<h2>Operational Shortcuts</h2>
|
|
</div>
|
|
<div class="quick-actions">
|
|
<a class="quick-actions__item" href="/f_modules/m_backend/members.php">
|
|
<div class="quick-actions__icon">🔎</div>
|
|
<div class="quick-actions__content">
|
|
<div class="quick-actions__title">Advanced Search</div>
|
|
<div class="quick-actions__desc">Filter by activity, role, verification, or balances.</div>
|
|
</div>
|
|
</a>
|
|
<a class="quick-actions__item" href="/f_modules/m_backend/subscribers.php">
|
|
<div class="quick-actions__icon">📠</div>
|
|
<div class="quick-actions__content">
|
|
<div class="quick-actions__title">Subscription Management</div>
|
|
<div class="quick-actions__desc">Review subscriptions and premium upgrades.</div>
|
|
</div>
|
|
</a>
|
|
<a class="quick-actions__item" href="/f_modules/m_backend/token_customization.php">
|
|
<div class="quick-actions__icon">💳</div>
|
|
<div class="quick-actions__content">
|
|
<div class="quick-actions__title">Token Adjustments</div>
|
|
<div class="quick-actions__desc">Credit or debit balances and configure packages.</div>
|
|
</div>
|
|
</a>
|
|
<a class="quick-actions__item" href="/f_modules/m_backend/log_viewer.php">
|
|
<div class="quick-actions__icon">📅</div>
|
|
<div class="quick-actions__content">
|
|
<div class="quick-actions__title">Security Events</div>
|
|
<div class="quick-actions__desc">Audit sensitive changes performed by staff.</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<?php
|
|
admin_page_end();
|
|
?>
|