The admin panel lets owners and admins manage dashboard user accounts — create new users, edit roles, and remove access. This module builds the user management interface and its supporting API endpoints in both Node.js and PHP.
The dashboard supports three roles, each with a distinct set of permissions:
| Role | View Data | Manage Viewers | Manage Admins | Manage Owners |
|---|---|---|---|---|
| owner | Yes | Yes | Yes | Yes |
| admin | Yes | Yes | No | No |
| viewer | Yes | No | No | No |
The owner role can do everything, including managing other owners. An admin can manage viewer accounts and view all collected data. A viewer can only view data — no user management access.
Only users with the owner or admin role can access the admin panel. Viewers who attempt to reach the admin page are redirected or shown an error.
The admin page (admin.html) is a self-contained HTML file with two main sections: a user list table and an "Add User" form.
The user list table displays all dashboard accounts with four columns:
owner, admin, or viewer.The table is populated on page load by fetching GET /api/users. Each row is built dynamically using textContent rather than innerHTML to prevent XSS from stored user data.
Below the table, the Add User form collects four fields:
<form id="add-user-form">
<input type="email" id="new-email" placeholder="Email" required>
<input type="text" id="new-name" placeholder="Display Name" required>
<input type="password" id="new-password" placeholder="Password" required>
<select id="new-role">
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit">Add User</button>
</form>
When submitted, the form calls POST /api/users with the form data as JSON. On success, the table is refreshed to include the new user.
The admin panel relies on four API endpoints that follow standard REST conventions. All endpoints require the caller to have an admin or owner role (verified via session):
| Method | Endpoint | Description | Access |
|---|---|---|---|
GET |
/api/users |
List all users | admin+ only |
POST |
/api/users |
Create a new user | admin+ only |
PUT |
/api/users/:id |
Update user role or display name | admin+ only |
DELETE |
/api/users/:id |
Delete a user (cannot delete self) | admin+ only |
The GET response returns an array of user objects. Notice that password_hash is never included — the SELECT query explicitly lists only the safe columns:
SELECT id, email, display_name, role, created_at, last_login
FROM users
ORDER BY created_at
The POST endpoint hashes the password before storing it. The DELETE endpoint includes a self-deletion guard — it compares the target user ID against the session user ID and returns a 400 error if they match.
The Node.js implementation (users-api.js) uses Express routes with a middleware function to enforce admin access:
function requireAdmin(req, res, next) {
if (!req.session.user ||
!['owner', 'admin'].includes(req.session.user.role)) {
return res.status(403).json({
success: false,
error: 'Admin access required'
});
}
next();
}
This middleware is applied to every route in the file. It checks two things: that a session exists (req.session.user is truthy) and that the user's role is either owner or admin. If either check fails, it short-circuits the request with a 403 Forbidden response.
For creating users, the password is hashed with bcrypt before storage:
const passwordHash = await bcrypt.hash(password, 10);
The second argument (10) is the cost factor — the number of rounds of hashing. Higher values are slower but more resistant to brute-force attacks. A cost of 10 takes roughly 100ms on modern hardware, which is fast enough for account creation but far too slow for an attacker to try millions of passwords.
The route also validates the role against a whitelist of valid values:
const validRoles = ['owner', 'admin', 'viewer'];
const userRole = validRoles.includes(role) ? role : 'viewer';
If the client sends an invalid role (or no role at all), it defaults to viewer — the least-privileged role. This ensures that a malicious or buggy client cannot assign arbitrary role strings.
The PHP implementation (users-api.php) follows the same logic using PDO for database access. The admin check happens at the top of the file, before any routing:
if (empty($_SESSION['user']) ||
!in_array($_SESSION['user']['role'] ?? '', ['owner', 'admin'])) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
Because PHP scripts are typically one-file-per-endpoint (or use a simple router), the access check runs once at the top. If it fails, exit stops execution immediately — none of the routing logic below will execute.
For password hashing, PHP uses its built-in function:
$hash = password_hash($password, PASSWORD_BCRYPT);
This is equivalent to bcrypt.hash(password, 10) in Node.js. PHP's password_hash() automatically generates a salt and uses a default cost of 10. The resulting hash string contains the algorithm identifier, cost factor, salt, and hash — all in one string.
URL routing is handled by extracting the user ID from the request path:
preg_match('#/api/users/(\d+)$#', $path, $matches);
$userId = $matches[1] ?? null;
If a numeric ID is present in the URL (e.g., /api/users/42), the request is routed to the PUT or DELETE handler. If no ID is present, it goes to GET (list) or POST (create).
User management endpoints are high-value targets. Several precautions are built into both implementations:
password_hash in API responses. The SELECT query explicitly names the columns to return. Even though bcrypt hashes are one-way, exposing them gives attackers material for offline cracking attempts. The hash never leaves the database row except during password verification.
req.params.id (or $userId) against the session user's ID. Without this check, an admin could accidentally lock themselves out by deleting their own account mid-session. The session would remain valid briefly but the user could never log in again.
['owner', 'admin', 'viewer']). If the role is not in the list, it defaults to viewer. This prevents injection of arbitrary strings into the role column, which could break authorization checks elsewhere.
POST /api/users endpoint should be rate-limited to prevent an attacker (who has compromised an admin account) from creating thousands of accounts. A simple approach is a per-session or per-IP limit of a few requests per minute.
ER_DUP_ENTRY / SQLSTATE 23000) and return a 409 Conflict response. This is better than checking for existence first and then inserting, which creates a race condition where two simultaneous requests could both pass the existence check.