Files
hyprcrd/relay/inc/routes/admin.php
T
thandClaude Opus 4.6 d50f9fe789 relay UI overhaul, favicon, settings, admin last-seen, fixes
Relay:
  - Inbox/shared/sent split into own view-inbox (nav button + poll).
    Compose is now single-column, centered.
  - Header logo links to compose. Inbox badge stays fresh on all
    views via a quiet loadInbox on every view switch.
  - Mobile menu: full-height sheet below header, larger buttons
    (15px font, 14px padding), full width.
  - Login form wrapped in <form> with name attributes + for/id on
    labels so password managers can autofill.
  - Admin user list shows "last seen <relative time>" per user.
    last_seen_at column on tenants, updated on every authenticated
    request (session + client API).
  - Cache-busting on style.css + app.js via filemtime.
  - View persistence in localStorage (survives reload).
  - Shared-bucket cards show title only (preview line removed).

Favicon:
  - favicon.svg added to project root + copied to public/, relay/,
    website/. Linked via <link rel="icon" type="image/svg+xml"> in
    all three <head> sections.

Settings:
  - New toggle: "Show shopping list items in task collector"
    (default off). collectTasks skips #einkauf/#shopping cards when
    disabled.

Fixes:
  - card-spinner gets display:inline-block so it rotates outside
    flex containers (shopping save spinner).
  - Tim tab test opens the plugin before checking active state.
  - Compose view centered with margin:auto on desktop.

Bumps to 1.9.29.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:16:23 +02:00

104 lines
5.1 KiB
PHP

<?php
// Admin-only routes (session + is_admin):
// GET /api/users
// POST /api/users
// PATCH /api/users/{id}
// DELETE /api/users/{id}
//
// Plus the tenant directory available to any logged-in user:
// GET /api/tenants
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// $sessionTenant is set by routes/public.php earlier in the chain
if ($route === '/api/users' && $method === 'GET') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$rows = $db->query('SELECT id, username, is_admin, created_at, last_seen_at FROM tenants ORDER BY id ASC')->fetchAll();
jsonResponse(['users' => $rows]);
}
if ($route === '/api/users' && $method === 'POST') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim((string)($input['username'] ?? ''));
$password = (string)($input['password'] ?? '');
$isAdmin = !empty($input['is_admin']) ? 1 : 0;
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
}
if (strlen($password) < 4) jsonResponse(['error' => 'password must be at least 4 chars'], 400);
if (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
// No client secret — the new user sets that themselves after first login
// via the account view. password_hash is bcrypted; secret columns stay NULL.
// password_changed = 0 forces a password change on first login (the
// admin-supplied password is meant as a temporary credential).
// Admin-flagged users still need to change because the admin set it.
$db->prepare('INSERT INTO tenants (username, password_hash, is_admin, password_changed) VALUES (:u, :p, :a, 0)')
->execute([
'u' => $username,
'p' => password_hash($password, PASSWORD_BCRYPT),
'a' => $isAdmin,
]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'PATCH') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$id = (int)$m[1];
$target = getTenantById($db, $id);
if (!$target) jsonResponse(['error' => 'user not found'], 404);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$fields = []; $params = ['id' => $id];
if (!empty($input['password'])) {
if (strlen($input['password']) < 4) jsonResponse(['error' => 'password too short'], 400);
$fields[] = 'password_hash = :pw';
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
}
if (array_key_exists('is_admin', $input)) {
if (!$input['is_admin'] && $target['is_admin'] && adminCount($db) <= 1) {
jsonResponse(['error' => 'cannot demote the last admin'], 400);
}
$fields[] = 'is_admin = :ad';
$params['ad'] = $input['is_admin'] ? 1 : 0;
}
// Note: client secret is set by the user themselves via /api/account/secret,
// not by the admin. Admins can only reset password and toggle admin flag.
if (!$fields) jsonResponse(['ok' => true]);
$db->prepare('UPDATE tenants SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
jsonResponse(['ok' => true]);
}
if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$id = (int)$m[1];
$target = getTenantById($db, $id);
if (!$target) jsonResponse(['error' => 'user not found'], 404);
if ($target['is_admin'] && adminCount($db) <= 1) {
jsonResponse(['error' => 'cannot delete the last admin'], 400);
}
if ($id === (int)$sessionTenant['id']) {
jsonResponse(['error' => 'cannot delete yourself'], 400);
}
// chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
// SET NULL — that leaves sender_username populated so the deleted user
// keeps showing up as a chat thread for their peers. Purge those rows
// explicitly BEFORE the tenant delete, while sender_id is still matchable.
$db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
jsonResponse(['ok' => true]);
}
// ── Tenant directory (visible to all authenticated tenants) ──────
// Accepts both UI session auth and client API headers, so hyprcrd
// instances can fetch the list to populate their recipient picker.
if ($route === '/api/tenants' && $method === 'GET') {
$authedTenant = $sessionTenant ?: authClient($db);
if (!$authedTenant) jsonResponse(['error' => 'unauthorized'], 401);
$rows = $db->query('SELECT id, username FROM tenants ORDER BY username ASC')->fetchAll();
jsonResponse(['tenants' => $rows, 'self' => $authedTenant['username']]);
}