'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']]); }