diff --git a/assets/css/admin/user-auth.css b/assets/css/admin/user-auth.css new file mode 100644 index 00000000..7c97af23 --- /dev/null +++ b/assets/css/admin/user-auth.css @@ -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; } +} diff --git a/assets/js/admin-user-auth.js b/assets/js/admin-user-auth.js new file mode 100644 index 00000000..0104c101 --- /dev/null +++ b/assets/js/admin-user-auth.js @@ -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(); + }); +})(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 74414d09..13a651ce 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -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']); } diff --git a/src/Admin/Native/UsersMenusVerify.php b/src/Admin/Native/UsersMenusVerify.php index 8cd46727..594f1b16 100644 --- a/src/Admin/Native/UsersMenusVerify.php +++ b/src/Admin/Native/UsersMenusVerify.php @@ -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 '
'; + $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 ''; + echo ''; 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>,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 '
'; - echo ''; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + if ($filters['keyword'] !== '') { + echo '' . esc_html__('清除搜索', 'aksrt') . ''; + } + echo '
'; } - private function render_verify_table(array $items): void { - echo ''; - 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 '
用户状态认证名称认证简介 / 申请说明证明材料审核
'; + foreach (['user' => __('申请用户', 'aksrt'), 'profile' => __('认证资料', 'aksrt'), 'evidence' => __('证明材料', 'aksrt'), 'status' => __('状态', 'aksrt'), 'time' => __('申请时间', 'aksrt'), 'actions' => __('操作', 'aksrt')] as $key => $label) { + echo ''; } - foreach ($items as $row) $this->render_verify_row($row); + echo ''; + 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 '
' . esc_html($label) . '
'; } - 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 '' . NativeUi::user_cell($user_id, (string) $row['display_name']) . '
' . esc_html((string) $row['user_email']) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. - echo '' . 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 '' . NativeUi::user_cell((int) $row['user_id'], (string) $row['display_name']); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '
' . esc_html((string) $row['user_email']) . '
'; + echo '' . esc_html((string) $row['public_name']) . '
' . esc_html((string) $row['description']) . '
'; + $this->render_verify_detail_template($row, $evidence, $filters, $page); + $this->render_verify_delete_template($row, $filters, $page); + echo '' . esc_html($evidence === [] ? __('未提供材料', 'aksrt') : sprintf(__('%d 份材料', 'aksrt'), count($evidence))) . ''; + echo '' . $this->verify_status_badge((string) $row['status']) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '' . esc_html($this->verify_time((string) $row['created_at'])) . ''; + echo '
'; + echo ''; + echo '
'; + } + + private function verify_status_badge(string $status): string { + return NativeUi::status_badge($status, [ 'manual_pending' => [__('待审核', 'aksrt'), 'warning'], 'verified' => [__('已通过', 'aksrt'), 'success'], 'rejected' => [__('已拒绝', 'aksrt'), 'danger'], - ]) . '
' . esc_html((string) $row['created_at']) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. - echo '' . esc_html((string) $row['public_name']) . ''; - echo '' . esc_html((string) $row['description']) . '

' . esc_html((string) $row['manual_reason']) . '

'; - if (!empty($row['review_note'])) echo '

' . esc_html__('处理留言:', 'aksrt') . esc_html((string) $row['review_note']) . '

'; - echo ''; - foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) { - echo ''; - } - echo ''; - $this->render_verify_actions($row); - echo ''; + ]); } - 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 '
'; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); - echo ''; - echo ''; - echo ''; - echo '
'; - } - } else { - echo '' . esc_html__('已处理', 'aksrt') . ''; - } - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']); - echo '
'; + 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); } } diff --git a/src/Admin/Native/UsersMenusVerifyDialogs.php b/src/Admin/Native/UsersMenusVerifyDialogs.php new file mode 100644 index 00000000..a8fc3d04 --- /dev/null +++ b/src/Admin/Native/UsersMenusVerifyDialogs.php @@ -0,0 +1,124 @@ +

' . esc_html($title) . '

'; + echo '

' . esc_html($description) . '

'; + echo ''; + } + + private function render_verify_context(array $row, array $filters, int $page, string $action): void { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + 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 ''; + } + + private function render_verify_text_section(string $title, string $text): void { + echo '

' . esc_html($title) . '

' . esc_html($text !== '' ? $text : __('未填写', 'aksrt')) . '
'; + } + + private function render_verify_evidence(array $evidence): void { + echo '

' . esc_html__('证明材料', 'aksrt') . '

'; + if ($evidence === []) { + echo '

' . esc_html__('未提供可查看的证明材料,请结合申请说明判断。', 'aksrt') . '

'; + } else { + echo ''; + } + echo '
'; + } + + private function render_verify_review_fields(array $row): void { + echo '

' . esc_html__('审核处理', 'aksrt') . '

'; + echo '
' . esc_html__('审核结果(必选)', 'aksrt') . ''; + foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $status => $label) { + echo ''; + } + echo '
'; + echo ''; + echo '

' . esc_html__('审核结果会通知申请用户;拒绝时请说明原因,便于用户修改后重新申请。', 'aksrt') . '

'; + } + + private function render_verify_delete_template(array $row, array $filters, int $page): void { + echo ''; + } +} diff --git a/src/Identity/EvidenceService.php b/src/Identity/EvidenceService.php index b696d4cc..076bab9d 100644 --- a/src/Identity/EvidenceService.php +++ b/src/Identity/EvidenceService.php @@ -83,7 +83,7 @@ final class EvidenceService { /** @return array */ 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>|\WP_Error */ private function normalize($input) { if (!is_array($input) || empty($input['name'])) { diff --git a/tests/widget-system/user-auth-admin-contract.test.cjs b/tests/widget-system/user-auth-admin-contract.test.cjs new file mode 100644 index 00000000..84f9cee1 --- /dev/null +++ b/tests/widget-system/user-auth-admin-contract.test.cjs @@ -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(`]*\\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('', dialogStart); + assert.ok(cardStart >= 0 && cardEnd > cardStart, 'verification list card wraps its content'); + const card = html.slice(cardStart, cardEnd + ''.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, //); + assert.doesNotMatch(card, /class="wp-list-table/); + assert.match(card, /name="status" value="pending"/); + assert.match(card, /name="keyword" value="search alert\(1\) &_%"/); + assert.match(card, /status=pending&keyword=search\+alert%281%29\+%26_%25/); + assert.match(card, /class="current" aria-current="page">待审核/); + assert.match(card, /第 3 页,共 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 <script>alert\(1\)<\/script>/); + assert.match(html, /Applicant name <script>alert\(3\)<\/script>/); + assert.match(html, /Bio <img src=x onerror=alert\(4\)> & text/); + assert.match(html, /Reviewer <img src=x onerror=alert\(2\)>/); + assert.doesNotMatch(html, /', + }); +}); + +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\)/); +}); diff --git a/tests/widget-system/user-auth-admin-harness.php b/tests/widget-system/user-auth-admin-harness.php new file mode 100644 index 00000000..9e4d8ad2 --- /dev/null +++ b/tests/widget-system/user-auth-admin-harness.php @@ -0,0 +1,518 @@ +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 = ''; + 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 ', + 42 => 'Approved User', + 9 => 'Reviewer ', + ]; + $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 ', + 'description' => 'Bio & text', + 'manual_reason' => 'Reason ', + 'review_note' => '', + 'reviewer_id' => 0, + 'evidence_ids' => '[1001]', + 'created_at' => '2026-10-30 03:30:00', + 'completed_at' => null, + 'display_name' => 'Applicant ', + 'user_login' => 'applicant"', + 'user_email' => 'applicant"@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 &_%', + '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 & explain', + 'name' => 'Ignored ', + 'description' => 'Ignored description', + 'filter_status' => 'approved', + 'keyword' => 'review & 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', ''); + 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); +} diff --git a/tests/widget-system/user-auth-admin-runtime.test.cjs b/tests/widget-system/user-auth-admin-runtime.test.cjs new file mode 100644 index 00000000..82a993c2 --- /dev/null +++ b/tests/widget-system/user-auth-admin-runtime.test.cjs @@ -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 = '') { + 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, //); + 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); +});