feat(admin): redesign identity review with dialogs

Replace inline identity review forms with focused detail and review dialogs, improve list filters and caching, and align evidence permissions and deletion feedback. Add contract and runtime regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 20:05:21 +08:00
1 parent 5732be847e
commit bc26d62602
9 files changed
+2222 -116

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
/* 认证审核原生后台页。卡片、表单与颜色令牌继承 native-ui.css。 */
.aksrt-verify-page [hidden],
.aksrt-verify-dialog [hidden] { display: none !important; }
.aksrt-verify-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
padding: 14px 20px;
border-bottom: 1px solid var(--an-border-soft);
}
.aksrt-verify-toolbar__views {
display: flex;
flex: 1 1 620px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
}
.aksrt-verify-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; }
.aksrt-verify-toolbar .aksrt-native-filters { flex: 0 1 auto; margin: 0; }
.aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 200px; }
.aksrt-verify-page .aksrt-verify-table { min-width: 1040px; table-layout: fixed; }
.aksrt-verify-table .column-user { width: 20%; }
.aksrt-verify-table .column-profile { width: 24%; }
.aksrt-verify-table .column-evidence { width: 10%; }
.aksrt-verify-table .column-status { width: 10%; }
.aksrt-verify-table .column-time { width: 18%; }
.aksrt-verify-table .column-actions { width: 18%; }
.aksrt-verify-page .aksrt-verify-table td { vertical-align: middle; overflow-wrap: anywhere; }
.aksrt-verify-summary {
display: -webkit-box;
max-height: 3.1em;
margin-top: 4px;
color: var(--an-text-2);
font-size: 12px;
line-height: 1.55;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.aksrt-verify-sub { margin-top: 4px; color: var(--an-text-2); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
.aksrt-verify-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.aksrt-verify-row-actions a,
.aksrt-verify-row-actions button { white-space: nowrap; }
.aksrt-verify-row-actions .button-link-delete { color: var(--an-danger); }
.aksrt-verify-page :is(a, button):focus-visible,
.aksrt-verify-dialog :is(a, button, input, textarea):focus-visible {
outline: 2px solid var(--an-primary);
outline-offset: 2px;
}
html.aksrt-verify-modal-open { overflow: hidden; }
.aksrt-verify-dialog {
width: min(760px, calc(100vw - 32px));
max-width: none;
max-height: calc(100vh - 40px);
max-height: calc(100dvh - 40px);
margin: auto;
padding: 0;
overflow: hidden;
color: var(--an-text-2);
background: var(--an-card);
border: 1px solid var(--an-border-soft);
border-radius: var(--an-radius);
box-shadow: var(--an-shadow-hover);
}
.aksrt-verify-dialog::backdrop { background: rgba(15, 23, 42, .45); }
.aksrt-verify-dialog__frame { display: flex; flex-direction: column; max-height: inherit; min-height: 0; margin: 0; }
.aksrt-verify-dialog__head {
display: flex;
flex: none;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 19px 24px 17px;
border-bottom: 1px solid var(--an-border-soft);
}
.aksrt-verify-dialog .aksrt-verify-dialog__head h2 {
margin: 0;
padding: 0;
color: var(--an-text);
font-size: 18px;
font-weight: 600;
line-height: 1.45;
}
.aksrt-verify-dialog__head p { margin: 5px 0 0; color: var(--an-text-2); font-size: 13px; line-height: 1.6; }
.aksrt-verify-dialog__head [data-aksrt-dialog-close] {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--an-text-2);
background: transparent;
border: 0;
border-radius: var(--an-radius-sm);
cursor: pointer;
}
.aksrt-verify-dialog__head [data-aksrt-dialog-close]:hover { color: var(--an-text); background: var(--an-bg-soft); }
.aksrt-verify-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; }
.aksrt-verify-dialog__foot {
display: flex;
flex: none;
justify-content: flex-end;
gap: 8px;
padding: 14px 24px;
background: var(--an-card);
border-top: 1px solid var(--an-border-soft);
}
.aksrt-verify-dialog__foot .button { min-width: 116px; }
.aksrt-verify-dialog__foot .button.is-danger { color: var(--an-card); background: var(--an-danger); border-color: var(--an-danger); }
.aksrt-verify-dialog__foot .button.is-danger:hover { background: var(--an-danger); border-color: var(--an-danger); }
.aksrt-verify-dialog__foot .button:disabled { cursor: not-allowed; }
.aksrt-verify-applicant { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; min-width: 0; margin: 0 0 18px; color: var(--an-text); font-size: 17px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
.aksrt-verify-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
margin: 0;
padding: 14px 16px;
background: var(--an-bg-soft);
border-radius: var(--an-radius-sm);
}
.aksrt-verify-meta > div { min-width: 0; }
.aksrt-verify-meta dt { color: var(--an-text-2); font-size: 12px; line-height: 1.5; }
.aksrt-verify-meta dd { margin: 3px 0 0; color: var(--an-text); overflow-wrap: anywhere; }
.aksrt-verify-section { margin-top: 20px; }
.aksrt-verify-dialog .aksrt-verify-section h3,
.aksrt-verify-review > h3 {
margin: 0 0 9px;
padding: 0;
color: var(--an-text);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
}
.aksrt-verify-copy { color: var(--an-text-2); line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
.aksrt-verify-evidence { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }
.aksrt-verify-evidence li { min-width: 0; }
.aksrt-verify-evidence a {
display: flex;
align-items: center;
gap: 9px;
min-height: 52px;
padding: 7px 9px;
color: var(--an-text-2);
background: var(--an-bg-soft);
border: 1px solid var(--an-border-soft);
border-radius: var(--an-radius-sm);
text-decoration: none;
}
.aksrt-verify-evidence a:hover { color: var(--an-primary); border-color: var(--an-primary); }
.aksrt-verify-evidence img { flex: none; width: 38px; height: 38px; object-fit: cover; border-radius: 5px; }
.aksrt-verify-evidence span { min-width: 0; overflow-wrap: anywhere; }
.aksrt-verify-evidence-empty { margin: 0; color: var(--an-text-2); }
.aksrt-verify-review { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--an-border-soft); }
.aksrt-verify-decisions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; padding: 0; border: 0; }
.aksrt-verify-decisions > legend { width: 100%; margin-bottom: 8px; padding: 0; color: var(--an-text); font-weight: 600; }
.aksrt-verify-decisions label { position: relative; flex: 1 1 180px; cursor: pointer; }
.aksrt-verify-decisions input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); }
.aksrt-verify-decisions label > span {
display: flex;
align-items: center;
min-height: 42px;
padding: 9px 12px;
color: var(--an-text-2);
background: var(--an-card);
border: 1px solid var(--an-border);
border-radius: var(--an-radius-sm);
}
.aksrt-verify-decisions label:hover > span { border-color: var(--an-primary); }
.aksrt-verify-decisions input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; }
.aksrt-verify-decisions input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-verify-profile {
min-width: 0;
margin: 0 0 16px;
padding: 14px;
border: 1px solid var(--an-border);
border-radius: var(--an-radius-sm);
}
.aksrt-verify-profile > legend { padding: 0 5px; color: var(--an-text); font-weight: 600; }
.aksrt-verify-review .aksrt-native-field { margin-top: 13px; }
.aksrt-verify-review .aksrt-native-field > input,
.aksrt-verify-review .aksrt-native-field > textarea { width: 100%; max-width: none; }
.aksrt-verify-review textarea { resize: vertical; }
@media (max-width: 782px) {
.aksrt-verify-toolbar { padding: 12px 14px; }
.aksrt-verify-toolbar__views { flex: 1 1 100%; justify-content: flex-start; }
.aksrt-verify-toolbar .aksrt-native-filters { width: 100%; }
.aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 0; flex: 1 1 220px; }
}
@media (max-width: 600px) {
.aksrt-verify-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
.aksrt-verify-dialog__head,
.aksrt-verify-dialog__body,
.aksrt-verify-dialog__foot { padding-left: 16px; padding-right: 16px; }
.aksrt-verify-meta { grid-template-columns: 1fr; gap: 10px; }
.aksrt-verify-evidence { grid-template-columns: 1fr; }
.aksrt-verify-decisions { flex-direction: column; }
.aksrt-verify-decisions label { flex-basis: auto; }
.aksrt-verify-dialog__foot .button { flex: 1; }
}
+255
View File
@@ -0,0 +1,255 @@
/** 认证记录审核详情与删除确认。仅在认证 native 后台页入队。 */
(function () {
'use strict';
var root = document.querySelector('.aksrt-verify-page');
var dialog = document.getElementById('aksrt-verify-dialog');
if (!root || !dialog || typeof dialog.showModal !== 'function') return;
var opener = null;
var activeForm = null;
var submitting = false;
var pointerStartedOutside = false;
function prevent(event) {
if (event && typeof event.preventDefault === 'function') event.preventDefault();
}
function valueOf(element, name, fallback) {
var value = element ? element.getAttribute(name) : null;
return value === null ? (fallback || '') : value;
}
function lockScroll() {
document.documentElement.classList.toggle('aksrt-verify-modal-open', !!dialog.open);
}
function closestAttribute(target, attribute, boundary) {
var node = target;
while (node) {
if (node.nodeType === 1 && node.hasAttribute(attribute)) return node;
if (node === boundary) break;
node = node.parentElement;
}
return null;
}
function isOutside(event) {
var rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
}
function selectedStatus(form) {
var radios = form.querySelectorAll('input[name="status"]');
for (var index = 0; index < radios.length; index += 1) {
if (radios[index].checked) return radios[index].value;
}
return '';
}
function getProfileName(profile) {
return profile ? profile.querySelector('input[name="name"]') : null;
}
function setTrimValidity(field, active) {
if (!field || typeof field.setCustomValidity !== 'function') return;
if (!active || String(field.value || '').trim() !== '') {
field.setCustomValidity('');
return;
}
field.setCustomValidity(valueOf(field, 'data-required-error', '必填项不能为空'));
}
function syncReview(form) {
var decision = selectedStatus(form);
var profile = form.querySelector('[data-aksrt-verify-profile]');
var name = getProfileName(profile);
var note = form.querySelector('textarea[name="note"]');
var noteLabel = form.querySelector('[data-aksrt-verify-note-label]');
var submit = form.querySelector('[data-aksrt-verify-submit]');
var rejected = decision === 'rejected';
var approved = decision === 'verified';
if (profile) {
profile.hidden = !approved;
profile.disabled = !approved;
}
if (note) {
note.required = rejected;
setTrimValidity(note, rejected);
}
setTrimValidity(name, approved);
if (noteLabel) {
noteLabel.textContent = valueOf(noteLabel, rejected ? 'data-required' : 'data-optional');
}
if (submit) {
var idleLabel = valueOf(submit, 'data-idle-label');
submit.disabled = !decision;
submit.textContent = approved
? valueOf(submit, 'data-approve-label', idleLabel)
: rejected
? valueOf(submit, 'data-reject-label', idleLabel)
: idleLabel;
submit.classList.toggle('is-danger', rejected);
}
}
function resetReview(form) {
var radios = form.querySelectorAll('input[name="status"]');
radios.forEach(function (radio) { radio.checked = false; });
var note = form.querySelector('textarea[name="note"]');
if (note) note.value = '';
syncReview(form);
}
function actionButtons() {
var buttons = [];
var submitButtons = dialog.querySelectorAll('[data-aksrt-verify-submit]');
var closeButtons = dialog.querySelectorAll('[data-aksrt-dialog-close]');
submitButtons.forEach(function (button) { buttons.push(button); });
closeButtons.forEach(function (button) {
if (buttons.indexOf(button) === -1) buttons.push(button);
});
return buttons;
}
function setActionsDisabled(disabled) {
actionButtons().forEach(function (button) { button.disabled = disabled; });
}
function setBusyLabel(form) {
var submit = form.querySelector('[data-aksrt-verify-submit]');
if (submit) submit.textContent = valueOf(submit, 'data-busy-label', submit.textContent);
}
function syncReviewIfIdle(form) {
if (!submitting && form.dataset.submitting !== '1') syncReview(form);
}
function setupForm(form) {
var review = form.hasAttribute('data-aksrt-verify-review');
var deletion = form.hasAttribute('data-aksrt-verify-delete-form');
var submit = form.querySelector('[data-aksrt-verify-submit]');
if (!review && !deletion) return;
if (review) {
resetReview(form);
form.addEventListener('change', function () { syncReviewIfIdle(form); });
form.addEventListener('input', function () { syncReviewIfIdle(form); });
} else if (submit) {
submit.disabled = false;
submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent);
submit.classList.add('is-danger');
}
form.addEventListener('submit', function (event) {
if (submitting || form.dataset.submitting === '1') {
prevent(event);
return;
}
if (review) syncReview(form);
var valid = typeof form.reportValidity !== 'function' || form.reportValidity();
if (!valid || (review && !selectedStatus(form))) {
prevent(event);
return;
}
submitting = true;
form.dataset.submitting = '1';
setActionsDisabled(true);
setBusyLabel(form);
lockScroll();
});
}
function openTemplate(button, prefix) {
if (submitting || dialog.open) return;
var id = button.getAttribute(prefix);
if (id === null || id === '') return;
var template = document.getElementById('aksrt-verify-' + (prefix === 'data-aksrt-verify-view' ? 'detail-' : 'delete-') + id);
if (!template || !template.content || typeof template.content.cloneNode !== 'function') return;
pointerStartedOutside = false;
dialog.replaceChildren(template.content.cloneNode(true));
opener = button;
activeForm = dialog.querySelector('form[data-aksrt-verify-review]')
|| dialog.querySelector('form[data-aksrt-verify-delete-form]');
if (activeForm) setupForm(activeForm);
dialog.showModal();
lockScroll();
}
root.querySelectorAll('[data-aksrt-verify-view]').forEach(function (button) {
button.addEventListener('click', function (event) {
prevent(event);
openTemplate(button, 'data-aksrt-verify-view');
});
});
root.querySelectorAll('[data-aksrt-verify-delete]').forEach(function (button) {
button.addEventListener('click', function (event) {
prevent(event);
openTemplate(button, 'data-aksrt-verify-delete');
});
});
dialog.addEventListener('click', function (event) {
var closeButton = closestAttribute(event.target, 'data-aksrt-dialog-close', dialog);
if (closeButton) {
prevent(event);
if (!submitting) dialog.close();
pointerStartedOutside = false;
return;
}
if (pointerStartedOutside && event.target === dialog && isOutside(event) && !submitting) dialog.close();
pointerStartedOutside = false;
});
dialog.addEventListener('pointerdown', function (event) {
pointerStartedOutside = event.target === dialog && isOutside(event);
});
dialog.addEventListener('cancel', function (event) {
prevent(event);
if (!submitting) dialog.close();
});
dialog.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && submitting) {
prevent(event);
if (typeof event.stopPropagation === 'function') event.stopPropagation();
}
});
dialog.addEventListener('close', function () {
dialog.replaceChildren();
activeForm = null;
submitting = false;
pointerStartedOutside = false;
lockScroll();
var previousOpener = opener;
opener = null;
if (previousOpener && previousOpener.isConnected) previousOpener.focus();
});
window.addEventListener('pageshow', function () {
submitting = false;
if (activeForm) {
delete activeForm.dataset.submitting;
setActionsDisabled(false);
if (activeForm.hasAttribute('data-aksrt-verify-review')) {
syncReview(activeForm);
} else {
var submit = activeForm.querySelector('[data-aksrt-verify-submit]');
if (submit) {
submit.disabled = false;
submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent);
submit.classList.add('is-danger');
}
}
}
lockScroll();
});
})();
+1
View File
@@ -46,6 +46,7 @@ class UsersMenus {
add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_verify_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_vip_assets']);
}
+161 -114
View File
@@ -3,169 +3,218 @@
namespace AKSRT\Admin\Native;
use AKSRT\Identity\ApplicationRepository;
use AKSRT\Identity\EvidenceService;
use AKSRT\Identity\EventRepository;
use AKSRT\User\AuthApply;
defined('ABSPATH') || exit;
trait UsersMenusVerify {
use UsersMenusVerifyDialogs;
private const VERIFY_PER_PAGE = 20;
private const VERIFY_STATUSES = ['pending', 'approved', 'rejected', ''];
public function enqueue_verify_assets(string $hook): void {
if ($hook !== 'users_page_aksrt_user_auth') return;
$css = '/assets/css/admin/user-auth.css';
$js = '/assets/js/admin-user-auth.js';
wp_enqueue_style('aksrt-admin-user-auth', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-user-auth', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
private function count_verify_status(string $status): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('identity_application');
$stored = $status === 'pending' ? 'manual_pending' : $status;
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE type = %s AND channel = %s AND status = %s",
'general',
'manual',
$stored
'general', 'manual', $status === 'pending' ? 'manual_pending' : $status
));
}
public function render_verify_page(): void {
$this->ensure_cap();
$filters = $this->get_verify_filters();
$page = max(1, (int) ($_GET['paged'] ?? 1));
$data = $this->query_verify_requests($filters, $page, 20);
$counts = $this->get_verify_status_counts();
NativeUi::page_open();
NativeUi::header(__('身份认证', 'aksrt'), '', 'dashicons-id');
$filters = $this->get_verify_filters($_GET);
$data = $this->query_verify_requests($filters, max(1, (int) ($_GET['paged'] ?? 1)));
$counts = $this->get_verify_status_counts($filters);
NativeUi::page_open('aksrt-verify-page');
NativeUi::header(
__('身份认证', 'aksrt'),
__('查看申请资料与证明材料,在审核弹窗中确认处理结果。', 'aksrt'),
'dashicons-id',
[['label' => __('认证设置', 'aksrt'), 'url' => admin_url('admin.php?page=aksrt-settings#/users/verify'), 'icon' => 'dashicons-admin-settings']]
);
$this->flash();
NativeUi::stats([
['label' => __('待审核', 'aksrt'), 'value' => $counts['pending'], 'icon' => 'dashicons-clock', 'tone' => 'warning'],
['label' => __('已通过', 'aksrt'), 'value' => $counts['approved'], 'icon' => 'dashicons-yes-alt', 'tone' => 'success'],
['label' => __('已拒绝', 'aksrt'), 'value' => $counts['rejected'], 'icon' => 'dashicons-dismiss', 'tone' => 'danger'],
]);
NativeUi::card_open('', '', false, 'aksrt-native-toolbar');
$this->render_verify_filters($filters);
NativeUi::card_close();
NativeUi::table_card_open(__('认证申请', 'aksrt'));
$this->render_verify_table($data['items']);
NativeUi::card_open('', '', true, 'aksrt-verify-list-card');
$this->render_verify_filters($filters, $counts);
echo '<div class="aksrt-native-table-wrap">';
$this->render_verify_table($data['items'], $filters, $data['page']);
NativeUi::table_wrap_close();
$this->pagination($data['pages'], $page);
AdminListPagination::render('aksrt_user_auth', $data['total'], $data['page'], self::VERIFY_PER_PAGE, $filters, 'users.php');
NativeUi::card_close();
echo '<dialog id="aksrt-verify-dialog" class="aksrt-verify-dialog" aria-labelledby="aksrt-verify-dialog-title" aria-describedby="aksrt-verify-dialog-description"></dialog>';
echo '<noscript><p class="aksrt-native-callout">' . esc_html__('请启用 JavaScript 后查看申请资料和处理审核。', 'aksrt') . '</p></noscript>';
NativeUi::page_close();
}
/** @return array{status:string,keyword:string} */
private function get_verify_filters(): array {
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending';
if (!in_array($status, ['pending', 'approved', 'rejected', ''], true)) $status = 'pending';
private function get_verify_filters(array $input): array {
$status = isset($input['status']) ? sanitize_key((string) wp_unslash($input['status'])) : 'pending';
return [
'status' => $status,
'keyword' => isset($_GET['keyword']) ? sanitize_text_field((string) wp_unslash($_GET['keyword'])) : '',
'status' => in_array($status, self::VERIFY_STATUSES, true) ? $status : 'pending',
'keyword' => sanitize_text_field((string) wp_unslash($input['keyword'] ?? '')),
];
}
/** @return array{pending:int,approved:int,rejected:int} */
private function get_verify_status_counts(): array {
return [
'pending' => $this->count_verify_status('pending'),
'approved' => $this->count_verify_status('verified'),
'rejected' => $this->count_verify_status('rejected'),
];
}
/** @return array{items:array<int,array<string,mixed>>,pages:int} */
private function query_verify_requests(array $filters, int $page, int $per_page): array {
/** @return array{0:string,1:array} */
private function verify_query_base(array $filters): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('identity_application');
$where = ["a.type = 'general'", "a.channel = 'manual'"];
$params = [];
$where = ['a.type = %s', 'a.channel = %s'];
$params = ['general', 'manual'];
if ($filters['status'] !== '') {
$where[] = 'a.status = %s';
$params[] = $filters['status'] === 'pending' ? 'manual_pending' : ($filters['status'] === 'approved' ? 'verified' : $filters['status']);
$params[] = ['pending' => 'manual_pending', 'approved' => 'verified', 'rejected' => 'rejected'][$filters['status']];
}
if ($filters['keyword'] !== '') {
$like = '%' . $wpdb->esc_like($filters['keyword']) . '%';
$where[] = '(CAST(u.ID AS CHAR) LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s OR u.user_email LIKE %s OR a.public_name LIKE %s OR a.provider_result_summary LIKE %s)';
array_push($params, $like, $like, $like, $like, $like, $like);
}
$condition = implode(' AND ', $where);
$base = "FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE {$condition}";
$count_sql = "SELECT COUNT(*) {$base}";
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
$limit = max(1, min(100, $per_page));
$query_params = array_merge($params, [$limit, (max(1, $page) - 1) * $limit]);
$sql = "SELECT a.id, a.application_uuid, a.user_id, a.type, a.channel, a.status, a.public_name, a.provider_result_summary AS description, a.manual_reason, a.review_note, a.evidence_ids, a.created_at, u.display_name, u.user_login, u.user_email {$base} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d";
return ["FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE " . implode(' AND ', $where), $params];
}
/** @return array{pending:int,approved:int,rejected:int,all:int} */
private function get_verify_status_counts(array $filters): array {
global $wpdb;
$filters['status'] = '';
[$base, $params] = $this->verify_query_base($filters);
$rows = $wpdb->get_results($wpdb->prepare("SELECT a.status, COUNT(*) AS total {$base} GROUP BY a.status", $params), ARRAY_A);
$counts = ['pending' => 0, 'approved' => 0, 'rejected' => 0, 'all' => 0];
$keys = ['manual_pending' => 'pending', 'verified' => 'approved', 'rejected' => 'rejected'];
foreach ((array) $rows as $row) {
$total = (int) $row['total'];
$counts['all'] += $total;
if (isset($keys[$row['status']])) $counts[$keys[$row['status']]] = $total;
}
return $counts;
}
/** @return array{items:array,page:int,total:int} */
private function query_verify_requests(array $filters, int $page): array {
global $wpdb;
[$base, $params] = $this->verify_query_base($filters);
$total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) {$base}", $params));
$page = min(max(1, $page), max(1, (int) ceil($total / self::VERIFY_PER_PAGE)));
$query_params = array_merge($params, [self::VERIFY_PER_PAGE, ($page - 1) * self::VERIFY_PER_PAGE]);
$sql = "SELECT a.id, a.application_uuid, a.user_id, a.status, a.public_name, a.provider_result_summary AS description, a.manual_reason, a.review_note, a.reviewer_id, a.evidence_ids, a.created_at, a.completed_at, u.display_name, u.user_login, u.user_email {$base} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A);
return ['items' => is_array($rows) ? $rows : [], 'pages' => max(1, (int) ceil(max(1, $total) / $limit))];
$items = is_array($rows) ? $rows : [];
$users = [];
$evidence = [];
foreach ($items as &$row) {
$ids = json_decode((string) $row['evidence_ids'], true);
$row['evidence_ids'] = is_array($ids) ? array_values(array_filter(array_map('absint', $ids))) : [];
array_push($evidence, ...$row['evidence_ids']);
$users[] = (int) $row['user_id'];
$users[] = (int) $row['reviewer_id'];
}
unset($row);
$users = array_values(array_unique(array_filter($users)));
if ($users !== []) cache_users($users);
if ($evidence !== []) update_meta_cache('post', array_values(array_unique($evidence)));
return ['items' => $items, 'page' => $page, 'total' => $total];
}
private function render_verify_filters(array $filters): void {
echo '<form method="get" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_user_auth" />';
echo '<label>' . esc_html__('状态', 'aksrt') . '<select name="status">';
private function verify_page_url(array $args = []): string {
return add_query_arg(array_merge(['page' => 'aksrt_user_auth'], $args), admin_url('users.php'));
}
private function render_verify_filters(array $filters, array $counts): void {
echo '<div class="aksrt-verify-toolbar"><div class="aksrt-verify-toolbar__views">';
echo '<h2 class="aksrt-native-card__title">' . esc_html__('认证申请', 'aksrt') . '</h2>';
echo '<ul class="subsubsub" aria-label="' . esc_attr__('认证状态筛选', 'aksrt') . '">';
foreach (['pending' => __('待审核', 'aksrt'), 'approved' => __('已通过', 'aksrt'), 'rejected' => __('已拒绝', 'aksrt'), '' => __('全部', 'aksrt')] as $key => $label) {
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['status'], $key, false), esc_html($label));
$url = $this->verify_page_url(['status' => $key, 'keyword' => $filters['keyword']]);
echo '<li><a href="' . esc_url($url) . '"' . ($key === $filters['status'] ? ' class="current" aria-current="page"' : '') . '>' . esc_html($label)
. ' <span class="count">' . esc_html(number_format_i18n($counts[$key === '' ? 'all' : $key])) . '</span></a></li>';
}
echo '</select></label><label>' . esc_html__('搜索', 'aksrt') . '<input type="search" name="keyword" value="' . esc_attr($filters['keyword']) . '" /></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
echo '</ul></div><form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_user_auth"><input type="hidden" name="status" value="' . esc_attr($filters['status']) . '">';
echo '<label class="screen-reader-text" for="aksrt-verify-search">' . esc_html__('搜索用户或认证资料', 'aksrt') . '</label>';
echo '<input type="search" id="aksrt-verify-search" name="keyword" value="' . esc_attr($filters['keyword']) . '" placeholder="' . esc_attr__('用户 ID / 昵称 / 邮箱 / 认证名称', 'aksrt') . '">';
echo '<button type="submit" class="button">' . esc_html__('搜索', 'aksrt') . '</button>';
if ($filters['keyword'] !== '') {
echo '<a class="button-link" href="' . esc_url($this->verify_page_url(['status' => $filters['status']])) . '">' . esc_html__('清除搜索', 'aksrt') . '</a>';
}
echo '</form></div>';
}
private function render_verify_table(array $items): void {
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>用户</th><th>状态</th><th>认证名称</th><th>认证简介 / 申请说明</th><th>证明材料</th><th>审核</th></tr></thead><tbody>';
if ($items === []) {
echo NativeUi::empty_row(6, __('暂无认证申请', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
private function render_verify_table(array $items, array $filters, int $page): void {
echo '<table class="widefat fixed striped aksrt-verify-table"><thead><tr>';
foreach (['user' => __('申请用户', 'aksrt'), 'profile' => __('认证资料', 'aksrt'), 'evidence' => __('证明材料', 'aksrt'), 'status' => __('状态', 'aksrt'), 'time' => __('申请时间', 'aksrt'), 'actions' => __('操作', 'aksrt')] as $key => $label) {
echo '<th scope="col" class="column-' . esc_attr($key) . '">' . esc_html($label) . '</th>';
}
foreach ($items as $row) $this->render_verify_row($row);
echo '</tr></thead><tbody>';
if ($items === []) {
$title = $filters['status'] === 'pending' && $filters['keyword'] === '' ? __('暂无待审核申请', 'aksrt') : __('没有符合条件的认证申请', 'aksrt');
$hint = $filters['keyword'] !== '' ? __('请调整搜索关键词或切换状态。', 'aksrt') : __('可切换状态查看其他申请,新申请将在这里显示。', 'aksrt');
echo NativeUi::empty_row(6, $title, $hint); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
}
foreach ($items as $row) $this->render_verify_row($row, $filters, $page);
echo '</tbody></table>';
}
private function render_verify_row(array $row): void {
$user_id = (int) $row['user_id'];
$status = (string) $row['status'];
$application = (new \AKSRT\Identity\ApplicationRepository())->find_by_id((int) $row['id']) ?: $row;
echo '<tr><td>' . NativeUi::user_cell($user_id, (string) $row['display_name']) . '<div class="aksrt-native-muted">' . esc_html((string) $row['user_email']) . '</div></td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo '<td>' . NativeUi::status_badge($status, [
private function render_verify_row(array $row, array $filters, int $page): void {
$id = (int) $row['id'];
$pending = $row['status'] === 'manual_pending';
$evidence = (new EvidenceService())->items($row, true);
echo '<tr><td class="column-user">' . NativeUi::user_cell((int) $row['user_id'], (string) $row['display_name']); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo '<div class="aksrt-verify-sub">' . esc_html((string) $row['user_email']) . '</div></td>';
echo '<td class="column-profile"><strong>' . esc_html((string) $row['public_name']) . '</strong><div class="aksrt-verify-summary">' . esc_html((string) $row['description']) . '</div>';
$this->render_verify_detail_template($row, $evidence, $filters, $page);
$this->render_verify_delete_template($row, $filters, $page);
echo '</td><td class="column-evidence">' . esc_html($evidence === [] ? __('未提供材料', 'aksrt') : sprintf(__('%d 份材料', 'aksrt'), count($evidence))) . '</td>';
echo '<td class="column-status">' . $this->verify_status_badge((string) $row['status']) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo '<td class="column-time">' . esc_html($this->verify_time((string) $row['created_at'])) . '</td>';
echo '<td class="column-actions"><div class="aksrt-verify-row-actions">';
echo '<button type="button" class="button' . ($pending ? ' button-primary' : '') . '" data-aksrt-verify-view="' . $id . '" aria-haspopup="dialog" aria-controls="aksrt-verify-dialog">' . esc_html($pending ? __('审核', 'aksrt') : __('查看详情', 'aksrt')) . '</button>';
echo '<button type="button" class="button-link button-link-delete" data-aksrt-verify-delete="' . $id . '" aria-haspopup="dialog" aria-controls="aksrt-verify-dialog">' . esc_html__('删除记录', 'aksrt') . '</button></div></td></tr>';
}
private function verify_status_badge(string $status): string {
return NativeUi::status_badge($status, [
'manual_pending' => [__('待审核', 'aksrt'), 'warning'],
'verified' => [__('已通过', 'aksrt'), 'success'],
'rejected' => [__('已拒绝', 'aksrt'), 'danger'],
]) . '<div class="aksrt-native-muted">' . esc_html((string) $row['created_at']) . '</div></td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo '<td><strong>' . esc_html((string) $row['public_name']) . '</strong></td>';
echo '<td>' . esc_html((string) $row['description']) . '<p class="description">' . esc_html((string) $row['manual_reason']) . '</p>';
if (!empty($row['review_note'])) echo '<p class="description">' . esc_html__('处理留言:', 'aksrt') . esc_html((string) $row['review_note']) . '</p>';
echo '</td><td>';
foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) {
echo '<a href="' . esc_url($item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url($item['thumbnail_url']) . '" alt="" style="width:64px;height:64px;object-fit:cover;margin:4px;border-radius:4px;" /></a>';
}
echo '</td><td>';
$this->render_verify_actions($row);
echo '</td></tr>';
]);
}
private function render_verify_actions(array $row): void {
$status = (string) $row['status'];
if ($status === 'manual_pending') {
foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $next => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:5px;margin-bottom:10px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify"><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '"><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '"><input type="hidden" name="status" value="' . esc_attr($next) . '">';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<input name="name" type="text" maxlength="64" value="' . esc_attr((string) $row['public_name']) . '" placeholder="' . esc_attr__('认证名称', 'aksrt') . '" required>';
echo '<textarea name="description" rows="2" placeholder="' . esc_attr__('认证简介', 'aksrt') . '">' . esc_textarea((string) $row['description']) . '</textarea>';
echo '<textarea name="note" rows="2" ' . ($next === 'rejected' ? 'required' : '') . ' placeholder="' . esc_attr__('处理留言;拒绝时请填写原因', 'aksrt') . '"></textarea>';
echo '<button class="button ' . ($next === 'verified' ? 'button-primary' : '') . '">' . esc_html($label) . '</button></form>';
}
} else {
echo '<span class="description">' . esc_html__('已处理', 'aksrt') . '</span>';
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" onsubmit="return confirm(\'' . esc_js(__('确定删除此认证申请记录及其证明材料?', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_users_delete_verify"><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '">';
wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']);
echo '<button class="button button-link-delete">' . esc_html__('删除记录', 'aksrt') . '</button></form>';
private function verify_time(string $utc): string {
return $utc !== '' && $utc !== '0000-00-00 00:00:00' ? get_date_from_gmt($utc, 'Y-m-d H:i') : '—';
}
/** @return array{status:string,keyword:string,paged:int} */
private function verify_return_args(): array {
return array_merge($this->get_verify_filters(['status' => $_POST['filter_status'] ?? 'pending', 'keyword' => $_POST['keyword'] ?? '']), [
'paged' => max(1, (int) ($_POST['paged'] ?? 1)),
]);
}
public function handle_review_verify(): void {
if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$user_id = absint($_POST['user_id'] ?? 0);
check_admin_referer('aksrt_users_review_verify_' . $user_id);
$uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? ''));
check_admin_referer('aksrt_users_review_verify_' . $uuid);
$user_id = absint($_POST['user_id'] ?? 0);
$status = sanitize_key((string) wp_unslash($_POST['status'] ?? ''));
$note = sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? ''));
$fields = [
'name' => sanitize_text_field((string) wp_unslash($_POST['name'] ?? '')),
'description' => sanitize_textarea_field((string) wp_unslash($_POST['description'] ?? '')),
];
$result = \AKSRT\User\AuthApply::review($user_id, $uuid, $status, $note, get_current_user_id(), $fields);
$extra = ['status' => 'pending'];
$result = AuthApply::review($user_id, $uuid, $status, $note, get_current_user_id(), $fields);
$extra = $this->verify_return_args();
if (is_wp_error($result)) $extra['aksrt_verify_error'] = $result->get_error_message();
$this->redirect('aksrt_user_auth', is_wp_error($result) ? 'verify_failed' : 'verify_updated', $extra);
}
@@ -174,21 +223,19 @@ trait UsersMenusVerify {
if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? ''));
check_admin_referer('aksrt_users_delete_verify_' . $uuid);
$repo = new \AKSRT\Identity\ApplicationRepository();
$extra = $this->verify_return_args();
$repo = new ApplicationRepository();
$application = $repo->find_by_uuid($uuid);
if (
!$application
|| (string) ($application['type'] ?? '') !== 'general'
|| (string) ($application['channel'] ?? '') !== 'manual'
) {
$this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')]);
if (!$application) {
$this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')]));
}
$deleted = $repo->delete((int) $application['id']);
if (!$deleted) {
$this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')]);
if (!$repo->delete((int) $application['id'])) {
$this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')]));
}
(new \AKSRT\Identity\EvidenceService())->delete((array) ($application['evidence_ids'] ?? []));
(new \AKSRT\Identity\EventRepository())->delete_for_application((int) $application['id']);
$this->redirect('aksrt_user_auth', 'verify_deleted');
(new EvidenceService())->delete((array) $application['evidence_ids']);
if (!(new EventRepository())->delete_for_application((int) $application['id'])) {
$this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证申请记录已删除,但审核事件清理失败。', 'aksrt')]));
}
$this->redirect('aksrt_user_auth', 'verify_deleted', $extra);
}
}
@@ -0,0 +1,124 @@
<?php
/** Identity application detail, review and deletion dialogs. @package AKSRT */
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusVerifyDialogs {
private function render_verify_dialog_head(string $title, string $description): void {
echo '<div class="aksrt-verify-dialog__head"><div><h2 id="aksrt-verify-dialog-title">' . esc_html($title) . '</h2>';
echo '<p id="aksrt-verify-dialog-description">' . esc_html($description) . '</p></div>';
echo '<button type="button" class="aksrt-verify-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></div>';
}
private function render_verify_context(array $row, array $filters, int $page, string $action): void {
echo '<input type="hidden" name="action" value="' . esc_attr($action) . '">';
echo '<input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '">';
echo '<input type="hidden" name="filter_status" value="' . esc_attr($filters['status']) . '">';
echo '<input type="hidden" name="keyword" value="' . esc_attr($filters['keyword']) . '">';
echo '<input type="hidden" name="paged" value="' . esc_attr((string) $page) . '">';
wp_nonce_field($action . '_' . (string) $row['application_uuid']);
}
private function render_verify_detail_template(array $row, array $evidence, array $filters, int $page): void {
$pending = $row['status'] === 'manual_pending';
echo '<template id="aksrt-verify-detail-' . (int) $row['id'] . '">';
if ($pending) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-verify-dialog__frame" data-aksrt-verify-review>';
$this->render_verify_context($row, $filters, $page, 'aksrt_users_review_verify');
echo '<input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '">';
} else {
echo '<div class="aksrt-verify-dialog__frame">';
}
$this->render_verify_dialog_head(
$pending ? __('审核认证申请', 'aksrt') : __('认证申请详情', 'aksrt'),
$pending ? __('先核对申请资料与证明材料,再选择审核结果。', 'aksrt') : __('该申请已处理,以下为申请资料与审核结果。', 'aksrt')
);
echo '<div class="aksrt-verify-dialog__body">';
echo '<div class="aksrt-verify-applicant">' . NativeUi::user_cell((int) $row['user_id'], (string) $row['display_name']); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo $this->verify_status_badge((string) $row['status']) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
$meta = [
__('用户账号', 'aksrt') => (string) $row['user_login'],
__('邮箱', 'aksrt') => (string) $row['user_email'],
__('申请时间', 'aksrt') => $this->verify_time((string) $row['created_at']),
];
if (!$pending) {
$reviewer = get_userdata((int) $row['reviewer_id']);
$meta[__('审核人', 'aksrt')] = $reviewer ? (string) $reviewer->display_name : '—';
$meta[__('审核时间', 'aksrt')] = $this->verify_time((string) ($row['completed_at'] ?? ''));
}
echo '<dl class="aksrt-verify-meta">';
foreach ($meta as $label => $value) {
echo '<div><dt>' . esc_html($label) . '</dt><dd>' . esc_html($value !== '' ? $value : '—') . '</dd></div>';
}
echo '</dl>';
$this->render_verify_text_section(__('认证名称', 'aksrt'), (string) $row['public_name']);
$this->render_verify_text_section(__('认证简介', 'aksrt'), (string) $row['description']);
$this->render_verify_text_section(__('申请说明', 'aksrt'), (string) $row['manual_reason']);
$this->render_verify_evidence($evidence);
if ($pending) {
$this->render_verify_review_fields($row);
} else {
$this->render_verify_text_section($row['status'] === 'rejected' ? __('拒绝原因', 'aksrt') : __('处理留言', 'aksrt'), (string) $row['review_note']);
}
echo '</div><div class="aksrt-verify-dialog__foot">';
echo '<button type="button" class="button" data-aksrt-dialog-close>' . esc_html($pending ? __('取消', 'aksrt') : __('关闭', 'aksrt')) . '</button>';
if ($pending) {
echo '<button type="submit" class="button button-primary" data-aksrt-verify-submit disabled'
. ' data-idle-label="' . esc_attr__('请选择审核结果', 'aksrt') . '"'
. ' data-approve-label="' . esc_attr__('确认通过', 'aksrt') . '"'
. ' data-reject-label="' . esc_attr__('确认拒绝', 'aksrt') . '"'
. ' data-busy-label="' . esc_attr__('正在提交…', 'aksrt') . '">' . esc_html__('请选择审核结果', 'aksrt') . '</button>';
}
echo '</div>' . ($pending ? '</form>' : '</div>') . '</template>';
}
private function render_verify_text_section(string $title, string $text): void {
echo '<section class="aksrt-verify-section"><h3>' . esc_html($title) . '</h3><div class="aksrt-verify-copy">' . esc_html($text !== '' ? $text : __('未填写', 'aksrt')) . '</div></section>';
}
private function render_verify_evidence(array $evidence): void {
echo '<section class="aksrt-verify-section"><h3>' . esc_html__('证明材料', 'aksrt') . '</h3>';
if ($evidence === []) {
echo '<p class="aksrt-verify-evidence-empty">' . esc_html__('未提供可查看的证明材料,请结合申请说明判断。', 'aksrt') . '</p>';
} else {
echo '<ul class="aksrt-verify-evidence">';
foreach ($evidence as $index => $item) {
$label = sprintf(__('材料 %d(新窗口查看原图)', 'aksrt'), $index + 1);
echo '<li><a href="' . esc_url($item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url($item['thumbnail_url']) . '" alt="' . esc_attr(sprintf(__('证明材料 %d', 'aksrt'), $index + 1)) . '"><span>' . esc_html($label) . '</span></a></li>';
}
echo '</ul>';
}
echo '</section>';
}
private function render_verify_review_fields(array $row): void {
echo '<section class="aksrt-verify-review"><h3>' . esc_html__('审核处理', 'aksrt') . '</h3>';
echo '<fieldset class="aksrt-verify-decisions"><legend>' . esc_html__('审核结果(必选)', 'aksrt') . '</legend>';
foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $status => $label) {
echo '<label><input type="radio" name="status" value="' . esc_attr($status) . '" required><span>' . esc_html($label) . '</span></label>';
}
echo '</fieldset><fieldset class="aksrt-verify-profile" data-aksrt-verify-profile hidden disabled><legend>' . esc_html__('通过后公开展示的认证资料', 'aksrt') . '</legend>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('认证名称(必填)', 'aksrt') . '</span>';
echo '<input type="text" name="name" maxlength="64" value="' . esc_attr((string) $row['public_name']) . '" required data-required-error="' . esc_attr__('请填写认证名称', 'aksrt') . '"></label>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('认证简介', 'aksrt') . '</span><textarea name="description" rows="3">' . esc_textarea((string) $row['description']) . '</textarea></label>';
echo '<p class="aksrt-native-field__hint">' . esc_html__('通过审核前可修订名称与简介,原申请说明和证明材料不会被修改。', 'aksrt') . '</p></fieldset>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label" data-aksrt-verify-note-label data-optional="' . esc_attr__('处理留言(选填)', 'aksrt') . '" data-required="' . esc_attr__('拒绝原因(必填)', 'aksrt') . '">' . esc_html__('处理留言(选填)', 'aksrt') . '</span>';
echo '<textarea name="note" rows="3" data-required-error="' . esc_attr__('请填写拒绝原因', 'aksrt') . '" aria-describedby="aksrt-verify-note-hint"></textarea></label>';
echo '<p id="aksrt-verify-note-hint" class="aksrt-native-field__hint">' . esc_html__('审核结果会通知申请用户;拒绝时请说明原因,便于用户修改后重新申请。', 'aksrt') . '</p></section>';
}
private function render_verify_delete_template(array $row, array $filters, int $page): void {
echo '<template id="aksrt-verify-delete-' . (int) $row['id'] . '"><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-verify-dialog__frame" data-aksrt-verify-delete-form>';
$this->render_verify_context($row, $filters, $page, 'aksrt_users_delete_verify');
$this->render_verify_dialog_head(__('删除认证申请记录', 'aksrt'), __('请核对申请信息。此操作不可撤销。', 'aksrt'));
echo '<div class="aksrt-verify-dialog__body"><div class="aksrt-verify-applicant">' . NativeUi::user_cell((int) $row['user_id'], (string) $row['display_name']); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
echo $this->verify_status_badge((string) $row['status']) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
$this->render_verify_text_section(__('认证名称', 'aksrt'), (string) $row['public_name']);
echo '<p class="aksrt-native-callout is-danger">' . esc_html__('将永久删除该申请记录、证明材料及审核事件。删除记录不会撤销用户已获得的认证。', 'aksrt') . '</p>';
echo '</div><div class="aksrt-verify-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close autofocus>' . esc_html__('取消', 'aksrt') . '</button>';
echo '<button type="submit" class="button is-danger" data-aksrt-verify-submit data-idle-label="' . esc_attr__('确认删除', 'aksrt') . '" data-busy-label="' . esc_attr__('正在提交…', 'aksrt') . '">' . esc_html__('确认删除', 'aksrt') . '</button></div></form></template>';
}
}
+6 -2
View File
@@ -83,7 +83,7 @@ final class EvidenceService {
/** @return array<int,array{id:int,url:string,thumbnail_url:string}> */
public function items(array $application, bool $admin = false): array {
$owner_id = (int) ($application['user_id'] ?? 0);
if ($owner_id <= 0 || ($admin ? !current_user_can('aksrt_review_identity') : get_current_user_id() !== $owner_id)) {
if ($owner_id <= 0 || ($admin ? !$this->can_review() : get_current_user_id() !== $owner_id)) {
return [];
}
$items = [];
@@ -111,7 +111,7 @@ final class EvidenceService {
wp_die(esc_html__('安全校验失败', 'aksrt'), '', ['response' => 403]);
}
$owner = (int) get_post_meta($id, self::OWNER_META, true);
if ($owner <= 0 || (get_current_user_id() !== $owner && !current_user_can('aksrt_review_identity'))) {
if ($owner <= 0 || (get_current_user_id() !== $owner && !$this->can_review())) {
wp_die(esc_html__('您没有权限查看该证明材料', 'aksrt'), '', ['response' => 403]);
}
$path = get_attached_file($id);
@@ -132,6 +132,10 @@ final class EvidenceService {
exit;
}
private function can_review(): bool {
return current_user_can('aksrt_review_identity') || current_user_can('manage_options');
}
/** @return array<int,array<string,mixed>>|\WP_Error */
private function normalize($input) {
if (!is_array($input) || empty($input['name'])) {
@@ -0,0 +1,271 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
let result;
function harness() {
if (!result) {
const run = spawnSync('php', [path.join(__dirname, 'user-auth-admin-harness.php')], { encoding: 'utf8' });
assert.equal(run.status, 0, `${run.stderr}${run.stdout}`);
assert.equal(run.stderr, '', 'the PHP harness must not emit warnings');
result = JSON.parse(run.stdout);
}
return result;
}
function template(html, id) {
const safeId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = html.match(new RegExp(`<template\\b[^>]*\\bid="${safeId}"[^>]*>[\\s\\S]*?<\\/template>`));
assert.ok(match, `missing template ${id}`);
return match[0];
}
const source = {
verify: () => read('src', 'Admin', 'Native', 'UsersMenusVerify.php'),
dialogs: () => read('src', 'Admin', 'Native', 'UsersMenusVerifyDialogs.php'),
menus: () => read('src', 'Admin', 'Native', 'UsersMenus.php'),
authApply: () => read('src', 'User', 'AuthApply.php'),
applicationRepository: () => read('src', 'Identity', 'ApplicationRepository.php'),
};
test('default pending filters, keyword, status views, list and pagination share one card', () => {
const { html } = harness();
const cardStart = html.indexOf('class="aksrt-native-card aksrt-native-card--flush aksrt-verify-list-card"');
const dialogStart = html.indexOf('<dialog id="aksrt-verify-dialog"');
const cardEnd = html.lastIndexOf('</section>', dialogStart);
assert.ok(cardStart >= 0 && cardEnd > cardStart, 'verification list card wraps its content');
const card = html.slice(cardStart, cardEnd + '</section>'.length);
assert.equal((html.match(/aksrt-verify-list-card/g) || []).length, 1);
for (const marker of ['class="subsubsub"', 'class="aksrt-native-filters"', 'aksrt-verify-table', 'class="tablenav bottom"']) {
assert.ok(card.includes(marker), `${marker} should remain inside the list card`);
}
assert.match(card, /<table class="widefat fixed striped aksrt-verify-table">/);
assert.doesNotMatch(card, /class="wp-list-table/);
assert.match(card, /name="status" value="pending"/);
assert.match(card, /name="keyword" value="search alert\(1\) &amp;_%"/);
assert.match(card, /status=pending&amp;keyword=search\+alert%281%29\+%26_%25/);
assert.match(card, /class="current" aria-current="page">待审核/);
assert.match(card, /第 3 页,共 <span class="total-pages">3<\/span> 页/);
assert.match(card, /name="paged" value="3"/);
assert.doesNotMatch(card, /class="aksrt-verify-filter-summary"/);
});
test('rendered user/application data is escaped and row review forms exist only in inert templates', () => {
const { html } = harness();
assert.match(html, /Applicant &lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /Applicant name &lt;script&gt;alert\(3\)&lt;\/script&gt;/);
assert.match(html, /Bio &lt;img src=x onerror=alert\(4\)&gt; &amp; text/);
assert.match(html, /Reviewer &lt;img src=x onerror=alert\(2\)&gt;/);
assert.doesNotMatch(html, /<script>|<svg\b|<img[^>]*onerror=/i);
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/g, '');
assert.doesNotMatch(outsideTemplates, /<form\b[^>]*data-aksrt-verify-review/);
assert.equal((html.match(/data-aksrt-verify-review/g) || []).length, 1);
assert.equal((html.match(/data-aksrt-verify-delete-form/g) || []).length, 2);
assert.match(html, /data-aksrt-verify-view="301"/);
assert.match(html, /data-aksrt-verify-view="302"/);
});
test('detail and delete templates bind UUID nonces, and processed records are read-only', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
const pendingDelete = template(html, 'aksrt-verify-delete-301');
const approved = template(html, 'aksrt-verify-detail-302');
const approvedDelete = template(html, 'aksrt-verify-delete-302');
assert.match(pending, /name="application_uuid" value="uuid-pending-301"/);
assert.match(pending, /name="_wpnonce" value="nonce:aksrt_users_review_verify_uuid-pending-301"/);
assert.match(pendingDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-pending-301"/);
assert.match(approvedDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-approved-302"/);
assert.doesNotMatch(approved, /<form\b|data-aksrt-verify-review/);
assert.match(approved, /Reviewed safely/);
assert.match(approved, /2026-10-30 12:45/);
assert.doesNotMatch(pending + pendingDelete + approvedDelete, /aksrt_users_(?:review|delete)_verify_41/);
});
test('review UI exposes both decisions, conditional approval profile fields, and mandatory rejection note metadata', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
assert.match(pending, /name="status" value="verified" required/);
assert.match(pending, /name="status" value="rejected" required/);
assert.match(pending, /data-aksrt-verify-profile hidden disabled/);
assert.match(pending, /name="name" maxlength="64"[^>]*required/);
assert.match(pending, /<textarea name="description" rows="3">/);
assert.match(pending, /name="note" rows="3"[^>]*data-required-error="请填写拒绝原因"/);
assert.match(pending, /data-required="拒绝原因(必填)"/);
});
test('proof links are private admin-post URLs protected by per-attachment nonces', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
const link = pending.match(/<a href="([^"]+)" target="_blank" rel="noopener noreferrer"><img/);
assert.ok(link, 'evidence is displayed through a controlled link');
const url = new URL(link[1].replaceAll('&amp;', '&'));
assert.equal(url.origin, 'https://example.test');
assert.equal(url.pathname, '/wp-admin/admin-post.php');
assert.equal(url.searchParams.get('action'), 'aksrt_verify_evidence');
assert.equal(url.searchParams.get('attachment'), '1001');
assert.equal(url.searchParams.get('_wpnonce'), 'nonce:aksrt_verify_evidence_1001');
assert.doesNotMatch(link[1], /uploads|wp-content/i);
});
test('empty states, GMT conversion, clamped offset, prepared filters and batch caches are real trait behavior', () => {
const data = harness();
assert.match(data.pending_empty, /暂无待审核申请/);
assert.match(data.search_empty, /没有符合条件的认证申请/);
assert.match(data.search_empty, /请调整搜索关键词或切换状态/);
assert.match(data.html, /2026-10-30 11:30/);
assert.match(data.html, /2026-10-29 10:10/);
assert.equal(data.page_prepared.length, 3, 'status count, total and list queries are prepared once each');
const like = String.raw`%search alert(1) &\_\%%`;
for (const query of data.page_prepared) {
assert.deepEqual(query.params.slice(0, 2), ['general', 'manual']);
assert.match(query.sql, /a\.type = %s AND a\.channel = %s/);
assert.ok(query.params.includes(like), 'keyword wildcard characters are escaped before prepare');
}
const listQuery = data.page_prepared.find(({ sql }) => /LIMIT %d OFFSET %d/.test(sql));
assert.ok(listQuery, 'the list query has typed integer pagination placeholders');
assert.deepEqual(listQuery.params.slice(-2), [20, 40], 'requested page 999 is clamped to the final page before OFFSET');
assert.equal(listQuery.params[2], 'manual_pending', 'the pending view filters the stored status');
const groupedQuery = data.page_prepared.find(({ sql }) => /GROUP BY a.status/.test(sql));
assert.doesNotMatch(groupedQuery.sql, /a\.status = %s/, 'status view counts span every status for the same keyword');
assert.equal(data.page_executed.filter((sql) => /application_uuid\s*=/.test(sql)).length, 0, 'rendering does not run per-row find queries');
assert.deepEqual(data.cache_calls, [[41, 42, 9]], 'all applicant/reviewer user ids are primed in one batch');
assert.deepEqual(data.meta_cache_calls, [['post', [1001, 1002]]], 'evidence post meta is primed in one deduplicated batch');
});
test('review handler checks permission and UUID nonce, sanitizes payload, and preserves independent list filters', () => {
const data = harness();
assert.equal(data.permission_denied, true);
assert.equal(data.bad_nonce_rejected, true);
assert.deepEqual(data.review_nonce_calls[0], ['aksrt_users_review_verify_review-uuid-401', '_wpnonce']);
assert.deepEqual(data.review_call, {
user_id: 41,
application_uuid: 'review-uuid-401',
status: 'rejected',
note: 'Rejected reason & explain',
reviewer_id: 7,
profile_fields: { name: 'Ignored', description: 'Ignored description' },
});
assert.deepEqual(data.review_error_call, {
user_id: 41,
application_uuid: 'review-uuid-401',
status: 'verified',
note: '',
reviewer_id: 7,
profile_fields: { name: 'Safe name', description: 'Safe description' },
});
assert.deepEqual(data.review_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_updated',
status: 'approved',
keyword: 'review search & phrase',
paged: '4',
});
assert.deepEqual(data.review_error_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'keep this',
paged: '2',
aksrt_verify_error: '<script>review unavailable</script>',
});
});
test('delete handler cleans attachments and events only after successful delete, without revoking auth projection', () => {
const data = harness();
assert.deepEqual(data.delete_success_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_deleted',
status: 'rejected',
keyword: 'delete search',
paged: '6',
});
assert.deepEqual(data.delete_success_calls, [
{ table: 'wp_aksrt_identity_application', data: { id: 501, type: 'general', channel: 'manual' }, format: ['%d', '%s', '%s'] },
{ table: 'wp_aksrt_identity_event', data: { application_id: 501 }, format: ['%d'] },
]);
assert.deepEqual(data.delete_success_attachments, [[1001, true], [1002, true]]);
assert.deepEqual(data.delete_auth_projection, { auth: 1, auth_info: { name: 'Already verified', desc: 'kept' } });
assert.deepEqual(data.delete_user_meta_calls, []);
assert.deepEqual(data.delete_failure_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'pending',
keyword: 'delete failure',
paged: '1',
aksrt_verify_error: '认证记录删除失败',
});
assert.equal(data.delete_failure_calls.length, 1, 'event rows are untouched after repository deletion fails');
assert.match(data.delete_failure_calls[0].table, /identity_application/);
assert.deepEqual(data.delete_failure_attachments, []);
});
test('proof listing and serving honor both reviewer capabilities without exposing other users’ evidence', () => {
const { evidence_permissions: permissions } = harness();
for (const role of ['manager', 'reviewer']) {
assert.equal(permissions[role].admin_items, 1);
assert.equal(permissions[role].owner_items, 0);
assert.equal(permissions[role].serve_result, '证明材料不存在', 'authorized users reach the file check');
}
assert.deepEqual(permissions.outsider, {
admin_items: 0, owner_items: 0, serve_result: '您没有权限查看该证明材料',
});
assert.deepEqual(permissions.owner, {
admin_items: 0, owner_items: 1, serve_result: '证明材料不存在',
});
});
test('event cleanup failure reports partial deletion instead of a false success', () => {
const data = harness();
assert.equal(data.delete_event_failure_calls.length, 2);
assert.deepEqual(data.delete_event_failure_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'delete search',
paged: '6',
aksrt_verify_error: '认证申请记录已删除,但审核事件清理失败。',
});
});
test('assets are restricted to the authentication submenu and the stylesheet follows native UI', () => {
const data = harness();
assert.deepEqual(data.wrong_hook_assets, []);
assert.equal(data.auth_page_assets.length, 2);
assert.deepEqual(data.auth_page_assets[0].slice(0, 4), [
'style', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/css/admin/user-auth.css', ['aksrt-admin-native-ui'],
]);
assert.deepEqual(data.auth_page_assets[1].slice(0, 4), [
'script', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/js/admin-user-auth.js', [],
]);
assert.match(source.verify(), /if \(\$hook !== 'users_page_aksrt_user_auth'\) return/);
assert.match(source.menus(), /add_action\('admin_enqueue_scripts', \[\$this, 'enqueue_verify_assets'\]\)/);
});
test('the real AuthApply contract requires valid review data and a compare-and-swap from manual_pending', () => {
const auth = source.authApply();
const repository = source.applicationRepository();
assert.match(auth, /if \(!in_array\(\$status, \['verified', 'rejected'\], true\)\)/);
assert.match(auth, /if \(\$status === 'rejected' && \$note === ''\)/);
assert.match(auth, /\(int\) \$application\['user_id'\] !== \$user_id/);
assert.match(auth, /\(string\) \$application\['status'\] !== 'manual_pending'/);
assert.match(auth, /\$repo->transition\(\(int\) \$application\['id'\], 'manual_pending', \$status/);
assert.match(auth, /if \(!\$updated\) \{\s*return new \\WP_Error\('aksrt_auth_conflict'/);
assert.match(repository, /\['id' => \$id, 'type' => 'general', 'channel' => 'manual', 'status' => \$expected\]/);
});
test('review and deletion nonces use application UUIDs only, while failure notices are escaped by the shell', () => {
const verify = source.verify();
const dialogs = source.dialogs();
const menus = source.menus();
assert.match(verify, /check_admin_referer\('aksrt_users_review_verify_' \. \$uuid\)/);
assert.match(verify, /check_admin_referer\('aksrt_users_delete_verify_' \. \$uuid\)/);
assert.doesNotMatch(verify, /check_admin_referer\('aksrt_users_(?:review|delete)_verify_' \. \$user_id\)/);
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(string\) \$row\['application_uuid'\]\)/);
assert.match(menus, /sanitize_text_field\(\(string\) wp_unslash\(\$_GET\['aksrt_verify_error'\]\)\)/);
assert.match(menus, /esc_html\(\$message\)/);
});
@@ -0,0 +1,518 @@
<?php
/** WordPress-free harness for the native identity admin render and POST handlers. */
namespace AKSRT\Core {
final class DBMigrator {
public static function table($key) { return 'wp_aksrt_' . $key; }
}
}
namespace AKSRT\User {
final class AuthApply {
public static $next_result = null;
public static function review($user_id, $application_uuid, $status, $note, $reviewer_id, $profile_fields) {
$GLOBALS['review_calls'][] = compact('user_id', 'application_uuid', 'status', 'note', 'reviewer_id', 'profile_fields');
return self::$next_result;
}
}
}
namespace {
define('ABSPATH', __DIR__);
define('ARRAY_A', 'ARRAY_A');
$root = dirname(__DIR__, 2);
define('AKSRT_DIR', $root);
define('AKSRT_URI', 'https://example.test/theme');
class WP_Error {
public function __construct(public $code, public $message) {}
public function get_error_message() { return $this->message; }
}
class HarnessWpDie extends \RuntimeException {}
class HarnessRedirect extends \RuntimeException {}
class WP_User {
public function __construct(public $ID, public $display_name) {}
}
function __($text, $domain = '') { return (string) $text; }
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($text) { return esc_html($text); }
function esc_textarea($text) { return esc_html($text); }
function esc_html__($text, $domain = '') { return esc_html($text); }
function esc_attr__($text, $domain = '') { return esc_attr($text); }
function esc_url($url) {
$url = (string) $url;
return preg_match('/^\s*javascript:/i', $url) ? '' : esc_attr($url);
}
function admin_url($path = '') { return 'https://example.test/wp-admin/' . ltrim((string) $path, '/'); }
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . abs((int) $id)); }
function get_avatar_url($id, $args = []) { return 'https://example.test/avatar.png?user=' . abs((int) $id); }
function get_userdata($id) {
$id = (int) $id;
return isset($GLOBALS['users'][$id]) ? new WP_User($id, $GLOBALS['users'][$id]) : false;
}
function current_user_can($capability, ...$args) { return !empty($GLOBALS['caps'][$capability]); }
function get_current_user_id() { return $GLOBALS['current_user_id'] ?? 7; }
function is_user_logged_in() { return get_current_user_id() > 0; }
function get_attached_file($id) { return false; }
function get_post_mime_type($id) { return 'image/png'; }
function delete_user_meta($user_id, $key) {
$GLOBALS['delete_user_meta_calls'][] = [(int) $user_id, (string) $key];
unset($GLOBALS['user_meta'][(int) $user_id][(string) $key]);
return true;
}
function update_user_meta($user_id, $key, $value) {
$GLOBALS['user_meta'][(int) $user_id][(string) $key] = $value;
return true;
}
function wp_die($message = '') { throw new HarnessWpDie((string) $message); }
function wp_unslash($value) { return $value; }
function absint($value) { return abs((int) $value); }
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)) ?? ''; }
function sanitize_text_field($text) {
$text = strip_tags((string) $text);
$text = preg_replace('/[\r\n\t ]+/', ' ', $text) ?? '';
return trim($text);
}
function sanitize_textarea_field($text) {
$text = strip_tags((string) $text);
$text = preg_replace('/[\r\t ]+/', ' ', $text) ?? '';
$text = preg_replace('/\n{3,}/', "\n\n", $text) ?? '';
return trim($text);
}
function is_wp_error($value) { return $value instanceof WP_Error; }
function number_format_i18n($number) { return number_format((float) $number); }
function get_date_from_gmt($date, $format = 'Y-m-d H:i:s') {
if ((string) $date === '') return '';
$utc = new DateTimeImmutable((string) $date, new DateTimeZone('UTC'));
return $utc->setTimezone(new DateTimeZone('Asia/Shanghai'))->format($format);
}
function wp_create_nonce($action) { return 'nonce:' . (string) $action; }
function wp_nonce_field($action, $name = '_wpnonce', $referer = true, $display = true) {
$html = '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr(wp_create_nonce($action)) . '">';
if ($display) echo $html;
return $html;
}
function check_admin_referer($action, $query_arg = '_wpnonce') {
$GLOBALS['checked_nonces'][] = [$action, $query_arg];
$provided = (string) ($_POST[$query_arg] ?? '');
if (!hash_equals(wp_create_nonce($action), $provided)) {
throw new HarnessWpDie('nonce verification failed');
}
return 1;
}
function add_query_arg($key, $value = '', $url = '') {
if (is_array($key)) {
$args = $key;
$url = (string) $value;
} else {
$args = [$key => $value];
if ($url === '') $url = (string) ($_SERVER['REQUEST_URI'] ?? '');
}
$parts = parse_url((string) $url) ?: [];
parse_str($parts['query'] ?? '', $query);
foreach ($args as $name => $item) $query[$name] = $item;
$base = '';
if (isset($parts['scheme'])) $base .= $parts['scheme'] . '://';
if (isset($parts['host'])) $base .= $parts['host'];
if (isset($parts['port'])) $base .= ':' . $parts['port'];
$base .= $parts['path'] ?? '';
return $base . ($query !== [] ? '?' . http_build_query($query) : '') . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
}
function cache_users($ids) { $GLOBALS['cache_calls'][] = array_values($ids); }
function update_meta_cache($type, $ids) { $GLOBALS['meta_cache_calls'][] = [$type, array_values($ids)]; }
function get_post_meta($id, $key, $single = false) { return $GLOBALS['post_meta'][(int) $id][$key] ?? ''; }
function get_post_type($id) { return in_array((int) $id, $GLOBALS['attachment_ids'], true) ? 'attachment' : false; }
function wp_delete_attachment($id, $force = false) {
$GLOBALS['deleted_attachments'][] = [(int) $id, (bool) $force];
return true;
}
function wp_enqueue_style(...$args) { $GLOBALS['assets'][] = ['style', ...$args]; }
function wp_enqueue_script(...$args) { $GLOBALS['assets'][] = ['script', ...$args]; }
function esc_html_e($text, $domain = '') { echo esc_html($text); }
final class FakeWpdb {
public $users = 'wp_users';
public $prepared = [];
public $executed = [];
public $delete_calls = [];
public $total = 45;
public $application_delete_result = 1;
public $event_delete_result = 1;
public $applications = [];
public function prepare($sql, ...$params) {
$params = count($params) === 1 && is_array($params[0]) ? $params[0] : $params;
$this->prepared[] = ['sql' => (string) $sql, 'params' => $params];
return (string) $sql;
}
public function esc_like($text) { return addcslashes((string) $text, '_%\\'); }
public function get_var($sql) {
$this->executed[] = (string) $sql;
return str_contains((string) $sql, 'SELECT COUNT(*)') ? $this->total : null;
}
public function get_results($sql, $format = null) {
$this->executed[] = (string) $sql;
if (str_contains((string) $sql, 'GROUP BY a.status')) {
return [
['status' => 'manual_pending', 'total' => 2],
['status' => 'verified', 'total' => 1],
['status' => 'rejected', 'total' => 1],
];
}
if (str_contains((string) $sql, 'LIMIT %d OFFSET %d')) return $GLOBALS['render_rows'];
return [];
}
public function get_row($sql, $format = null) {
$this->executed[] = (string) $sql;
$query = $this->prepared[count($this->prepared) - 1] ?? ['params' => []];
$requested_uuid = (string) ($query['params'][0] ?? '');
foreach ($this->applications as $application) {
if (
str_contains((string) $sql, 'application_uuid = %s')
&& str_contains((string) $sql, 'identity_application')
&& (string) ($application['application_uuid'] ?? '') === $requested_uuid
) {
return $application;
}
}
return null;
}
public function delete($table, $data, $format = null) {
$this->delete_calls[] = ['table' => $table, 'data' => $data, 'format' => $format];
if (str_contains((string) $table, 'identity_application')) return $this->application_delete_result;
if (str_contains((string) $table, 'identity_event')) return $this->event_delete_result;
return 1;
}
}
require $root . '/src/Admin/Native/NativeUi.php';
require $root . '/src/Admin/Native/AdminListPagination.php';
require $root . '/src/Identity/ApplicationRepository.php';
require $root . '/src/Identity/EvidenceService.php';
require $root . '/src/Identity/EventRepository.php';
require $root . '/src/Admin/Native/UsersMenusVerifyDialogs.php';
require $root . '/src/Admin/Native/UsersMenusVerify.php';
final class VerifyAdminProbe {
use \AKSRT\Admin\Native\UsersMenusVerify;
public const CAPABILITY = 'manage_options';
public $cap_checks = 0;
private function ensure_cap(): void {
$this->cap_checks++;
if (!current_user_can('manage_options')) wp_die('capability denied');
}
private function flash(): void {}
private function redirect(string $page, string $message, array $extra = []): void {
$url = add_query_arg(array_merge(['page' => $page, 'aksrt_users_msg' => $message], $extra), admin_url('users.php'));
throw new HarnessRedirect($url);
}
public function invoke(string $method, ...$args) { return $this->$method(...$args); }
public function page(): string {
ob_start();
$this->render_verify_page();
return (string) ob_get_clean();
}
public function html(string $method, ...$args): string {
ob_start();
$this->$method(...$args);
return (string) ob_get_clean();
}
}
$GLOBALS['users'] = [
41 => 'Applicant <script>alert(1)</script>',
42 => 'Approved User',
9 => 'Reviewer <img src=x onerror=alert(2)>',
];
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
$GLOBALS['post_meta'] = [
1001 => [
'_aksrt_verify_evidence_owner' => 41,
'_aksrt_identity_application_uuid' => 'uuid-pending-301',
],
1002 => [
'_aksrt_verify_evidence_owner' => 42,
'_aksrt_identity_application_uuid' => 'uuid-approved-302',
],
];
$GLOBALS['attachment_ids'] = [1001, 1002];
$GLOBALS['deleted_attachments'] = [];
$GLOBALS['cache_calls'] = [];
$GLOBALS['meta_cache_calls'] = [];
$GLOBALS['checked_nonces'] = [];
$GLOBALS['review_calls'] = [];
$GLOBALS['assets'] = [];
$GLOBALS['delete_user_meta_calls'] = [];
$GLOBALS['user_meta'] = [41 => ['auth' => 1, 'auth_info' => ['name' => 'Already verified', 'desc' => 'kept']]];
$GLOBALS['render_rows'] = [
[
'id' => 301,
'application_uuid' => 'uuid-pending-301',
'user_id' => 41,
'status' => 'manual_pending',
'public_name' => 'Applicant name <script>alert(3)</script>',
'description' => 'Bio <img src=x onerror=alert(4)> & text',
'manual_reason' => 'Reason <svg onload=alert(5)>',
'review_note' => '',
'reviewer_id' => 0,
'evidence_ids' => '[1001]',
'created_at' => '2026-10-30 03:30:00',
'completed_at' => null,
'display_name' => 'Applicant <script>alert(1)</script>',
'user_login' => 'applicant"<script>login</script>',
'user_email' => 'applicant"<img src=x onerror=alert(6)>@example.test',
],
[
'id' => 302,
'application_uuid' => 'uuid-approved-302',
'user_id' => 42,
'status' => 'verified',
'public_name' => 'Approved name',
'description' => 'Approved bio',
'manual_reason' => 'Applied earlier',
'review_note' => 'Reviewed safely',
'reviewer_id' => 9,
'evidence_ids' => '[1002,1001]',
'created_at' => '2026-10-29 02:10:00',
'completed_at' => '2026-10-30 04:45:00',
'display_name' => 'Approved User',
'user_login' => 'approved_user',
'user_email' => 'approved@example.test',
],
];
$wpdb = new FakeWpdb();
$probe = new VerifyAdminProbe();
$_GET = [
'page' => 'aksrt_user_auth',
'keyword' => 'search <script>alert(1)</script> &_%',
'paged' => '999',
];
$html = $probe->page();
$page_prepared = $wpdb->prepared;
$page_executed = $wpdb->executed;
$page_cache_calls = $GLOBALS['cache_calls'];
$page_meta_cache_calls = $GLOBALS['meta_cache_calls'];
$pending_empty = $probe->html('render_verify_table', [], ['status' => 'pending', 'keyword' => ''], 1);
$search_empty = $probe->html('render_verify_table', [], ['status' => 'approved', 'keyword' => 'no match'], 1);
$GLOBALS['assets'] = [];
$probe->enqueue_verify_assets('users_page_aksrt_messages');
$wrong_hook_assets = $GLOBALS['assets'];
$probe->enqueue_verify_assets('users_page_aksrt_user_auth');
$auth_page_assets = $GLOBALS['assets'];
$evidence_service = new \AKSRT\Identity\EvidenceService();
$evidence_application = ['user_id' => 41, 'application_uuid' => 'uuid-pending-301', 'evidence_ids' => [1001]];
$evidence_permissions = [];
foreach ([
'manager' => [['manage_options' => true], 7],
'reviewer' => [['aksrt_review_identity' => true], 7],
'outsider' => [[], 7],
'owner' => [[], 41],
] as $role => [$caps, $current_user]) {
$GLOBALS['caps'] = $caps;
$GLOBALS['current_user_id'] = $current_user;
$evidence_permissions[$role] = [
'admin_items' => count($evidence_service->items($evidence_application, true)),
'owner_items' => count($evidence_service->items($evidence_application)),
];
$_GET = ['attachment' => 1001];
$_POST = ['_wpnonce' => wp_create_nonce('aksrt_verify_evidence_1001')];
try {
$evidence_service->serve();
} catch (HarnessWpDie $error) {
$evidence_permissions[$role]['serve_result'] = $error->getMessage();
}
}
$GLOBALS['current_user_id'] = 7;
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
$GLOBALS['checked_nonces'] = [];
$wpdb->prepared = [];
$wpdb->executed = [];
$wpdb->applications = [[
'id' => 401,
'application_uuid' => 'review-uuid-401',
'user_id' => 41,
'type' => 'general',
'channel' => 'manual',
'status' => 'manual_pending',
'public_name' => 'Before review',
'provider_result_summary' => 'Before bio',
'manual_reason' => 'Manual reason',
'review_note' => '',
'reviewer_id' => 0,
'evidence_ids' => '[1001]',
'created_at' => '2026-10-30 03:30:00',
'completed_at' => null,
'updated_at' => '2026-10-30 03:30:00',
]];
$_POST = [
'application_uuid' => 'review-uuid-401',
'user_id' => '41',
'status' => 'REJECTED!',
'note' => 'Rejected <script>reason</script> & explain',
'name' => 'Ignored <img src=x onerror=alert(1)>',
'description' => 'Ignored <b>description</b>',
'filter_status' => 'approved',
'keyword' => 'review <script>search</script> & phrase',
'paged' => '4',
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify_review-uuid-401'),
];
\AKSRT\User\AuthApply::$next_result = ['status' => 'rejected'];
try {
$probe->handle_review_verify();
} catch (HarnessRedirect $redirect) {
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
$review_redirect = $query;
}
$review_call = $GLOBALS['review_calls'][0] ?? null;
$review_nonce_calls = $GLOBALS['checked_nonces'];
$GLOBALS['review_calls'] = [];
$wpdb->prepared = [];
$_POST = [
'application_uuid' => 'review-uuid-401',
'user_id' => '41',
'status' => 'verified',
'note' => '',
'name' => 'Safe name',
'description' => 'Safe description',
'filter_status' => 'rejected',
'keyword' => 'keep this',
'paged' => '2',
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify_review-uuid-401'),
];
\AKSRT\User\AuthApply::$next_result = new WP_Error('review_failed', '<script>review unavailable</script>');
try {
$probe->handle_review_verify();
} catch (HarnessRedirect $redirect) {
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
$review_error_redirect = $query;
}
$review_error_call = $GLOBALS['review_calls'][0] ?? null;
$GLOBALS['caps'] = ['manage_options' => false, 'aksrt_review_identity' => false];
$permission_denied = false;
try {
$probe->handle_review_verify();
} catch (HarnessWpDie $error) {
$permission_denied = $error->getMessage() === '权限不足';
}
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
$_POST = [
'application_uuid' => 'review-uuid-401',
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify-wrong-uuid'),
];
$bad_nonce_rejected = false;
try {
$probe->handle_review_verify();
} catch (HarnessWpDie $error) {
$bad_nonce_rejected = $error->getMessage() === 'nonce verification failed';
}
$delete_application = [
'id' => 501,
'application_uuid' => 'delete-uuid-501',
'user_id' => 41,
'type' => 'general',
'channel' => 'manual',
'status' => 'verified',
'public_name' => 'Existing identity',
'provider_result_summary' => 'Existing description',
'manual_reason' => 'Manual reason',
'review_note' => 'Approved',
'reviewer_id' => 9,
'evidence_ids' => '[1001,1002]',
'created_at' => '2026-10-29 02:10:00',
'completed_at' => '2026-10-30 04:45:00',
'updated_at' => '2026-10-30 04:45:00',
];
$wpdb->applications = [$delete_application];
$wpdb->prepared = [];
$wpdb->executed = [];
$wpdb->delete_calls = [];
$wpdb->application_delete_result = 1;
$GLOBALS['deleted_attachments'] = [];
$GLOBALS['user_meta'][41] = ['auth' => 1, 'auth_info' => ['name' => 'Already verified', 'desc' => 'kept']];
$GLOBALS['delete_user_meta_calls'] = [];
$_POST = [
'application_uuid' => 'delete-uuid-501',
'filter_status' => 'rejected',
'keyword' => 'delete search',
'paged' => '6',
'_wpnonce' => wp_create_nonce('aksrt_users_delete_verify_delete-uuid-501'),
];
try {
$probe->handle_delete_verify();
} catch (HarnessRedirect $redirect) {
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
$delete_success_redirect = $query;
}
$delete_success_calls = $wpdb->delete_calls;
$delete_success_attachments = $GLOBALS['deleted_attachments'];
$delete_auth_projection = $GLOBALS['user_meta'][41];
$wpdb->delete_calls = [];
$wpdb->event_delete_result = false;
try {
$probe->handle_delete_verify();
} catch (HarnessRedirect $redirect) {
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
$delete_event_failure_redirect = $query;
}
$delete_event_failure_calls = $wpdb->delete_calls;
$wpdb->event_delete_result = 1;
$wpdb->prepared = [];
$wpdb->executed = [];
$wpdb->delete_calls = [];
$wpdb->application_delete_result = 0;
$GLOBALS['deleted_attachments'] = [];
$_POST = [
'application_uuid' => 'delete-uuid-501',
'filter_status' => 'pending',
'keyword' => 'delete failure',
'paged' => '1',
'_wpnonce' => wp_create_nonce('aksrt_users_delete_verify_delete-uuid-501'),
];
try {
$probe->handle_delete_verify();
} catch (HarnessRedirect $redirect) {
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
$delete_failure_redirect = $query;
}
echo json_encode([
'html' => $html,
'pending_empty' => $pending_empty,
'search_empty' => $search_empty,
'page_prepared' => $page_prepared,
'page_executed' => $page_executed,
'cache_calls' => $page_cache_calls,
'meta_cache_calls' => $page_meta_cache_calls,
'wrong_hook_assets' => $wrong_hook_assets,
'auth_page_assets' => $auth_page_assets,
'evidence_permissions' => $evidence_permissions,
'delete_event_failure_redirect' => $delete_event_failure_redirect ?? [],
'delete_event_failure_calls' => $delete_event_failure_calls,
'review_call' => $review_call,
'review_redirect' => $review_redirect ?? [],
'review_nonce_calls' => $review_nonce_calls,
'review_error_redirect' => $review_error_redirect ?? [],
'review_error_call' => $review_error_call,
'permission_denied' => $permission_denied,
'bad_nonce_rejected' => $bad_nonce_rejected,
'delete_success_redirect' => $delete_success_redirect ?? [],
'delete_success_calls' => $delete_success_calls,
'delete_success_attachments' => $delete_success_attachments,
'delete_auth_projection' => $delete_auth_projection,
'delete_user_meta_calls' => $GLOBALS['delete_user_meta_calls'],
'delete_failure_redirect' => $delete_failure_redirect ?? [],
'delete_failure_calls' => $wpdb->delete_calls,
'delete_failure_attachments' => $GLOBALS['deleted_attachments'],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,674 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const runtimeSource = fs.readFileSync(path.join(root, 'assets', 'js', 'admin-user-auth.js'), 'utf8');
class FakeEvent {
constructor(type, options = {}) {
this.type = type;
this.bubbles = options.bubbles !== false;
this.key = options.key || '';
this.clientX = options.clientX || 0;
this.clientY = options.clientY || 0;
this.defaultPrevented = false;
this.cancelBubble = false;
this.target = options.target || null;
this.currentTarget = null;
}
preventDefault() { this.defaultPrevented = true; }
stopPropagation() { this.cancelBubble = true; }
}
class FakeClassList {
constructor(element) { this.element = element; }
values() { return new Set((this.element.className || '').split(/\s+/).filter(Boolean)); }
add(...names) {
const values = this.values();
names.forEach((name) => values.add(name));
this.element.className = [...values].join(' ');
}
remove(...names) {
const values = this.values();
names.forEach((name) => values.delete(name));
this.element.className = [...values].join(' ');
}
contains(name) { return this.values().has(name); }
toggle(name, force) {
const present = this.contains(name);
const next = force === undefined ? !present : Boolean(force);
if (next) this.add(name); else this.remove(name);
return next;
}
}
function matchesCompound(element, selector) {
if (!element || element.nodeType !== 1) return false;
const tag = selector.match(/^[a-zA-Z][\w-]*/);
if (tag && element.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
const classes = selector.match(/\.[\w-]+/g) || [];
if (classes.some((name) => !element.classList.contains(name.slice(1)))) return false;
const attributes = selector.match(/\[[^\]]+\]/g) || [];
for (const token of attributes) {
const match = token.slice(1, -1).match(/^([\w-]+)(?:\s*=\s*["']?([^"']*)["']?)?$/);
if (!match || !element.hasAttribute(match[1])) return false;
if (match[2] !== undefined && element.getAttribute(match[1]) !== match[2]) return false;
}
return Boolean(tag || classes.length || attributes.length);
}
function matchesSelector(element, selector) {
return selector.split(',').some((part) => {
const chain = part.trim().split(/\s+/).filter(Boolean);
if (!chain.length || !matchesCompound(element, chain[chain.length - 1])) return false;
let ancestor = element.parentElement;
for (let index = chain.length - 2; index >= 0; index -= 1) {
while (ancestor && !matchesCompound(ancestor, chain[index])) ancestor = ancestor.parentElement;
if (!ancestor) return false;
ancestor = ancestor.parentElement;
}
return true;
});
}
class FakeElement {
constructor(tagName = 'div', ownerDocument = null) {
this.nodeType = tagName === '#document-fragment' ? 11 : 1;
this.tagName = tagName.toUpperCase();
this.ownerDocument = ownerDocument;
this.parentNode = null;
this._children = [];
this._text = '';
this.attributes = Object.create(null);
this.dataset = Object.create(null);
this.listeners = Object.create(null);
this.className = '';
this.classList = new FakeClassList(this);
this.value = '';
this.checked = false;
this.disabled = false;
this.hidden = false;
this.required = false;
this.type = '';
this.name = '';
this.open = false;
this.reportValidityCalls = 0;
this.customValidity = '';
this.content = null;
}
get children() { return this._children; }
get parentElement() { return this.parentNode && this.parentNode.nodeType === 1 ? this.parentNode : null; }
get isConnected() {
let node = this;
while (node) {
if (this.ownerDocument && node === this.ownerDocument.documentElement) return true;
node = node.parentNode;
}
return false;
}
get textContent() { return this._text + this._children.map((child) => child.textContent).join(''); }
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
appendChild(child) {
if (child.nodeType === 11) {
child._children.slice().forEach((item) => this.appendChild(item));
child.replaceChildren();
return child;
}
if (child.parentNode) child.parentNode.removeChild(child);
this._children.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
const index = this._children.indexOf(child);
if (index !== -1) {
this._children.splice(index, 1);
child.parentNode = null;
}
return child;
}
replaceChildren(...children) {
this._children.slice().forEach((child) => { child.parentNode = null; });
this._children = [];
this._text = '';
children.forEach((child) => { if (child) this.appendChild(child); });
}
setAttribute(name, value) {
const stringValue = String(value);
this.attributes[name] = stringValue;
if (name === 'class') this.className = stringValue;
if (name === 'value') this.value = stringValue;
if (name === 'name') this.name = stringValue;
if (name === 'type') this.type = stringValue;
if (name === 'disabled') this.disabled = true;
if (name === 'hidden') this.hidden = true;
if (name === 'required') this.required = true;
if (name.indexOf('data-') === 0) {
const key = name.slice(5).replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase());
this.dataset[key] = stringValue;
}
}
getAttribute(name) { return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(this.attributes, name); }
removeAttribute(name) {
delete this.attributes[name];
if (name === 'disabled') this.disabled = false;
if (name === 'hidden') this.hidden = false;
if (name === 'required') this.required = false;
}
addEventListener(type, callback) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(callback);
}
dispatchEvent(event) {
if (!event.target) event.target = this;
let current = this;
while (current) {
event.currentTarget = current;
(current.listeners[event.type] || []).slice().forEach((callback) => callback.call(current, event));
if (!event.bubbles || event.cancelBubble) break;
current = current.parentElement;
}
return !event.defaultPrevented;
}
querySelectorAll(selector) {
const found = [];
const visit = (node) => {
node._children.forEach((child) => {
if (matchesSelector(child, selector)) found.push(child);
visit(child);
});
if (node.tagName === 'TEMPLATE' && node.content) visit(node.content);
};
visit(this);
return found;
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
focus() {
if (this.ownerDocument) this.ownerDocument.activeElement = this;
this.focused = true;
}
click() { return this.dispatchEvent(new FakeEvent('click')); }
setCustomValidity(message) { this.customValidity = String(message); }
reportValidity() {
this.reportValidityCalls += 1;
const controls = this.querySelectorAll('input, textarea, select');
const enabled = controls.filter((field) => {
let parent = field.parentElement;
while (parent) {
if (parent.tagName === 'FIELDSET' && parent.disabled) return false;
parent = parent.parentElement;
}
return !field.disabled;
});
const requiredRadioNames = new Set(enabled.filter((field) => field.type === 'radio' && field.required).map((field) => field.name));
for (const name of requiredRadioNames) {
if (!enabled.some((field) => field.type === 'radio' && field.name === name && field.checked)) return false;
}
for (const field of enabled) {
if (field.customValidity) return false;
if (field.required && field.type !== 'radio' && field.value === '') return false;
}
return true;
}
cloneNode(deep = false) {
const clone = new FakeElement(this.tagName.toLowerCase(), this.ownerDocument);
Object.entries(this.attributes).forEach(([name, value]) => clone.setAttribute(name, value));
clone.value = this.value;
clone.checked = this.checked;
clone.disabled = this.disabled;
clone.hidden = this.hidden;
clone.required = this.required;
clone.type = this.type;
clone.name = this.name;
clone.customValidity = this.customValidity;
clone._text = this._text;
clone.dataset = Object.assign(Object.create(null), this.dataset);
if (this.content) clone.content = this.content.cloneNode(true);
if (deep) this._children.forEach((child) => clone.appendChild(child.cloneNode(true)));
return clone;
}
getBoundingClientRect() { return { left: 100, top: 100, right: 600, bottom: 700 }; }
showModal() { this.open = true; }
close() {
if (!this.open) return;
this.open = false;
this.dispatchEvent(new FakeEvent('close', { bubbles: false }));
}
}
class FakeDocument {
constructor() {
this.documentElement = new FakeElement('html', this);
this.body = new FakeElement('body', this);
this.documentElement.appendChild(this.body);
this.activeElement = null;
}
querySelector(selector) { return this.documentElement.querySelector(selector); }
getElementById(id) {
const visit = (node) => {
if (node.getAttribute && node.getAttribute('id') === id) return node;
for (const child of node.children || []) {
const match = visit(child);
if (match) return match;
}
if (node.tagName === 'TEMPLATE' && node.content) return visit(node.content);
return null;
};
return visit(this.documentElement);
}
}
function makeElement(document, tag, attributes = {}, text = '') {
const element = new FakeElement(tag, document);
Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value));
if (text !== '') element.textContent = text;
return element;
}
function addTemplate(rootElement, document, id, child) {
const template = makeElement(document, 'template', { id });
template.content = new FakeElement('#document-fragment', document);
template.content.appendChild(child);
rootElement.appendChild(template);
return template;
}
function makeCloseButton(document) {
return makeElement(document, 'button', { type: 'button', 'data-aksrt-dialog-close': '' }, '关闭');
}
function makeDetailFrame(document, text = '<img src=x onerror=alert(1)>') {
const form = makeElement(document, 'form', { method: 'post', class: 'aksrt-verify-dialog__frame', 'data-aksrt-verify-review': '' });
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '认证资料审核'));
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '核对申请人与所提交的材料。'));
head.appendChild(makeCloseButton(document));
form.appendChild(head);
const body = makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' });
body.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-applicant' }, '申请人 17'));
const meta = makeElement(document, 'dl', { class: 'aksrt-verify-meta' });
meta.appendChild(makeElement(document, 'div', {}, '用户资料'));
body.appendChild(meta);
const section = makeElement(document, 'section', { class: 'aksrt-verify-section' });
section.appendChild(makeElement(document, 'h3', {}, '申请说明'));
section.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-copy' }, text));
body.appendChild(section);
const evidence = makeElement(document, 'ul', { class: 'aksrt-verify-evidence' });
const evidenceItem = makeElement(document, 'li');
const evidenceLink = makeElement(document, 'a', { href: 'https://example.test/evidence.jpg' });
evidenceLink.appendChild(makeElement(document, 'img', { src: 'https://example.test/thumb.jpg', alt: '证件材料' }));
evidenceLink.appendChild(makeElement(document, 'span', {}, '证件材料'));
evidenceItem.appendChild(evidenceLink);
evidence.appendChild(evidenceItem);
body.appendChild(evidence);
const review = makeElement(document, 'section', { class: 'aksrt-verify-review' });
const decisions = makeElement(document, 'fieldset', { class: 'aksrt-verify-decisions' });
decisions.appendChild(makeElement(document, 'legend', {}, '审核结果'));
for (const [value, label] of [['verified', '通过'], ['rejected', '拒绝']]) {
const choice = makeElement(document, 'label');
const radio = makeElement(document, 'input', { type: 'radio', name: 'status', value, required: '' });
choice.appendChild(radio);
choice.appendChild(makeElement(document, 'span', {}, label));
decisions.appendChild(choice);
}
review.appendChild(decisions);
const profile = makeElement(document, 'fieldset', { class: 'aksrt-verify-profile', 'data-aksrt-verify-profile': '', hidden: '', disabled: '' });
const nameField = makeElement(document, 'label', { class: 'aksrt-native-field' });
nameField.appendChild(makeElement(document, 'span', {}, '认证名称'));
nameField.appendChild(makeElement(document, 'input', { type: 'text', name: 'name', maxlength: '64', required: '', value: 'Existing profile name', 'data-required-error': '请填写认证名称' }));
profile.appendChild(nameField);
const descriptionField = makeElement(document, 'label', { class: 'aksrt-native-field' });
descriptionField.appendChild(makeElement(document, 'span', {}, '认证说明'));
const description = makeElement(document, 'textarea', { name: 'description' });
description.value = 'Existing profile description';
descriptionField.appendChild(description);
profile.appendChild(descriptionField);
review.appendChild(profile);
const noteLabel = makeElement(document, 'label', { class: 'aksrt-native-field' });
noteLabel.appendChild(makeElement(document, 'span', { 'data-aksrt-verify-note-label': '', 'data-optional': '处理留言(选填)', 'data-required': '拒绝原因(必填)' }, '处理留言(选填)'));
noteLabel.appendChild(makeElement(document, 'textarea', { name: 'note', 'data-required-error': '请填写拒绝原因' }));
review.appendChild(noteLabel);
body.appendChild(review);
form.appendChild(body);
const foot = makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' });
foot.appendChild(makeElement(document, 'button', { type: 'submit', class: 'button button-primary', 'data-aksrt-verify-submit': '', 'data-idle-label': '请选择审核结果', 'data-approve-label': '确认通过', 'data-reject-label': '确认拒绝', 'data-busy-label': '正在提交…', disabled: '' }, '请选择审核结果'));
form.appendChild(foot);
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: '_wpnonce', value: 'nonce-17' }));
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'application_uuid', value: 'uuid-17' }));
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'user_id', value: '17' }));
return form;
}
function makeDeleteFrame(document) {
const form = makeElement(document, 'form', { method: 'post', class: 'aksrt-verify-dialog__frame', 'data-aksrt-verify-delete-form': '' });
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '删除认证记录'));
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '请确认后再继续。'));
head.appendChild(makeCloseButton(document));
form.appendChild(head);
const body = makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' });
body.appendChild(makeElement(document, 'div', { class: 'aksrt-native-callout is-danger' }, '删除不可撤销,只删除记录、材料与事件,不撤销已完成的认证。'));
form.appendChild(body);
const foot = makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' });
foot.appendChild(makeElement(document, 'button', { type: 'submit', 'data-aksrt-verify-submit': '', 'data-idle-label': '确认删除', 'data-busy-label': '正在提交…' }, '确认删除'));
form.appendChild(foot);
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: '_wpnonce', value: 'delete-nonce-17' }));
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'application_uuid', value: 'delete-uuid-17' }));
return form;
}
function makeReadonlyFrame(document) {
const frame = makeElement(document, 'div', { class: 'aksrt-verify-dialog__frame' });
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '认证记录详情'));
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '此记录仅供查看。'));
head.appendChild(makeCloseButton(document));
frame.appendChild(head);
frame.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' }, '只读内容'));
frame.appendChild(makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' }));
return frame;
}
function setup() {
const document = new FakeDocument();
const rootElement = makeElement(document, 'main', { class: 'aksrt-verify-page' });
const view17 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-view': '17', 'aria-controls': 'aksrt-verify-dialog' }, '查看审核');
const view18 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-view': '18', 'aria-controls': 'aksrt-verify-dialog' }, '查看只读记录');
const delete17 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-delete': '17', 'aria-controls': 'aksrt-verify-dialog' }, '删除');
rootElement.appendChild(view17);
rootElement.appendChild(view18);
rootElement.appendChild(delete17);
const detailTemplate = addTemplate(rootElement, document, 'aksrt-verify-detail-17', makeDetailFrame(document));
const readonlyTemplate = addTemplate(rootElement, document, 'aksrt-verify-detail-18', makeReadonlyFrame(document));
const deleteTemplate = addTemplate(rootElement, document, 'aksrt-verify-delete-17', makeDeleteFrame(document));
const dialog = makeElement(document, 'dialog', {
id: 'aksrt-verify-dialog',
class: 'aksrt-verify-dialog',
'aria-labelledby': 'aksrt-verify-dialog-title',
'aria-describedby': 'aksrt-verify-dialog-description',
});
document.body.appendChild(rootElement);
document.body.appendChild(dialog);
const windowListeners = Object.create(null);
const window = {
addEventListener(type, callback) {
if (!windowListeners[type]) windowListeners[type] = [];
windowListeners[type].push(callback);
},
dispatchEvent(event) {
(windowListeners[event.type] || []).forEach((callback) => callback(event));
},
};
vm.runInNewContext(runtimeSource, { document, window });
return { document, root: rootElement, dialog, view17, view18, delete17, detailTemplate, readonlyTemplate, deleteTemplate, window };
}
function open(button) { button.click(); }
function submit(form) {
const event = new FakeEvent('submit');
form.dispatchEvent(event);
return event;
}
function fire(element, type, options = {}) { return element.dispatchEvent(new FakeEvent(type, options)); }
function reviewParts(form) {
return {
radios: form.querySelectorAll('input[name="status"]'),
profile: form.querySelector('[data-aksrt-verify-profile]'),
name: form.querySelector('input[name="name"]'),
description: form.querySelector('textarea[name="description"]'),
note: form.querySelector('textarea[name="note"]'),
noteLabel: form.querySelector('[data-aksrt-verify-note-label]'),
submit: form.querySelector('[data-aksrt-verify-submit]'),
};
}
test('details and deletion clone only their own inert templates; no markup parsing or stale review state', () => {
const app = setup();
assert.equal(app.dialog.children.length, 0, 'the shared dialog starts empty');
const sourceForm = app.detailTemplate.content.querySelector('form[data-aksrt-verify-review]');
open(app.view17);
const firstForm = app.dialog.querySelector('form[data-aksrt-verify-review]');
assert.ok(firstForm);
assert.notEqual(firstForm, sourceForm, 'dialog content is a deep clone, not the inert template node');
assert.equal(app.dialog.querySelectorAll('script').length, 0);
assert.match(app.dialog.querySelector('.aksrt-verify-copy').textContent, /<img src=x onerror=alert\(1\)>/);
assert.equal(app.dialog.querySelector('.aksrt-verify-copy').querySelector('img'), null, 'untrusted copy stays text');
const parts = reviewParts(firstForm);
assert.equal(parts.name.value, 'Existing profile name', 'the clone keeps server-prefilled profile data');
assert.equal(parts.description.value, 'Existing profile description');
parts.radios[1].checked = true;
parts.note.value = '已核对';
fire(parts.radios[1], 'change');
assert.equal(parts.submit.textContent, '确认拒绝');
assert.equal(sourceForm.querySelector('textarea[name="note"]').value, '', 'editing the clone does not mutate the template');
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
assert.equal(app.dialog.children.length, 0, 'closing removes every cloned node');
assert.equal(app.document.activeElement, app.view17, 'close restores focus to the opener');
open(app.view17);
const secondForm = app.dialog.querySelector('form[data-aksrt-verify-review]');
assert.notEqual(secondForm, firstForm, 'each open builds a fresh clone');
assert.equal(reviewParts(secondForm).radios.some((radio) => radio.checked), false);
assert.equal(reviewParts(secondForm).note.value, '', 'the previous note is not retained');
assert.equal(reviewParts(secondForm).name.value, 'Existing profile name', 'fresh clones preserve server-provided profile defaults');
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
open(app.delete17);
const deleteForm = app.dialog.querySelector('form[data-aksrt-verify-delete-form]');
const sourceDeleteForm = app.deleteTemplate.content.querySelector('form[data-aksrt-verify-delete-form]');
assert.ok(deleteForm);
assert.notEqual(deleteForm, sourceDeleteForm, 'deletion actions also clone their own inert template');
assert.equal(app.dialog.querySelector('form[data-aksrt-verify-review]'), null);
assert.equal(app.dialog.querySelector('.aksrt-native-callout.is-danger').textContent.includes('不撤销已完成的认证'), true);
assert.equal(app.dialog.querySelector('[data-aksrt-verify-submit]').textContent, '确认删除');
assert.equal(app.dialog.querySelectorAll('input[name="status"]').length, 0);
});
test('cancel, Escape, and backdrop close the dialog; backdrop must begin and end outside', () => {
const app = setup();
open(app.view17);
const cancel = new FakeEvent('cancel', { bubbles: false });
app.dialog.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented, true, 'cancel is handled through the shared close path');
assert.equal(app.dialog.open, false);
assert.equal(app.document.activeElement, app.view17);
open(app.view17);
fire(app.dialog, 'keydown', { key: 'Escape' });
assert.equal(app.dialog.open, true, 'Escape is completed by the native cancel event');
fire(app.dialog, 'pointerdown', { clientX: 120, clientY: 130 });
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
assert.equal(app.dialog.open, true, 'a gesture that began inside cannot dismiss the dialog');
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
fire(app.dialog, 'click', { clientX: 120, clientY: 130 });
assert.equal(app.dialog.open, true, 'a gesture that ended inside cannot dismiss the dialog');
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
assert.equal(app.dialog.open, false, 'a full outside pointer gesture closes the dialog');
assert.equal(app.dialog.children.length, 0);
assert.equal(app.document.activeElement, app.view17);
});
test('review starts unselected and cannot submit before a decision', () => {
const app = setup();
open(app.view17);
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
const parts = reviewParts(form);
assert.equal(parts.radios.some((radio) => radio.checked), false);
assert.equal(parts.submit.disabled, true);
assert.equal(parts.submit.textContent, '请选择审核结果');
assert.equal(parts.profile.hidden, true);
assert.equal(parts.profile.disabled, true);
assert.equal(parts.note.required, false);
const event = submit(form);
assert.equal(event.defaultPrevented, true);
assert.equal(form.reportValidityCalls, 1, 'native validity is reported even when no decision is selected');
assert.notEqual(form.dataset.submitting, '1');
});
test('approve reveals and enables profile fields; trimmed name validation and successful hidden fields are preserved', () => {
const app = setup();
open(app.view17);
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
const parts = reviewParts(form);
parts.radios[0].checked = true;
fire(parts.radios[0], 'change');
assert.equal(parts.profile.hidden, false);
assert.equal(parts.profile.disabled, false);
assert.equal(parts.note.required, false);
assert.equal(parts.noteLabel.textContent, '处理留言(选填)');
assert.equal(parts.submit.disabled, false);
assert.equal(parts.submit.textContent, '确认通过');
parts.name.value = ' ';
fire(parts.name, 'input');
const blankName = submit(form);
assert.equal(blankName.defaultPrevented, true, 'whitespace-only names fail native reportValidity');
assert.equal(parts.name.customValidity, '请填写认证名称');
assert.equal(form.reportValidityCalls, 1);
parts.name.value = '认证名称';
parts.description.value = '认证说明';
fire(parts.name, 'input');
const accepted = submit(form);
assert.equal(accepted.defaultPrevented, false, 'valid review submits through the browser form navigation');
assert.equal(form.dataset.submitting, '1');
assert.equal(parts.submit.disabled, true);
assert.equal(parts.submit.textContent, '正在提交…');
assert.equal(parts.name.disabled, false, 'successful user fields are not disabled while submitting');
assert.equal(parts.description.disabled, false);
assert.equal(parts.note.disabled, false);
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'nonce-17');
assert.equal(form.querySelector('[name="_wpnonce"]').disabled, false);
assert.equal(form.querySelector('[name="application_uuid"]').value, 'uuid-17');
assert.equal(form.querySelector('[name="application_uuid"]').disabled, false);
assert.equal(form.querySelector('[name="user_id"]').disabled, false);
parts.radios[0].checked = false;
parts.radios[1].checked = true;
fire(parts.radios[1], 'change');
parts.note.value = '提交中不应切换审核状态';
fire(parts.note, 'input');
assert.equal(parts.submit.disabled, true, 'change/input events cannot re-enable actions after submission starts');
assert.equal(parts.submit.textContent, '正在提交…', 'change/input events cannot overwrite the busy label');
assert.equal(parts.profile.hidden, false, 'a busy review does not resync profile visibility');
assert.equal(parts.note.required, false, 'a busy review does not resync required fields');
assert.equal(parts.noteLabel.textContent, '处理留言(选填)');
const repeated = submit(form);
assert.equal(repeated.defaultPrevented, true, 'second submission is stopped');
assert.equal(form.reportValidityCalls, 2, 'duplicate submits do not run additional validation or navigation');
});
test('reject hides profile fields, requires a trimmed reason, and applies danger action state', () => {
const app = setup();
open(app.view17);
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
const parts = reviewParts(form);
parts.radios[1].checked = true;
fire(parts.radios[1], 'change');
assert.equal(parts.profile.hidden, true);
assert.equal(parts.profile.disabled, true);
assert.equal(parts.note.required, true);
assert.equal(parts.noteLabel.textContent, '拒绝原因(必填)');
assert.equal(parts.submit.disabled, false);
assert.equal(parts.submit.textContent, '确认拒绝');
assert.equal(parts.submit.classList.contains('is-danger'), true);
parts.note.value = ' \n ';
fire(parts.note, 'input');
const blankReason = submit(form);
assert.equal(blankReason.defaultPrevented, true);
assert.equal(parts.note.customValidity, '请填写拒绝原因');
assert.equal(form.reportValidityCalls, 1);
parts.note.value = '材料不完整';
fire(parts.note, 'input');
const rejected = submit(form);
assert.equal(rejected.defaultPrevented, false);
assert.equal(parts.note.disabled, false);
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'nonce-17');
assert.equal(form.querySelector('[name="application_uuid"]').value, 'uuid-17');
});
test('delete submits without confirm, blocks repeat/close while busy, and pageshow restores actions and scroll lock', () => {
const app = setup();
open(app.delete17);
const form = app.dialog.querySelector('form[data-aksrt-verify-delete-form]');
const submitButton = form.querySelector('[data-aksrt-verify-submit]');
const closeButton = form.querySelector('[data-aksrt-dialog-close]');
const first = submit(form);
assert.equal(first.defaultPrevented, false);
assert.equal(form.dataset.submitting, '1');
assert.equal(submitButton.disabled, true);
assert.equal(closeButton.disabled, true);
assert.equal(submitButton.textContent, '正在提交…');
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'delete-nonce-17');
assert.equal(form.querySelector('[name="application_uuid"]').value, 'delete-uuid-17');
assert.equal(form.querySelector('[name="_wpnonce"]').disabled, false);
assert.equal(form.querySelector('[name="application_uuid"]').disabled, false);
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), true);
assert.equal(submit(form).defaultPrevented, true, 'duplicate delete submission is blocked');
const escape = new FakeEvent('keydown', { key: 'Escape', bubbles: false });
app.dialog.dispatchEvent(escape);
assert.equal(escape.defaultPrevented, true);
const cancel = new FakeEvent('cancel', { bubbles: false });
app.dialog.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented, true);
assert.equal(app.dialog.open, true, 'busy submissions cannot close through keyboard');
closeButton.click();
assert.equal(app.dialog.open, true, 'busy submissions cannot close through the close action');
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
assert.equal(app.dialog.open, true, 'busy submissions cannot close through the backdrop');
app.window.dispatchEvent(new FakeEvent('pageshow'));
assert.equal(form.dataset.submitting, undefined);
assert.equal(submitButton.disabled, false);
assert.equal(closeButton.disabled, false);
assert.equal(submitButton.textContent, '确认删除');
assert.equal(app.dialog.open, true);
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), true, 'an open dialog retains the scroll lock');
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), false);
app.window.dispatchEvent(new FakeEvent('pageshow'));
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), false, 'closed pageshow state is unlocked');
});
test('read-only detail has no review submit behavior', () => {
const app = setup();
open(app.view18);
assert.ok(app.dialog.querySelector('.aksrt-verify-dialog__frame'));
assert.equal(app.dialog.querySelector('form'), null);
assert.equal(app.dialog.querySelector('[data-aksrt-verify-submit]'), null);
assert.match(app.dialog.textContent, /此记录仅供查看/);
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
assert.equal(app.dialog.children.length, 0);
});