accounts.php

main 445 lines · 17.6 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2/**
3 * Title: Accounts
4 * Icon: fa-address-card-o
5 * Group: Players
6 * Order: 10
7 * Description: Search an account, see its characters, points and history.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15$isOthire = (znote_server_adapter()->accountIdentityColumn() === 'id');
16$accNameCol = znote_server_adapter()->accountDisplayColumn();
17
18$search = trim((string)($_GET['q'] ?? ''));
19$accountId = intv($_GET['id'] ?? 0);
20
21// ---------------------------------------------------------------------------
22// Actions
23// ---------------------------------------------------------------------------
24if ($_SERVER['REQUEST_METHOD'] === 'POST') {
25
26 $id = intv($_POST['id'] ?? 0);
27 $do = (string)($_POST['do'] ?? '');
28
29 if ($id <= 0) {
30 acp_flash_error(t('acp.acc.no_selected'));
31 acp_redirect('accounts');
32 }
33
34 if ($do === 'points') {
35 $delta = intv($_POST['points'] ?? 0);
36
37 $row = db()->fetchOne("SELECT `points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1;", [$id]);
38 if (!is_array($row)) {
39 acp_flash_error(t('acp.acc.no_row'));
40 } else {
41 $new = max(0, (int)$row['points'] + $delta);
42 db()->execute("UPDATE `znote_accounts` SET `points` = ? WHERE `account_id` = ?;", [$new, $id]);
43 acp_log('account.points', '#' . $id, ['delta' => $delta, 'new_balance' => $new]);
44 acp_flash_success(t('acp.acc.points_applied', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'new' => $new]));
45 }
46 }
47
48 acp_redirect('accounts', array('id' => $id));
49}
50
51// ------------------------------------------------------ Bulk (selected rows)
52if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['do'] ?? '') === 'bulk_points') {
53 $ids = array_values(array_unique(array_filter(array_map('intv', (array)($_POST['ids'] ?? [])))));
54 $delta = intv($_POST['bulk_points'] ?? 0);
55
56 if (!$ids) {
57 acp_flash_error(t_default('acp.acc.bulk_none_selected', 'Select at least one account first.'));
58 acp_redirect('accounts', array('q' => $search));
59 }
60 if ($delta === 0) {
61 acp_flash_error(t('acp.mpts.zero_value'));
62 acp_redirect('accounts', array('q' => $search));
63 }
64
65 $placeholders = implode(',', array_fill(0, count($ids), '?'));
66 $updated = db()->execute("
67 UPDATE `znote_accounts` SET `points` = GREATEST(0, `points` + ?) WHERE `account_id` IN ({$placeholders});
68 ", array_merge([$delta], $ids));
69
70 if ($updated !== false) {
71 acp_log('accounts.bulk_points', implode(',', $ids), ['delta' => $delta, 'count' => count($ids)]);
72 acp_flash_success(t_default('acp.acc.bulk_points_done', '{delta} points applied to {n} account(s).', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'n' => count($ids)]));
73 } else {
74 acp_flash_error(t('acp.mpts.failed'));
75 }
76
77 acp_redirect('accounts', array('q' => $search));
78}
79
80// ---------------------------------------------------------------------------
81// One account
82// ---------------------------------------------------------------------------
83$account = null;
84if ($accountId > 0) {
85 $account = db()->fetchOne("
86 SELECT `a`.`id`, {$accNameCol} AS `account_name`, `a`.`email`,
87 `za`.`points`, `za`.`created`, `za`.`ip`, `za`.`flag`, `za`.`active_email`
88 FROM `accounts` `a`
89 LEFT JOIN `znote_accounts` `za` ON `za`.`account_id` = `a`.`id`
90 WHERE `a`.`id` = ?
91 LIMIT 1;
92 ", [$accountId]);
93
94 if (!is_array($account)) {
95 acp_flash_error(t('acp.acc.no_account', ['id' => $accountId]));
96 acp_redirect('accounts');
97 }
98
99 $characters = db()->fetchAll("
100 SELECT `id`, `name`, `level`, `vocation`, `group_id`
101 FROM `players`
102 WHERE `account_id` = ?
103 ORDER BY `level` DESC;
104 ", [$accountId]);
105 $characters = is_array($characters) ? $characters : array();
106
107 $purchases = db()->fetchAll("
108 SELECT `type`, `itemid`, `count`, `points`, `time`
109 FROM `znote_shop_logs`
110 WHERE `account_id` = ?
111 ORDER BY `id` DESC
112 LIMIT 10;
113 ", [$accountId]);
114 $purchases = is_array($purchases) ? $purchases : array();
115}
116
117// ---------------------------------------------------------------------------
118// Search / listing
119// ---------------------------------------------------------------------------
120const ACP_ACCOUNTS_PER_PAGE = 50;
121
122$results = array();
123if ($account === null) {
124 $where = '';
125 $params = [];
126 if ($search !== '') {
127 $like = '%' . $search . '%';
128 // Match the account name, its e-mail, or a character on it.
129 if ($isOthire) {
130 $where = "WHERE `a`.`id` = ? OR `a`.`email` LIKE ? OR `a`.`id` IN (SELECT `account_id` FROM `players` WHERE `name` LIKE ?)";
131 $params = [(int)$search, $like, $like];
132 } else {
133 $where = "WHERE `a`.`name` LIKE ? OR `a`.`email` LIKE ? OR `a`.`id` IN (SELECT `account_id` FROM `players` WHERE `name` LIKE ?)";
134 $params = [$like, $like, $like];
135 }
136 }
137
138 $totalRow = db()->fetchOne("SELECT COUNT(*) AS `n` FROM `accounts` `a` {$where};", $params);
139 $total = is_array($totalRow) ? (int)$totalRow['n'] : 0;
140 $pageCount = max(1, (int)ceil($total / ACP_ACCOUNTS_PER_PAGE));
141 $page = max(1, min($pageCount, intv($_GET['ap'] ?? 1)));
142 $offset = ($page - 1) * ACP_ACCOUNTS_PER_PAGE;
143
144 $results = db()->fetchAll("
145 SELECT `a`.`id`, {$accNameCol} AS `account_name`, `a`.`email`,
146 `za`.`points`, `za`.`created`,
147 (SELECT COUNT(*) FROM `players` `p` WHERE `p`.`account_id` = `a`.`id`) AS `characters`
148 FROM `accounts` `a`
149 LEFT JOIN `znote_accounts` `za` ON `za`.`account_id` = `a`.`id`
150 {$where}
151 ORDER BY `a`.`id` DESC
152 LIMIT {$offset}, " . ACP_ACCOUNTS_PER_PAGE . ";
153 ", $params);
154 $results = is_array($results) ? $results : array();
155}
156
157/** Keep the current search when building a paging link. */
158function acp_accounts_page_url(int $page, string $search): string {
159 $params = ['ap' => $page];
160 if ($search !== '') {
161 $params['q'] = $search;
162 }
163 return acp_url('accounts', $params);
164}
165?>
166
167<?php if ($account !== null): ?>
168
169 <div class="acp-toolbar">
170 <div>
171 <strong><?= h((string)$account['account_name']) ?></strong>
172 <span class="acp-pill acp-pill--grey">#<?= (int)$account['id'] ?></span>
173 </div>
174 <div class="acp-actions is-tight">
175 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', array('target' => '#' . (int)$account['id']))) ?>">
176 <i class="fa fa-history"></i> <?= t_default('acp.acc.view_history', 'Admin log for this account') ?>
177 </a>
178 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('accounts')) ?>">
179 <i class="fa fa-arrow-left"></i> <?= t('acp.acc.all_accounts') ?>
180 </a>
181 </div>
182 </div>
183
184 <div class="acp-grid acp-grid--2">
185
186 <section class="acp-card">
187 <header class="acp-card-head"><h2><?= t('acp.acc.account_title') ?></h2></header>
188 <div class="acp-card-body">
189 <dl class="acp-dl">
190 <dt><?= t('acp.acc.name') ?></dt><dd><?= h((string)$account['account_name']) ?></dd>
191 <dt><?= t('acp.acc.email') ?></dt>
192 <dd>
193 <?= h((string)$account['email']) ?>
194 <?php if (!empty($account['active_email'])): ?>
195 <span class="acp-pill acp-pill--green"><?= t('acp.acc.verified') ?></span>
196 <?php else: ?>
197 <span class="acp-pill acp-pill--grey"><?= t('acp.acc.unverified') ?></span>
198 <?php endif; ?>
199 </dd>
200 <dt><?= t('acp.acc.registered') ?></dt>
201 <dd><?= !empty($account['created']) ? h(getClock((int)$account['created'], true)) : '&mdash;' ?></dd>
202 <dt><?= t('acp.acc.last_ip') ?></dt>
203 <dd><?= !empty($account['ip']) ? '<code>' . h(long2ip((int)$account['ip'])) . '</code>' : '&mdash;' ?></dd>
204 <dt><?= t('acp.acc.country') ?></dt>
205 <dd><?= !empty($account['flag']) ? h((string)$account['flag']) : '&mdash;' ?></dd>
206 <dt><?= t('acp.acc.shop_points') ?></dt>
207 <dd><strong><?= number_format((int)($account['points'] ?? 0)) ?></strong></dd>
208 </dl>
209 </div>
210 </section>
211
212 <section class="acp-card">
213 <header class="acp-card-head">
214 <h2><?= t('acp.acc.adjust_points') ?></h2>
215 <p><?= t('acp.acc.adjust_sub') ?></p>
216 </header>
217 <div class="acp-card-body">
218 <form method="post">
219 <?= acp_csrf_field() ?>
220 <input type="hidden" name="do" value="points">
221 <input type="hidden" name="id" value="<?= (int)$account['id'] ?>">
222 <div class="acp-field">
223 <label class="acp-label" for="points"><?= t('acp.acc.amount') ?></label>
224 <input class="acp-input" id="points" name="points" type="number" value="0" required>
225 <p class="acp-hint"><?= t('acp.acc.balance_hint') ?></p>
226 </div>
227 <div class="acp-actions">
228 <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-diamond"></i> <?= t('acp.acc.apply') ?></button>
229 </div>
230 </form>
231
232 <hr>
233 <p class="is-muted" style="font-size:12.5px;">
234 <?= t('acp.acc.other_tools', [
235 'link' => '<a href="' . h(acp_url('players')) . '">' . t('acp.acc.player_tools_link') . '</a>',
236 ]) ?>
237 </p>
238 </div>
239 </section>
240 </div>
241
242 <section class="acp-card">
243 <header class="acp-card-head">
244 <h2><?= t('acp.acc.characters') ?></h2>
245 <p><?= t('acp.acc.n_on_account', ['n' => count($characters)]) ?></p>
246 </header>
247 <div class="acp-card-body is-flush">
248 <?php if ($characters): ?>
249 <div class="acp-table-wrap">
250 <table class="acp-table">
251 <thead><tr><th><?= t('acp.acc.col_name') ?></th><th><?= t('acp.acc.col_vocation') ?></th><th class="is-num"><?= t('acp.acc.col_level') ?></th><th><?= t('acp.acc.col_group') ?></th><th class="is-num">&nbsp;</th></tr></thead>
252 <tbody>
253 <?php foreach ($characters as $char): ?>
254 <tr>
255 <td>
256 <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$char['name']))) ?>" target="_blank" rel="noopener">
257 <?= h((string)$char['name']) ?>
258 </a>
259 </td>
260 <td class="is-muted"><?= h(vocation_id_to_name((int)$char['vocation'])) ?></td>
261 <td class="is-num"><?= (int)$char['level'] ?></td>
262 <td>
263 <?php if ((int)$char['group_id'] > 1): ?>
264 <span class="acp-pill acp-pill--red"><?= t('acp.acc.staff') ?></span>
265 <?php else: ?>
266 <span class="is-muted"><?= t('acp.acc.player') ?></span>
267 <?php endif; ?>
268 </td>
269 <td class="is-num is-nowrap">
270 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('skills', array('name' => (string)$char['name']))) ?>">
271 <i class="fa fa-bolt"></i> <?= t('acp.acc.skills') ?>
272 </a>
273 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', array('target' => (string)$char['name']))) ?>" title="<?= h(t_default('acp.acc.view_history', 'Admin log for this account')) ?>">
274 <i class="fa fa-history"></i>
275 </a>
276 </td>
277 </tr>
278 <?php endforeach; ?>
279 </tbody>
280 </table>
281 </div>
282 <?php else: ?>
283 <?php acp_empty(t('acp.acc.no_characters'), 'fa-user-o'); ?>
284 <?php endif; ?>
285 </div>
286 </section>
287
288 <section class="acp-card">
289 <header class="acp-card-head">
290 <h2><?= t('acp.acc.recent_purchases') ?></h2>
291 <p><?= t('acp.acc.last_10') ?></p>
292 </header>
293 <div class="acp-card-body is-flush">
294 <?php if ($purchases): ?>
295 <div class="acp-table-wrap">
296 <table class="acp-table">
297 <thead><tr><th><?= t('acp.acc.col_date') ?></th><th><?= t('acp.acc.col_type') ?></th><th class="is-num"><?= t('acp.acc.col_count') ?></th><th class="is-num"><?= t('acp.acc.col_points') ?></th></tr></thead>
298 <tbody>
299 <?php
300 $types = array(
301 1 => t('acp.acc.type_item'), 2 => t('acp.acc.type_premium'), 3 => t('acp.acc.type_gender'),
302 4 => t('acp.acc.type_name'), 5 => t('acp.acc.type_outfit'), 6 => t('acp.acc.type_mount'),
303 7 => t('acp.acc.type_custom'),
304 );
305 foreach ($purchases as $buy): ?>
306 <tr>
307 <td class="is-nowrap is-muted"><?= h(getClock((int)$buy['time'], true)) ?></td>
308 <td><?= h($types[(int)$buy['type']] ?? t('acp.acc.type_unknown')) ?></td>
309 <td class="is-num"><?= (int)$buy['count'] ?></td>
310 <td class="is-num"><?= (int)$buy['points'] ?></td>
311 </tr>
312 <?php endforeach; ?>
313 </tbody>
314 </table>
315 </div>
316 <?php else: ?>
317 <?php acp_empty(t('acp.acc.never_bought'), 'fa-shopping-cart'); ?>
318 <?php endif; ?>
319 </div>
320 </section>
321
322<?php else: ?>
323
324 <div class="acp-toolbar">
325 <form method="get" style="display:flex;gap:8px;flex:1 1 340px;max-width:520px;">
326 <input type="hidden" name="p" value="accounts">
327 <input class="acp-input" type="search" name="q" value="<?= h($search) ?>"
328 placeholder="<?= h(t('acp.acc.search_placeholder')) ?>" autofocus>
329 <button class="acp-btn" type="submit"><i class="fa fa-search"></i> <?= t('acp.acc.search') ?></button>
330 <?php if ($search !== ''): ?>
331 <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('accounts')) ?>"><?= t('acp.acc.clear') ?></a>
332 <?php endif; ?>
333 </form>
334 <span class="is-muted">
335 <?= $search !== '' ? t('acp.acc.match_count', ['n' => $total]) : t_default('acp.acc.total_count', '{n} accounts', ['n' => $total]) ?>
336 <?php if ($pageCount > 1): ?>
337 &middot; <?= t('acp.lay.page_of', ['page' => (int)$page, 'pageCount' => (int)$pageCount]) ?>
338 <?php endif; ?>
339 </span>
340 </div>
341
342 <section class="acp-card">
343 <header class="acp-card-head">
344 <h2><?= $search !== '' ? t('acp.acc.search_results') : t('acp.acc.newest_accounts') ?></h2>
345 </header>
346 <div class="acp-card-body is-flush">
347 <?php if ($results): ?>
348 <form id="acpAccBulkForm" method="post">
349 <?= acp_csrf_field() ?>
350 <input type="hidden" name="do" value="bulk_points">
351 <input type="hidden" name="q" value="<?= h($search) ?>">
352 </form>
353
354 <div class="acp-toolbar" id="acpAccBulkBar" hidden>
355 <span class="is-muted"><span id="acpAccBulkCount">0</span> <?= h(t_default('acp.acc.bulk_selected', 'selected')) ?></span>
356 <div class="acp-actions is-tight">
357 <input class="acp-input" form="acpAccBulkForm" name="bulk_points" type="number" step="1"
358 placeholder="<?= h(t_default('acp.acc.bulk_points_placeholder', '+/- points')) ?>" style="width:120px;">
359 <button type="submit" form="acpAccBulkForm" class="acp-btn acp-btn--sm">
360 <i class="fa fa-diamond"></i> <?= h(t_default('acp.acc.bulk_apply', 'Apply to selected')) ?>
361 </button>
362 </div>
363 </div>
364
365 <div class="acp-table-wrap">
366 <table class="acp-table" data-sortable>
367 <thead>
368 <tr><th><input type="checkbox" id="acpAccBulkAll" aria-label="<?= h(t_default('acp.acc.bulk_select_all', 'Select all')) ?>"></th><th>#</th><th><?= t('acp.acc.col_account') ?></th><th><?= t('acp.acc.col_email') ?></th><th class="is-num"><?= t('acp.acc.col_chars') ?></th><th class="is-num"><?= t('acp.acc.col_points') ?></th><th><?= t('acp.acc.col_registered') ?></th><th class="is-num">&nbsp;</th></tr>
369 </thead>
370 <tbody>
371 <?php foreach ($results as $row): ?>
372 <tr>
373 <td><input type="checkbox" class="acp-acc-bulk-check" form="acpAccBulkForm" name="ids[]" value="<?= (int)$row['id'] ?>"></td>
374 <td class="is-muted"><?= (int)$row['id'] ?></td>
375 <td><?= h((string)$row['account_name']) ?></td>
376 <td class="is-muted"><?= h((string)$row['email']) ?></td>
377 <td class="is-num"><?= (int)$row['characters'] ?></td>
378 <td class="is-num"><?= number_format((int)($row['points'] ?? 0)) ?></td>
379 <td class="is-nowrap is-muted"><?= !empty($row['created']) ? h(getClock((int)$row['created'], true)) : '&mdash;' ?></td>
380 <td class="is-num">
381 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('accounts', array('id' => (int)$row['id']))) ?>">
382 <i class="fa fa-eye"></i> <?= t('acp.acc.open') ?>
383 </a>
384 </td>
385 </tr>
386 <?php endforeach; ?>
387 </tbody>
388 </table>
389 </div>
390
391 <script>
392 (function () {
393 var all = document.getElementById('acpAccBulkAll');
394 var boxes = Array.prototype.slice.call(document.querySelectorAll('.acp-acc-bulk-check'));
395 var bar = document.getElementById('acpAccBulkBar');
396 var count = document.getElementById('acpAccBulkCount');
397 if (!all || !bar) return;
398
399 function refresh() {
400 var checked = boxes.filter(function (b) { return b.checked; });
401 bar.hidden = checked.length === 0;
402 if (count) count.textContent = checked.length;
403 all.checked = checked.length > 0 && checked.length === boxes.length;
404 all.indeterminate = checked.length > 0 && checked.length < boxes.length;
405 }
406
407 all.addEventListener('change', function () {
408 boxes.forEach(function (b) { b.checked = all.checked; });
409 refresh();
410 });
411 boxes.forEach(function (b) { b.addEventListener('change', refresh); });
412 })();
413 </script>
414 <?php else: ?>
415 <?php acp_empty($search !== '' ? t('acp.acc.no_match', ['search' => $search]) : t('acp.acc.no_accounts_yet'), 'fa-address-card-o'); ?>
416 <?php endif; ?>
417 </div>
418 </section>
419
420 <?php if ($pageCount > 1): ?>
421 <nav class="acp-actions" style="justify-content:center;margin:18px 0 24px;" aria-label="<?= h(t_default('acp.pagination_label', 'Pages')) ?>">
422 <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page <= 1 ? ' is-disabled' : '' ?>"
423 href="<?= h(acp_accounts_page_url(max(1, $page - 1), $search)) ?>"
424 <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
425 <i class="fa fa-angle-left"></i> <?= t('acp.lay.previous') ?>
426 </a>
427
428 <?php for ($p = 1; $p <= $pageCount; $p++): ?>
429 <?php if ($p === $page): ?>
430 <span class="acp-btn acp-btn--sm"><?= $p ?></span>
431 <?php else: ?>
432 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_accounts_page_url($p, $search)) ?>"><?= $p ?></a>
433 <?php endif; ?>
434 <?php endfor; ?>
435
436 <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page >= $pageCount ? ' is-disabled' : '' ?>"
437 href="<?= h(acp_accounts_page_url(min($pageCount, $page + 1), $search)) ?>"
438 <?= $page >= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
439 <?= t('acp.lay.next') ?> <i class="fa fa-angle-right"></i>
440 </a>
441 </nav>
442 <?php endif; ?>
443
444<?php endif; ?>
445
Top