targets = $targets ?? new TargetResolver(); $this->policy = $policy ?? new ApprovalPolicy(); } /** * Register the screen and its handlers. * * @return void * @since 1.5.0 */ public function register_hooks(): void { add_action( 'admin_menu', [ $this, 'add_menu_page' ], Menu::POSITION_APPROVALS ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); add_action( 'admin_post_' . self::ACTION_APPROVE, [ $this, 'handle_approve' ] ); add_action( 'admin_post_' . self::ACTION_REJECT, [ $this, 'handle_reject' ] ); } /** * Add the Approvals submenu, badging it with the open count. * * @return void * @since 1.5.0 */ public function add_menu_page(): void { $user_id = get_current_user_id(); $open = $this->policy->decides_everything( $user_id ) ? $this->repository->count_open() : $this->repository->count_open_for_user( $user_id ); $title = __( 'Approvals', 'albert-ai-butler' ); if ( $open > 0 ) { $title .= ' ' . esc_html( (string) number_format_i18n( $open ) ) . ''; } add_submenu_page( Menu::PARENT_SLUG, __( 'Approvals', 'albert-ai-butler' ), $title, $this->menu_capability(), ApprovalUrl::PAGE_SLUG, [ $this, 'render_page' ] ); } /** * The capability that gates the submenu and page load. * * The coarse "can open the screen" gate, matching `ApprovalPolicy::can_view()`. * Per-row authority is decided separately, so this only controls who reaches * the queue, never what they can do to a row once there. * * @return string * @since 1.5.0 */ private function menu_capability(): string { /** This documents the same filter `ApprovalPolicy::can_view()` consults. */ $capability = apply_filters( 'albert/approvals/view_capability', ApprovalPolicy::DEFAULT_VIEW_CAPABILITY ); return is_string( $capability ) && $capability !== '' ? $capability : ApprovalPolicy::DEFAULT_VIEW_CAPABILITY; } /** * Load the screen's own stylesheet and dialog script, on this screen only. * * Mirrors the other server-rendered Albert screens: the primitives handle is * declared as a dependency so the shared token and component layer loads * first, and this file's authored rules load after it. * * @param string $hook The current admin page's hook suffix. * * @return void * @since 1.5.0 */ public function enqueue_assets( string $hook ): void { if ( Menu::PARENT_SLUG . '_page_' . ApprovalUrl::PAGE_SLUG !== $hook ) { return; } wp_enqueue_style( 'albert-approvals', ALBERT_PLUGIN_URL . 'assets/css/admin-approvals.css', [ Assets::PRIMITIVES_HANDLE ], Assets::version( 'assets/css/admin-approvals.css' ) ); wp_enqueue_script( 'albert-approvals', ALBERT_PLUGIN_URL . 'assets/js/admin-approvals.js', [], Assets::version( 'assets/js/admin-approvals.js' ), true ); } /** * Approve a staged action and run it. * * @return void * @since 1.5.0 */ public function handle_approve(): void { $action = $this->authorize_decision(); $result = $this->approver->approve( $action, get_current_user_id() ); // A lost race (another tab/retry got there first) is not a failure and did // not run anything: show the "no longer waiting" notice, not an error. if ( is_wp_error( $result ) && $result->get_error_code() === 'albert_already_handled' ) { $this->redirect_with_notice( 'gone' ); } $this->redirect_with_notice( is_wp_error( $result ) ? 'failed' : 'approved', $action->action_id ); } /** * Reject a staged action. * * @return void * @since 1.5.0 */ public function handle_reject(): void { $action = $this->authorize_decision(); $rejected = $this->approver->reject( $action, get_current_user_id() ); if ( ! $rejected ) { $this->redirect_with_notice( 'gone' ); } $this->redirect_with_notice( 'rejected', $action->action_id ); } /** * Render the queue. * * @return void * @since 1.5.0 */ public function render_page(): void { if ( ! $this->policy->can_view() ) { // Not a bare return: that renders a blank admin page, which reads as // a broken screen rather than a refusal. Unreachable while the menu // uses the same check, and worth keeping honest for the day they drift. wp_die( esc_html__( 'You are not allowed to view approvals.', 'albert-ai-butler' ), '', [ 'response' => 403 ] ); } $user_id = get_current_user_id(); $all = $this->policy->decides_everything( $user_id ); $open = $all ? $this->repository->list_open() : $this->repository->list_open_for_user( $user_id ); $decided = $all ? $this->repository->list_decided() : $this->repository->list_decided_for_user( $user_id ); $focus = isset( $_GET['pending'] ) ? sanitize_text_field( wp_unslash( $_GET['pending'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only focus hint, no state change. $just = $this->just_decided( $decided ); echo '
' . esc_html__( 'Destructive actions your assistant requested are held here until you approve them. Approving runs the request exactly as it was made; rejecting discards it.', 'albert-ai-butler' ) . '
'; echo '' . esc_html__( 'That request is no longer waiting for a decision. It may have already been approved, rejected, or expired.', 'albert-ai-butler' ) . '
' . esc_html__( 'Nothing is waiting for approval.', 'albert-ai-butler' ) . '
'; echo '' . esc_html( $this->consequence_line( $action, $state ) ); $this->render_state_badge( $state, $is_focus ); echo '
'; echo ''; echo ''; echo esc_html( $this->decided_line( $action ) ); echo ' ' . esc_html( $label ) . ''; echo '
'; $this->render_failure_reason( $action ); echo ''; echo '';
echo esc_html( $message !== '' ? $message : $code );
if ( $message !== '' && $code !== '' ) {
echo ' ' . esc_html( $code ) . '';
}
echo '
' . esc_html( $text ) . '
' . esc_html__( 'Nothing can be held on this site yet.', 'albert-ai-butler' ) . ' ' . esc_html( sprintf( /* translators: %s: the WordPress version this site is running. */ __( 'Safe mode needs WordPress 7.1, which added the check it relies on. This site runs %s, so destructive actions an assistant requests are carried out immediately and this queue stays empty.', 'albert-ai-butler' ), get_bloginfo( 'version' ) ) ) . ' ' . esc_html__( 'Update WordPress', 'albert-ai-butler' ) . '
' . esc_html( $text ) . '
'; echo '