WebOrbiton
v1.0.0.6

Publisium

599 lines · 31.8 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/auth.php';
  8. require_once __DIR__ . '/includes/user-auth.php';
  9. require_once __DIR__ . '/includes/csrf.php';
  10. require_once __DIR__ . '/includes/avatar.php';
  11. require_once __DIR__ . '/includes/activity-log.php';
  12. require_once __DIR__ . '/includes/two-factor.php';
  13. require_once __DIR__ . '/includes/site-front.php';
  14. require_once __DIR__ . '/includes/indexnow.php';
  15. ​
  16. Auth::boot();
  17. Auth::requireRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
  18. ​
  19. $currentUser = Auth::user();
  20. $currentRole = Auth::role();
  21. $isSuperAdmin = $currentRole === Auth::ROLE_SUPER_ADMIN;
  22. ​
  23. $assignableRoles = $isSuperAdmin
  24. ? [
  25. Auth::ROLE_SUPER_ADMIN,
  26. Auth::ROLE_EDITOR_IN_CHIEF,
  27. Auth::ROLE_MANAGING_EDITOR,
  28. Auth::ROLE_SENIOR_WRITER,
  29. Auth::ROLE_WRITER,
  30. Auth::ROLE_PROOFREADER,
  31. ]
  32. : [
  33. Auth::ROLE_MANAGING_EDITOR,
  34. Auth::ROLE_SENIOR_WRITER,
  35. Auth::ROLE_WRITER,
  36. Auth::ROLE_PROOFREADER,
  37. ];
  38. ​
  39. $canManageAccount = static fn(array $account): bool => $isSuperAdmin
  40. ? $account['role'] !== Auth::ROLE_SUPER_ADMIN
  41. : in_array($account['role'], $assignableRoles, true);
  42. ​
  43. $flashMessage = null;
  44. $flashType = 'success';
  45. $db = Database::site();
  46. $usersDb = Database::users();
  47. $activeTab = ($_GET['tab'] ?? 'team') === 'readers' ? 'readers' : 'team';
  48. ​
  49. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  50. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  51. $flashMessage = 'Your session expired. Please try again.';
  52. $flashType = 'error';
  53. } else {
  54. $action = $_POST['action'] ?? '';
  55. ​
  56. if ($action === 'suspend_reader') {
  57. if (!$isSuperAdmin) {
  58. $flashMessage = 'Only the Super Admin can suspend reader accounts.';
  59. $flashType = 'error';
  60. } else {
  61. $readerId = (int) ($_POST['reader_id'] ?? 0);
  62. $update = $usersDb->prepare("UPDATE user_accounts SET status = 'suspended' WHERE id = :id");
  63. $update->execute(['id' => $readerId]);
  64. UserAuth::revokeAllRememberTokens($readerId);
  65. $flashMessage = 'Reader suspended. They can’t sign in until you reactivate them.';
  66. }
  67. $activeTab = 'readers';
  68. }
  69. ​
  70. if ($action === 'reactivate_reader') {
  71. if (!$isSuperAdmin) {
  72. $flashMessage = 'Only the Super Admin can reactivate reader accounts.';
  73. $flashType = 'error';
  74. } else {
  75. $readerId = (int) ($_POST['reader_id'] ?? 0);
  76. $update = $usersDb->prepare("UPDATE user_accounts SET status = 'active' WHERE id = :id");
  77. $update->execute(['id' => $readerId]);
  78. $flashMessage = 'Reader reactivated. They can sign in again.';
  79. }
  80. $activeTab = 'readers';
  81. }
  82. ​
  83. if ($action === 'create_account') {
  84. $username = trim((string) ($_POST['username'] ?? ''));
  85. $email = trim((string) ($_POST['email'] ?? ''));
  86. $displayName = trim((string) ($_POST['display_name'] ?? ''));
  87. $password = (string) ($_POST['password'] ?? '');
  88. $role = (string) ($_POST['role'] ?? '');
  89. ​
  90. if ($username === '' || $email === '' || $password === '' || !in_array($role, $assignableRoles, true)) {
  91. $flashMessage = 'Fill in every field and pick a role you’re allowed to give.';
  92. $flashType = 'error';
  93. } elseif ($role === Auth::ROLE_SUPER_ADMIN) {
  94. $flashMessage = 'There can only be one Super Admin, so you can’t create another one.';
  95. $flashType = 'error';
  96. } elseif (strlen($password) < 10) {
  97. $flashMessage = 'The password needs at least 10 characters.';
  98. $flashType = 'error';
  99. } else {
  100. $newAvatar = null;
  101. try {
  102. if (Avatar::hasUpload($_FILES['avatar'] ?? null)) {
  103. [$newAvatar, $avatarError] = Avatar::store($_FILES['avatar']);
  104. if ($avatarError !== null) {
  105. throw new InvalidArgumentException($avatarError);
  106. }
  107. }
  108. ​
  109. $insert = $db->prepare(
  110. 'INSERT INTO team_accounts (username, email, password_hash, display_name, role, status, avatar_path) VALUES (:username, :email, :hash, :display_name, :role, :status, :avatar)'
  111. );
  112. $insert->execute([
  113. 'username' => $username,
  114. 'email' => $email,
  115. 'hash' => password_hash($password, PASSWORD_DEFAULT),
  116. 'display_name' => $displayName !== '' ? $displayName : $username,
  117. 'role' => $role,
  118. 'status' => 'active',
  119. 'avatar' => $newAvatar,
  120. ]);
  121. $flashMessage = 'Account created.';
  122. } catch (InvalidArgumentException $exception) {
  123. $flashMessage = $exception->getMessage();
  124. $flashType = 'error';
  125. } catch (Throwable $exception) {
  126. Avatar::delete($newAvatar);
  127. $flashMessage = 'We couldn’t create the account. The username or email is probably already taken.';
  128. $flashType = 'error';
  129. }
  130. }
  131. }
  132. ​
  133. if ($action === 'update_account') {
  134. $targetId = (int) ($_POST['account_id'] ?? 0);
  135. $displayName = trim((string) ($_POST['display_name'] ?? ''));
  136. $role = (string) ($_POST['role'] ?? '');
  137. $status = ($_POST['status'] ?? 'active') === 'suspended' ? 'suspended' : 'active';
  138. $newPassword = (string) ($_POST['new_password'] ?? '');
  139. ​
  140. $targetStatement = $db->prepare('SELECT * FROM team_accounts WHERE id = :id LIMIT 1');
  141. $targetStatement->execute(['id' => $targetId]);
  142. $targetAccount = $targetStatement->fetch();
  143. ​
  144. $isSelf = $targetAccount && (int) $targetAccount['id'] === (int) $currentUser['id'];
  145. if ($isSelf) {
  146. $role = $targetAccount['role'];
  147. $status = 'active';
  148. }
  149. ​
  150. if (!$targetAccount) {
  151. $flashMessage = 'We couldn’t find this account.';
  152. $flashType = 'error';
  153. } elseif ($targetAccount['role'] === Auth::ROLE_SUPER_ADMIN && !$isSelf) {
  154. $flashMessage = 'Only the Super Admin can edit their own account.';
  155. $flashType = 'error';
  156. } elseif (!$isSelf && !$canManageAccount($targetAccount)) {
  157. $flashMessage = 'You can only edit accounts with a lower role than yours.';
  158. $flashType = 'error';
  159. } elseif ($role === Auth::ROLE_SUPER_ADMIN && $targetAccount['role'] !== Auth::ROLE_SUPER_ADMIN) {
  160. $flashMessage = 'The Super Admin role can’t be given to anyone else.';
  161. $flashType = 'error';
  162. } elseif (!$isSelf && !in_array($role, $assignableRoles, true)) {
  163. $flashMessage = 'You can’t give that role.';
  164. $flashType = 'error';
  165. } else {
  166. $fields = ['display_name = :display_name', 'role = :role', 'status = :status'];
  167. $params = [
  168. 'display_name' => $displayName !== '' ? $displayName : $targetAccount['display_name'],
  169. 'role' => $role,
  170. 'status' => $targetAccount['role'] === Auth::ROLE_SUPER_ADMIN ? 'active' : $status,
  171. 'id' => $targetId,
  172. ];
  173. $saveError = null;
  174. $newAvatar = null;
  175. $removeAvatar = isset($_POST['remove_avatar']);
  176. ​
  177. if ($newPassword !== '') {
  178. if (strlen($newPassword) < 10) {
  179. $saveError = 'The new password needs at least 10 characters.';
  180. } else {
  181. $fields[] = 'password_hash = :hash';
  182. $params['hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
  183. }
  184. }
  185. ​
  186. if ($saveError === null && Avatar::hasUpload($_FILES['avatar'] ?? null)) {
  187. [$newAvatar, $saveError] = Avatar::store($_FILES['avatar']);
  188. }
  189. ​
  190. if ($saveError !== null) {
  191. $flashMessage = $saveError;
  192. $flashType = 'error';
  193. } else {
  194. if ($newAvatar !== null) {
  195. $fields[] = 'avatar_path = :avatar';
  196. $params['avatar'] = $newAvatar;
  197. } elseif ($removeAvatar) {
  198. $fields[] = 'avatar_path = NULL';
  199. }
  200. ​
  201. $resetTwoFactor = isset($_POST['reset_2fa']);
  202. if ($resetTwoFactor) {
  203. array_push($fields, 'totp_enabled = 0', 'totp_secret = NULL', 'totp_recovery = NULL', 'totp_last_step = NULL');
  204. }
  205. ​
  206. $update = $db->prepare('UPDATE team_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id');
  207. $update->execute($params);
  208. ​
  209. if ($resetTwoFactor) {
  210. ActivityLog::record('security.2fa_reset', 'account', $targetId);
  211. }
  212. ​
  213. if ($newAvatar !== null || $removeAvatar) {
  214. Avatar::delete((string) ($targetAccount['avatar_path'] ?? ''));
  215. }
  216. ​
  217. $flashMessage = 'Changes saved.';
  218. }
  219. }
  220. }
  221. ​
  222. if ($action === 'delete_account') {
  223. $targetId = (int) ($_POST['account_id'] ?? 0);
  224. ​
  225. $targetStatement = $db->prepare('SELECT * FROM team_accounts WHERE id = :id LIMIT 1');
  226. $targetStatement->execute(['id' => $targetId]);
  227. $targetAccount = $targetStatement->fetch();
  228. ​
  229. if (!$targetAccount) {
  230. $flashMessage = 'We couldn’t find this account.';
  231. $flashType = 'error';
  232. } elseif ($targetAccount['role'] === Auth::ROLE_SUPER_ADMIN) {
  233. $flashMessage = 'The Super Admin account can’t be deleted.';
  234. $flashType = 'error';
  235. } elseif ((int) $targetAccount['id'] === (int) $currentUser['id']) {
  236. $flashMessage = 'You can’t delete your own account.';
  237. $flashType = 'error';
  238. } elseif (!$canManageAccount($targetAccount)) {
  239. $flashMessage = 'You can only delete accounts with a lower role than yours.';
  240. $flashType = 'error';
  241. } elseif (!in_array($_POST['content_action'] ?? '', ['keep', 'delete'], true)) {
  242. $flashMessage = 'Choose what to do with this person’s articles and pages.';
  243. $flashType = 'error';
  244. } else {
  245. $deleteContent = $_POST['content_action'] === 'delete';
  246. $authorName = mb_substr((string) $targetAccount['display_name'], 0, 120);
  247. $removedArticles = [];
  248. ​
  249. try {
  250. $db->beginTransaction();
  251. ​
  252. if ($deleteContent) {
  253. $articleStatement = $db->prepare('SELECT id, slug, status, deleted_at FROM articles WHERE author_id = :id');
  254. $articleStatement->execute(['id' => $targetId]);
  255. $removedArticles = $articleStatement->fetchAll();
  256. ​
  257. foreach ($removedArticles as $removedArticle) {
  258. $db->prepare('DELETE FROM article_versions WHERE article_id = :id')->execute(['id' => $removedArticle['id']]);
  259. $db->prepare('DELETE FROM article_tags WHERE article_id = :id')->execute(['id' => $removedArticle['id']]);
  260. $db->prepare("DELETE FROM content_translations WHERE entity_type = 'article' AND entity_id = :id")->execute(['id' => $removedArticle['id']]);
  261. $db->prepare('DELETE FROM articles WHERE id = :id')->execute(['id' => $removedArticle['id']]);
  262. }
  263. ​
  264. $pageStatement = $db->prepare('SELECT id FROM pages WHERE author_id = :id');
  265. $pageStatement->execute(['id' => $targetId]);
  266. foreach ($pageStatement->fetchAll(PDO::FETCH_COLUMN) as $removedPageId) {
  267. $db->prepare("DELETE FROM content_translations WHERE entity_type = 'page' AND entity_id = :id")->execute(['id' => $removedPageId]);
  268. $db->prepare('DELETE FROM pages WHERE id = :id')->execute(['id' => $removedPageId]);
  269. }
  270. }
  271. ​
  272. // Whatever is left is handed over to the system; only the author's name is kept.
  273. $db->prepare('UPDATE articles SET author_name = COALESCE(author_name, :name), author_id = NULL WHERE author_id = :id')
  274. ->execute(['name' => $authorName, 'id' => $targetId]);
  275. $db->prepare('UPDATE pages SET author_name = COALESCE(author_name, :name), author_id = NULL WHERE author_id = :id')
  276. ->execute(['name' => $authorName, 'id' => $targetId]);
  277. $db->prepare('UPDATE ads SET created_by = NULL WHERE created_by = :id')->execute(['id' => $targetId]);
  278. $db->prepare('UPDATE article_revisions SET editor_id = NULL WHERE editor_id = :id')->execute(['id' => $targetId]);
  279. ​
  280. $db->prepare('DELETE FROM team_accounts WHERE id = :id')->execute(['id' => $targetId]);
  281. $db->commit();
  282. } catch (PDOException $e) {
  283. if ($db->inTransaction()) {
  284. $db->rollBack();
  285. }
  286. error_log('Publisium: could not delete account ' . $targetId . ': ' . $e->getMessage());
  287. $flashMessage = 'We couldn’t delete the account, so nothing was changed. Please try again.';
  288. $flashType = 'error';
  289. }
  290. ​
  291. if ($flashType !== 'error') {
  292. Avatar::delete((string) ($targetAccount['avatar_path'] ?? ''));
  293. try {
  294. $db->prepare('DELETE FROM author_profiles WHERE account_id = :id')->execute(['id' => $targetId]);
  295. } catch (PDOException $e) {
  296. error_log('Publisium: could not remove author profile: ' . $e->getMessage());
  297. }
  298. ​
  299. foreach ($removedArticles as $removedArticle) {
  300. if ($removedArticle['status'] === 'published' && $removedArticle['deleted_at'] === null) {
  301. IndexNow::notifyRemoved((string) $removedArticle['slug'], (int) $removedArticle['id']);
  302. }
  303. }
  304. ​
  305. ActivityLog::record('account.delete', 'account', $targetId, $authorName . ($deleteContent ? ' [content deleted]' : ' [content kept by system]'));
  306. $flashMessage = $deleteContent
  307. ? 'Account deleted, along with its articles and pages.'
  308. : 'Account deleted. Their articles and pages stay on the site under the name "' . $authorName . '".';
  309. }
  310. }
  311. }
  312. }
  313. }
  314. ​
  315. $contentCounts = [];
  316. foreach (['articles' => 'articles', 'pages' => 'pages'] as $countKey => $countTable) {
  317. foreach ($db->query('SELECT author_id, COUNT(*) AS total FROM ' . $countTable . ' WHERE author_id IS NOT NULL GROUP BY author_id')->fetchAll() as $countRow) {
  318. $contentCounts[(int) $countRow['author_id']][$countKey] = (int) $countRow['total'];
  319. }
  320. }
  321. ​
  322. $accounts = $db->query('SELECT * FROM team_accounts ORDER BY FIELD(role, \'super_admin\',\'editor_in_chief\',\'managing_editor\',\'senior_writer\',\'writer\',\'proofreader\'), display_name ASC')->fetchAll();
  323. ​
  324. $readers = [];
  325. if ($isSuperAdmin) {
  326. $readers = $usersDb->query('SELECT * FROM user_accounts ORDER BY created_at DESC')->fetchAll();
  327. }
  328. ​
  329. $dashActivePage = 'admin';
  330. $dashPageTitle = 'Team';
  331. ​
  332. require __DIR__ . '/includes/dash-header.php';
  333. ​
  334. ?>
  335. ​
  336. <?php if ($flashMessage !== null): ?>
  337. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  338. <?php endif; ?>
  339. ​
  340. <h1 class="dash-title"><?= Icons::icon("users", "icon icon-lg") ?>Accounts</h1>
  341. ​
  342. <?php if ($isSuperAdmin): ?>
  343. <div class="settings-tabs">
  344. <a href="admin.php?tab=team" class="<?= $activeTab === 'team' ? 'active' : '' ?>"><?= Icons::icon("team", "icon icon-sm") ?>Team</a>
  345. <a href="admin.php?tab=readers" class="<?= $activeTab === 'readers' ? 'active' : '' ?>"><?= Icons::icon("book", "icon icon-sm") ?>Readers</a>
  346. </div>
  347. <?php endif; ?>
  348. ​
  349. <?php if ($activeTab === 'readers' && $isSuperAdmin): ?>
  350. ​
  351. <table class="dash-table">
  352. <thead>
  353. <tr>
  354. <th><?= Icons::icon('heading', 'icon icon-sm') ?>Name</th>
  355. <th><?= Icons::icon('user', 'icon icon-sm') ?>Email</th>
  356. <th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th>
  357. <th><?= Icons::icon('stats', 'icon icon-sm') ?>Registered</th>
  358. <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last login</th>
  359. <th></th>
  360. </tr>
  361. </thead>
  362. <tbody>
  363. <?php foreach ($readers as $reader): ?>
  364. <tr>
  365. <td><?= htmlspecialchars((string) ($reader['display_name'] ?? 'No name')) ?></td>
  366. <td><?= htmlspecialchars($reader['email']) ?></td>
  367. <td><span class="status-pill status-<?= $reader['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars($reader['status']) ?></span></td>
  368. <td><?= htmlspecialchars($reader['created_at']) ?></td>
  369. <td><?= htmlspecialchars((string) ($reader['last_login_at'] ?? 'Never')) ?></td>
  370. <td class="dash-table-actions">
  371. <?php if ($reader['status'] === 'active'): ?>
  372. <form method="post" style="display:inline;" onsubmit="return confirm('Suspend this reader? They’ll be signed out and won’t be able to sign in.');">
  373. <?= Csrf::field() ?>
  374. <input type="hidden" name="action" value="suspend_reader">
  375. <input type="hidden" name="reader_id" value="<?= (int) $reader['id'] ?>">
  376. <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('lock', 'icon icon-sm') ?>Suspend</button>
  377. </form>
  378. <?php else: ?>
  379. <form method="post" style="display:inline;">
  380. <?= Csrf::field() ?>
  381. <input type="hidden" name="action" value="reactivate_reader">
  382. <input type="hidden" name="reader_id" value="<?= (int) $reader['id'] ?>">
  383. <button type="submit" class="dash-btn-small dash-btn-success"><?= Icons::icon('check', 'icon icon-sm') ?>Reactivate</button>
  384. </form>
  385. <?php endif; ?>
  386. </td>
  387. </tr>
  388. <?php endforeach; ?>
  389. <?php if (empty($readers)): ?>
  390. <tr>
  391. <td colspan="6">No readers have signed up yet.</td>
  392. </tr>
  393. <?php endif; ?>
  394. </tbody>
  395. </table>
  396. ​
  397. <?php else: ?>
  398. ​
  399. <table class="dash-table">
  400. <thead>
  401. <tr>
  402. <th><?= Icons::icon('heading', 'icon icon-sm') ?>Name</th>
  403. <th><?= Icons::icon('user', 'icon icon-sm') ?>Username</th>
  404. <th><?= Icons::icon('hash', 'icon icon-sm') ?>Email</th>
  405. <th><?= Icons::icon('team', 'icon icon-sm') ?>Role</th>
  406. <th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th>
  407. <th><?= Icons::icon('lock', 'icon icon-sm') ?>2FA</th>
  408. <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last login</th>
  409. <th></th>
  410. </tr>
  411. </thead>
  412. <tbody>
  413. <?php foreach ($accounts as $account): ?>
  414. <tr>
  415. <td><span class="dash-avatar-inline"><?= Avatar::html($account, 'dash-user-avatar') ?><?= htmlspecialchars($account['display_name']) ?></span></td>
  416. <td><?= htmlspecialchars($account['username']) ?></td>
  417. <td><?= htmlspecialchars((string) $account['email']) ?></td>
  418. <td><?= htmlspecialchars(Auth::roleLabel($account['role'])) ?></td>
  419. <td><?= htmlspecialchars($account['status']) ?></td>
  420. <td><?= TwoFactor::isEnabled($account) ? 'On' : 'Off' ?></td>
  421. <td><?= htmlspecialchars((string) ($account['last_login_at'] ?? 'Never')) ?></td>
  422. <td class="dash-table-actions">
  423. <?php if ($canManageAccount($account) || (int) $account['id'] === (int) $currentUser['id']): ?>
  424. <button type="button" class="dash-btn-small js-edit-account"
  425. data-id="<?= (int) $account['id'] ?>"
  426. data-display-name="<?= htmlspecialchars($account['display_name'], ENT_QUOTES) ?>"
  427. data-avatar="<?= htmlspecialchars(Avatar::isValidPath((string) ($account['avatar_path'] ?? '')) ? (string) $account['avatar_path'] : '', ENT_QUOTES) ?>"
  428. data-initial="<?= htmlspecialchars(Avatar::initial((string) $account['display_name']), ENT_QUOTES) ?>"
  429. data-role="<?= htmlspecialchars($account['role'], ENT_QUOTES) ?>"
  430. data-status="<?= htmlspecialchars($account['status'], ENT_QUOTES) ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</button>
  431. <?php endif; ?>
  432. <?php if ($canManageAccount($account) && (int) $account['id'] !== (int) $currentUser['id']): ?>
  433. <button type="button" class="dash-btn-small dash-btn-danger js-delete-account"
  434. data-id="<?= (int) $account['id'] ?>"
  435. data-display-name="<?= htmlspecialchars($account['display_name'], ENT_QUOTES) ?>"
  436. data-articles="<?= (int) ($contentCounts[(int) $account['id']]['articles'] ?? 0) ?>"
  437. data-pages="<?= (int) ($contentCounts[(int) $account['id']]['pages'] ?? 0) ?>"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button>
  438. <?php endif; ?>
  439. </td>
  440. </tr>
  441. <?php endforeach; ?>
  442. </tbody>
  443. </table>
  444. ​
  445. <h2 class="dash-subtitle"><?= Icons::icon('plus', 'icon icon-sm') ?>Create account</h2>
  446. <form method="post" enctype="multipart/form-data">
  447. <?= Csrf::field() ?>
  448. <input type="hidden" name="action" value="create_account">
  449. ​
  450. <label><?= Icons::icon('user', 'icon icon-sm') ?>Username</label>
  451. <input type="text" name="username" required>
  452. ​
  453. <label><?= Icons::icon('hash', 'icon icon-sm') ?>Email</label>
  454. <input type="text" name="email" required>
  455. ​
  456. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Display name</label>
  457. <input type="text" name="display_name">
  458. ​
  459. <label><?= Icons::icon('lock', 'icon icon-sm') ?>Password</label>
  460. <input type="password" name="password" minlength="10" required>
  461. ​
  462. <label><?= Icons::icon('user', 'icon icon-sm') ?>Profile photo (optional, JPG, PNG or WebP, up to 2 MB)</label>
  463. <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
  464. ​
  465. <label><?= Icons::icon('team', 'icon icon-sm') ?>Role</label>
  466. <select name="role" required>
  467. <?php foreach ($assignableRoles as $role): ?>
  468. <?php if ($role === Auth::ROLE_SUPER_ADMIN) {
  469. continue;
  470. } ?>
  471. <option value="<?= htmlspecialchars($role) ?>"><?= htmlspecialchars(Auth::roleLabel($role)) ?></option>
  472. <?php endforeach; ?>
  473. </select>
  474. ​
  475. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Create account</button>
  476. </form>
  477. ​
  478. <h2 class="dash-subtitle" id="edit-account-title" style="display:none;"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit account</h2>
  479. <form method="post" id="edit-account-form" style="display:none;" enctype="multipart/form-data">
  480. <?= Csrf::field() ?>
  481. <input type="hidden" name="action" value="update_account">
  482. <input type="hidden" name="account_id" id="edit_account_id">
  483. ​
  484. <label><?= Icons::icon('user', 'icon icon-sm') ?>Profile photo (JPG, PNG or WebP, up to 2 MB)</label>
  485. <div class="avatar-edit-preview">
  486. <div class="dash-user-avatar" id="edit_avatar_preview"></div>
  487. <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
  488. </div>
  489. <label><input type="checkbox" name="remove_avatar" id="edit_remove_avatar"> Remove the photo (their first initial is shown instead)</label>
  490. ​
  491. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Display name</label>
  492. <input type="text" name="display_name" id="edit_display_name">
  493. ​
  494. <label><?= Icons::icon('team', 'icon icon-sm') ?>Role</label>
  495. <select name="role" id="edit_role">
  496. <?php $editableRoleOptions = $isSuperAdmin ? array_merge($assignableRoles, [Auth::ROLE_SUPER_ADMIN]) : $assignableRoles; ?>
  497. <?php foreach (array_unique($editableRoleOptions) as $role): ?>
  498. <option value="<?= htmlspecialchars($role) ?>"><?= htmlspecialchars(Auth::roleLabel($role)) ?></option>
  499. <?php endforeach; ?>
  500. </select>
  501. ​
  502. <label><?= Icons::icon('flag', 'icon icon-sm') ?>Status</label>
  503. <select name="status" id="edit_status">
  504. <option value="active">Active</option>
  505. <option value="suspended">Suspended</option>
  506. </select>
  507. ​
  508. <label><?= Icons::icon('lock', 'icon icon-sm') ?>New password (leave empty to keep the current one)</label>
  509. <input type="password" name="new_password" minlength="10">
  510. ​
  511. <label><input type="checkbox" name="reset_2fa" id="edit_reset_2fa"> Reset two-factor login (if they lost their phone and their recovery codes)</label>
  512. ​
  513. <div class="publish-actions">
  514. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save changes</button>
  515. <button type="button" class="dash-btn" onclick="document.getElementById('edit-account-form').style.display='none';document.getElementById('edit-account-title').style.display='none';"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button>
  516. </div>
  517. </form>
  518. ​
  519. <h2 class="dash-subtitle" id="delete-account-title" style="display:none;"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete account: <span id="delete_account_name"></span></h2>
  520. <form method="post" id="delete-account-form" style="display:none;">
  521. <?= Csrf::field() ?>
  522. <input type="hidden" name="action" value="delete_account">
  523. <input type="hidden" name="account_id" id="delete_account_id">
  524. ​
  525. <p id="delete_account_summary" style="margin:0 0 12px;"></p>
  526. <p style="margin:0 0 8px;font-weight:600;">Do you also want to delete everything this person wrote?</p>
  527. ​
  528. <label><input type="radio" name="content_action" value="keep" id="delete_content_keep" checked> No, keep their articles and pages on the site. Their name stays on everything they wrote.</label>
  529. <label><input type="radio" name="content_action" value="delete" id="delete_content_delete"> Yes, delete all their articles and pages for good, including anything in the trash.</label>
  530. ​
  531. <div class="publish-actions">
  532. <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete account</button>
  533. <button type="button" class="dash-btn" onclick="document.getElementById('delete-account-form').style.display='none';document.getElementById('delete-account-title').style.display='none';"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button>
  534. </div>
  535. </form>
  536. ​
  537. <script>
  538. document.querySelectorAll('.js-delete-account').forEach(function(button) {
  539. button.addEventListener('click', function() {
  540. var articles = parseInt(this.dataset.articles, 10) || 0;
  541. var pages = parseInt(this.dataset.pages, 10) || 0;
  542. document.getElementById('edit-account-form').style.display = 'none';
  543. document.getElementById('edit-account-title').style.display = 'none';
  544. document.getElementById('delete-account-title').style.display = '';
  545. document.getElementById('delete-account-form').style.display = '';
  546. document.getElementById('delete_account_id').value = this.dataset.id;
  547. document.getElementById('delete_account_name').textContent = this.dataset.displayName;
  548. document.getElementById('delete_account_summary').textContent = 'They have ' + articles + (articles === 1 ? ' article' : ' articles') + ' and ' + pages + (pages === 1 ? ' page' : ' pages') + '.';
  549. document.getElementById('delete_content_keep').checked = true;
  550. document.getElementById('delete-account-form').scrollIntoView({
  551. behavior: 'smooth'
  552. });
  553. });
  554. });
  555. ​
  556. document.getElementById('delete-account-form').addEventListener('submit', function(event) {
  557. var deleteContent = document.getElementById('delete_content_delete').checked;
  558. var message = deleteContent
  559. ? 'Delete this account and all of its articles and pages? You won’t be able to get them back.'
  560. : 'Delete this account? Their articles and pages stay on the site.';
  561. if (!window.confirm(message)) {
  562. event.preventDefault();
  563. }
  564. });
  565. ​
  566. document.querySelectorAll('.js-edit-account').forEach(function(button) {
  567. button.addEventListener('click', function() {
  568. document.getElementById('delete-account-form').style.display = 'none';
  569. document.getElementById('delete-account-title').style.display = 'none';
  570. document.getElementById('edit-account-title').style.display = '';
  571. document.getElementById('edit-account-form').style.display = '';
  572. document.getElementById('edit_account_id').value = this.dataset.id;
  573. document.getElementById('edit_display_name').value = this.dataset.displayName;
  574. document.getElementById('edit_role').value = this.dataset.role;
  575. document.getElementById('edit_status').value = this.dataset.status;
  576. document.getElementById('edit_remove_avatar').checked = false;
  577. document.getElementById('edit_reset_2fa').checked = false;
  578. ​
  579. var preview = document.getElementById('edit_avatar_preview');
  580. preview.textContent = '';
  581. preview.classList.toggle('has-image', this.dataset.avatar !== '');
  582. if (this.dataset.avatar !== '') {
  583. var image = document.createElement('img');
  584. image.src = this.dataset.avatar;
  585. image.alt = '';
  586. preview.appendChild(image);
  587. } else {
  588. preview.textContent = this.dataset.initial;
  589. }
  590. document.getElementById('edit-account-form').scrollIntoView({
  591. behavior: 'smooth'
  592. });
  593. });
  594. });
  595. </script>
  596. ​
  597. <?php endif; ?>
  598. ​
  599. <?php require __DIR__ . '/includes/dash-footer.php'; ?>