diff --git a/aryo-activity-log.php b/aryo-activity-log.php index 7f7b7ed..9dca7b2 100644 --- a/aryo-activity-log.php +++ b/aryo-activity-log.php @@ -1,6 +1,6 @@ { @@ -53,3 +53,27 @@ export function buildExportUrl( filters ) { return url.toString(); } + +export function fetchSettings() { + return apiFetch( { + path: 'activity-log/v1/settings', + headers: { 'X-AAL-Settings-Nonce': config.settingsNonce || '' }, + } ); +} + +export function saveSettings( data ) { + return apiFetch( { + path: 'activity-log/v1/settings', + method: 'PUT', + data, + headers: { 'X-AAL-Settings-Nonce': config.settingsNonce || '' }, + } ); +} + +export function eraseLogs() { + return apiFetch( { + path: 'activity-log/v1/logs/erase', + method: 'POST', + headers: { 'X-AAL-Settings-Nonce': config.settingsNonce || '' }, + } ); +} diff --git a/assets/js/admin/src/settings-index.js b/assets/js/admin/src/settings-index.js new file mode 100644 index 0000000..511b469 --- /dev/null +++ b/assets/js/admin/src/settings-index.js @@ -0,0 +1,8 @@ +import { createRoot } from '@wordpress/element'; +import Settings from './settings'; + +const container = document.getElementById( 'aal-settings-root' ); +if ( container ) { + const root = createRoot( container ); + root.render( ); +} diff --git a/assets/js/admin/src/settings.js b/assets/js/admin/src/settings.js new file mode 100644 index 0000000..14cc2ef --- /dev/null +++ b/assets/js/admin/src/settings.js @@ -0,0 +1,232 @@ +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { + Button, + SelectControl, + Notice, + Spinner, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { fetchSettings, saveSettings, eraseLogs } from './api'; + +export default function Settings() { + const [ schema, setSchema ] = useState( null ); + const [ values, setValues ] = useState( {} ); + const [ canEraseLogs, setCanEraseLogs ] = useState( false ); + const [ loading, setLoading ] = useState( true ); + const [ saving, setSaving ] = useState( false ); + const [ erasing, setErasing ] = useState( false ); + const [ notice, setNotice ] = useState( null ); + + const loadSettings = useCallback( () => { + setLoading( true ); + fetchSettings() + .then( ( result ) => { + setSchema( result.fields ); + setCanEraseLogs( result.canEraseLogs ); + + const initial = {}; + Object.entries( result.fields ).forEach( ( [ key, field ] ) => { + initial[ key ] = field.value; + } ); + setValues( initial ); + + setLoading( false ); + } ) + .catch( ( err ) => { + setNotice( { status: 'error', message: err.message || __( 'Failed to load settings.', 'aryo-activity-log' ) } ); + setLoading( false ); + } ); + }, [] ); + + useEffect( () => { + loadSettings(); + }, [ loadSettings ] ); + + const handleSave = () => { + setSaving( true ); + setNotice( null ); + + saveSettings( values ) + .then( () => { + setNotice( { status: 'success', message: __( 'Settings saved.', 'aryo-activity-log' ) } ); + setSaving( false ); + } ) + .catch( ( err ) => { + setNotice( { status: 'error', message: err.message || __( 'Failed to save settings.', 'aryo-activity-log' ) } ); + setSaving( false ); + } ); + }; + + const handleErase = () => { + if ( ! window.confirm( __( 'Attention: We are going to DELETE ALL ACTIVITIES from the database. Are you sure you want to do that?', 'aryo-activity-log' ) ) ) { + return; + } + + setErasing( true ); + setNotice( null ); + + eraseLogs() + .then( () => { + setNotice( { status: 'success', message: __( 'All activities have been successfully deleted.', 'aryo-activity-log' ) } ); + setErasing( false ); + } ) + .catch( ( err ) => { + setNotice( { status: 'error', message: err.message || __( 'Failed to delete activities.', 'aryo-activity-log' ) } ); + setErasing( false ); + } ); + }; + + const updateValue = ( key, val ) => { + setValues( ( prev ) => ( { ...prev, [ key ]: val } ) ); + }; + + if ( loading ) { + return ( +
+ +
+ ); + } + + if ( ! schema ) { + return null; + } + + return ( +
+ { notice && ( + setNotice( null ) } + style={ { margin: '0 0 16px' } } + > + { notice.message } + + ) } + + + + { schema.logs_lifespan && ( + + + + + ) } + + { schema.logs_failed_login && ( + + + + + ) } + + { schema.logs_email && ( + + + + + ) } + + { schema.log_visitor_ip_source && ( + + + + + ) } + + { canEraseLogs && ( + + + + + ) } + +
+ + + updateValue( 'logs_lifespan', e.target.value ) } + /> + { ' ' } + { schema.logs_lifespan.suffix } + { schema.logs_lifespan.description && ( +

{ schema.logs_lifespan.description }

+ ) } +
+ + + updateValue( 'logs_failed_login', v ) } + __nextHasNoMarginBottom + /> +
+ + + updateValue( 'logs_email', v ) } + __nextHasNoMarginBottom + /> +
+ + + updateValue( 'log_visitor_ip_source', v ) } + __nextHasNoMarginBottom + /> + { schema.log_visitor_ip_source.description && ( +

{ schema.log_visitor_ip_source.description }

+ ) } +
+ { __( 'Delete Log Activities', 'aryo-activity-log' ) } + + +

+ { __( 'Warning: Clicking this will delete all activities from the database.', 'aryo-activity-log' ) } +

+
+ +

+ +

+
+ ); +} diff --git a/changelog.txt b/changelog.txt index f582b95..149c2de 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,8 @@ == Changelog == += 2.15.0 - 2026-09-07 = +* New: Settings page rebuilt with React UI and REST API + = 2.14.1 - 2026-09-02 = * Tweak: Moved Application Password badge from Source column to User column diff --git a/classes/class-aal-rest.php b/classes/class-aal-rest.php index 31c3af3..643b69b 100644 --- a/classes/class-aal-rest.php +++ b/classes/class-aal-rest.php @@ -25,6 +25,25 @@ public function register_routes() { 'permission_callback' => array( $this, 'check_permissions' ), ) ); + register_rest_route( self::NAMESPACE_V1, '/settings', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_settings' ), + 'permission_callback' => array( $this, 'check_settings_permissions' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_settings' ), + 'permission_callback' => array( $this, 'check_settings_permissions' ), + ), + ) ); + + register_rest_route( self::NAMESPACE_V1, '/logs/erase', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'erase_logs' ), + 'permission_callback' => array( $this, 'check_settings_permissions' ), + ) ); + register_rest_route( self::NAMESPACE_V1, '/promotions/(?P[a-z_]+)/dismiss', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'dismiss_promotion' ), @@ -55,6 +74,173 @@ public function check_permissions() { ); } + public function check_settings_permissions( WP_REST_Request $request ) { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to manage settings.', 'aryo-activity-log' ), + array( 'status' => \WP_Http::FORBIDDEN ) + ); + } + + $nonce = $request->get_header( 'X-AAL-Settings-Nonce' ); + + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'aal_settings' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Invalid or missing settings nonce.', 'aryo-activity-log' ), + array( 'status' => \WP_Http::FORBIDDEN ) + ); + } + + return true; + } + + private static $allowed_ip_sources = array( + 'REMOTE_ADDR', + 'HTTP_CF_CONNECTING_IP', + 'HTTP_TRUE_CLIENT_IP', + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + 'no-collect-ip', + ); + + public function get_settings( WP_REST_Request $request ) { + $options = AAL_Main::instance()->settings->get_options(); + + $fields = array( + 'logs_lifespan' => array( + 'value' => isset( $options['logs_lifespan'] ) ? $options['logs_lifespan'] : '', + 'label' => __( 'Keep logs for', 'aryo-activity-log' ), + 'type' => 'number', + 'description' => __( 'Maximum number of days to keep activity log. Leave blank to keep activity log forever (not recommended).', 'aryo-activity-log' ), + 'suffix' => __( 'days.', 'aryo-activity-log' ), + ), + 'logs_failed_login' => array( + 'value' => isset( $options['logs_failed_login'] ) ? $options['logs_failed_login'] : 'yes', + 'label' => __( 'Keep Failed Login Logs', 'aryo-activity-log' ), + 'type' => 'select', + 'options' => array( + array( 'value' => 'yes', 'label' => __( 'Keep', 'aryo-activity-log' ) ), + array( 'value' => 'no', 'label' => __( "Don't Keep (Not recommended)", 'aryo-activity-log' ) ), + ), + ), + 'logs_email' => array( + 'value' => isset( $options['logs_email'] ) ? $options['logs_email'] : 'yes', + 'label' => __( 'Keep Email Logs', 'aryo-activity-log' ), + 'type' => 'select', + 'options' => array( + array( 'value' => 'yes', 'label' => __( 'Keep', 'aryo-activity-log' ) ), + array( 'value' => 'no', 'label' => __( "Don't Keep", 'aryo-activity-log' ) ), + ), + ), + 'log_visitor_ip_source' => array( + 'value' => isset( $options['log_visitor_ip_source'] ) ? $options['log_visitor_ip_source'] : 'REMOTE_ADDR', + 'label' => __( 'Visitor IP Detected', 'aryo-activity-log' ), + 'type' => 'select', + 'description' => __( 'Select the source of the visitor IP address. For example, if you are using Cloudflare, select HTTP_CF_CONNECTING_IP. Please note: if you choose "Do not collect IP", the IP column will be hidden in the log.', 'aryo-activity-log' ), + 'options' => array( + array( 'value' => 'REMOTE_ADDR', 'label' => 'REMOTE_ADDR' ), + array( 'value' => 'HTTP_CF_CONNECTING_IP', 'label' => 'HTTP_CF_CONNECTING_IP' ), + array( 'value' => 'HTTP_TRUE_CLIENT_IP', 'label' => 'HTTP_TRUE_CLIENT_IP' ), + array( 'value' => 'HTTP_CLIENT_IP', 'label' => 'HTTP_CLIENT_IP' ), + array( 'value' => 'HTTP_X_FORWARDED_FOR', 'label' => 'HTTP_X_FORWARDED_FOR' ), + array( 'value' => 'HTTP_X_FORWARDED', 'label' => 'HTTP_X_FORWARDED' ), + array( 'value' => 'HTTP_X_CLUSTER_CLIENT_IP', 'label' => 'HTTP_X_CLUSTER_CLIENT_IP' ), + array( 'value' => 'HTTP_FORWARDED_FOR', 'label' => 'HTTP_FORWARDED_FOR' ), + array( 'value' => 'HTTP_FORWARDED', 'label' => 'HTTP_FORWARDED' ), + array( 'value' => 'no-collect-ip', 'label' => __( 'Do not collect IP', 'aryo-activity-log' ) ), + ), + ), + ); + + return rest_ensure_response( array( + 'fields' => $fields, + 'canEraseLogs' => (bool) apply_filters( 'aal_allow_option_erase_logs', true ), + ) ); + } + + public function update_settings( WP_REST_Request $request ) { + $current = AAL_Main::instance()->settings->get_options(); + $body = $request->get_json_params(); + + $allowed_keys = array( 'logs_lifespan', 'logs_failed_login', 'logs_email', 'log_visitor_ip_source' ); + $sanitized = array(); + + foreach ( $allowed_keys as $key ) { + if ( ! array_key_exists( $key, $body ) ) { + continue; + } + + $value = $body[ $key ]; + + switch ( $key ) { + case 'logs_lifespan': + if ( '' === $value || null === $value ) { + $sanitized[ $key ] = ''; + } elseif ( ! is_numeric( $value ) || (int) $value < 0 ) { + return new WP_Error( + 'rest_invalid_param', + __( 'logs_lifespan must be a non-negative number or empty.', 'aryo-activity-log' ), + array( 'status' => \WP_Http::BAD_REQUEST ) + ); + } else { + $sanitized[ $key ] = (string) absint( $value ); + } + break; + + case 'logs_failed_login': + case 'logs_email': + if ( ! in_array( $value, array( 'yes', 'no' ), true ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: option key name */ + sprintf( __( '%s must be "yes" or "no".', 'aryo-activity-log' ), $key ), + array( 'status' => \WP_Http::BAD_REQUEST ) + ); + } + $sanitized[ $key ] = $value; + break; + + case 'log_visitor_ip_source': + if ( ! in_array( $value, self::$allowed_ip_sources, true ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'Invalid IP source.', 'aryo-activity-log' ), + array( 'status' => \WP_Http::BAD_REQUEST ) + ); + } + $sanitized[ $key ] = $value; + break; + } + } + + $output = array_merge( $current, $sanitized ); + $output = apply_filters( 'aal_validate_options', $output, $current ); + + update_option( 'activity-log-settings', $output ); + + return rest_ensure_response( array( 'success' => true ) ); + } + + public function erase_logs( WP_REST_Request $request ) { + if ( ! apply_filters( 'aal_allow_option_erase_logs', true ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Log erasure is disabled.', 'aryo-activity-log' ), + array( 'status' => \WP_Http::FORBIDDEN ) + ); + } + + AAL_Main::instance()->api->erase_all_items(); + + return rest_ensure_response( array( 'success' => true ) ); + } + private function get_logs_args() { return array( 'page' => array( diff --git a/classes/class-aal-settings.php b/classes/class-aal-settings.php index ed72167..cbb225f 100644 --- a/classes/class-aal-settings.php +++ b/classes/class-aal-settings.php @@ -10,11 +10,7 @@ public function __construct() { add_action( 'init', array( &$this, 'init' ) ); add_action( 'admin_menu', array( &$this, 'action_admin_menu' ), 30 ); add_action( 'admin_init', array( &$this, 'register_settings' ) ); - add_action( 'admin_notices', array( &$this, 'admin_notices' ) ); - add_action( 'admin_footer', array( &$this, 'admin_footer' ) ); add_filter( 'plugin_action_links_' . ACTIVITY_LOG_BASE, array( &$this, 'plugin_action_links' ) ); - - add_action( 'wp_ajax_aal_reset_items', array( &$this, 'ajax_aal_reset_items' ) ); } public function init() { @@ -39,191 +35,74 @@ public function plugin_action_links( $links ) { public function action_admin_menu() { $this->hook = add_submenu_page( 'activity-log-page', - __( 'Activity Log Settings', 'aryo-activity-log' ), // tag - __( 'Settings', 'aryo-activity-log' ), // menu label - 'manage_options', // required cap to view this page - $this->slug, // page slug - array( &$this, 'display_settings_page' ) // callback + __( 'Activity Log Settings', 'aryo-activity-log' ), + __( 'Settings', 'aryo-activity-log' ), + 'manage_options', + $this->slug, + array( &$this, 'display_settings_page' ) ); - // register scripts & styles, specific for the settings page - add_action( "admin_print_scripts-{$this->hook}", array( &$this, 'scripts_n_styles' ) ); - // this callback will initialize the settings for AAL - // add_action( "load-$this->hook", array( $this, 'register_settings' ) ); - } - - /** - * Register scripts & styles - * - * @since 1.0 - */ - public function scripts_n_styles() { - wp_enqueue_style( 'aal-settings', plugins_url( 'assets/css/settings.css', ACTIVITY_LOG__FILE__ ) ); + add_action( 'admin_enqueue_scripts', array( &$this, 'enqueue_settings_scripts' ) ); } - public function register_settings() { - // If no options exist, create them. - if ( ! get_option( $this->slug ) ) { - update_option( $this->slug, apply_filters( 'aal_default_options', array( - 'logs_lifespan' => '30', - 'logs_failed_login' => 'yes', - 'logs_email' => 'yes', - ) ) ); + public function enqueue_settings_scripts( $hook ) { + if ( ! isset( $this->hook ) || $hook !== $this->hook ) { + return; } - register_setting( 'aal-options', $this->slug, array( $this, 'validate_options' ) ); - $section = $this->get_setup_section(); - - switch ( $section ) { - case 'general': - // First, we register a section. This is necessary since all future options must belong to a - add_settings_section( - 'general_settings_section', // ID used to identify this section and with which to register options - __( 'Display Options', 'aryo-activity-log' ), // Title to be displayed on the administration page - array( 'AAL_Settings_Fields', 'general_settings_section_header' ), // Callback used to render the description of the section - $this->slug // Page on which to add this section of options - ); - - add_settings_field( - 'logs_lifespan', - __( 'Keep logs for', 'aryo-activity-log' ), - array( 'AAL_Settings_Fields', 'number_field' ), - $this->slug, - 'general_settings_section', - array( - 'id' => 'logs_lifespan', - 'page' => $this->slug, - 'classes' => array( 'small-text' ), - 'type' => 'number', - 'sub_desc' => __( 'days.', 'aryo-activity-log' ), - 'desc' => __( 'Maximum number of days to keep activity log. Leave blank to keep activity log forever (not recommended).', 'aryo-activity-log' ), - ) - ); - - add_settings_field( - 'logs_failed_login', - __( 'Keep Failed Login Logs', 'aryo-activity-log' ), - array( 'AAL_Settings_Fields', 'select_field' ), - $this->slug, - 'general_settings_section', - array( - 'id' => 'logs_failed_login', - 'page' => $this->slug, - 'type' => 'select', - 'options' => array( - 'yes' => __( 'Keep', 'aryo-activity-log' ), - 'no' => __( "Don't Keep (Not recommended)", 'aryo-activity-log' ), - ), - ) - ); - - add_settings_field( - 'logs_email', - __( 'Keep Email Logs', 'aryo-activity-log' ), - array( 'AAL_Settings_Fields', 'select_field' ), - $this->slug, - 'general_settings_section', - array( - 'id' => 'logs_email', - 'page' => $this->slug, - 'type' => 'select', - 'options' => array( - 'yes' => __( 'Keep', 'aryo-activity-log' ), - 'no' => __( "Don't Keep", 'aryo-activity-log' ), - ), - ) - ); - - add_settings_field( - 'log_visitor_ip_source', - __( 'Visitor IP Detected', 'aryo-activity-log' ), - array( 'AAL_Settings_Fields', 'select_field' ), - $this->slug, - 'general_settings_section', - array( - 'id' => 'log_visitor_ip_source', - 'page' => $this->slug, - 'type' => 'select', - 'options' => array( - 'REMOTE_ADDR' => 'REMOTE_ADDR', - 'HTTP_CF_CONNECTING_IP' => 'HTTP_CF_CONNECTING_IP', - 'HTTP_TRUE_CLIENT_IP' => 'HTTP_TRUE_CLIENT_IP', - 'HTTP_CLIENT_IP' => 'HTTP_CLIENT_IP', - 'HTTP_X_FORWARDED_FOR' => 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_FORWARDED' => 'HTTP_X_FORWARDED', - 'HTTP_X_CLUSTER_CLIENT_IP' => 'HTTP_X_CLUSTER_CLIENT_IP', - 'HTTP_FORWARDED_FOR' => 'HTTP_FORWARDED_FOR', - 'HTTP_FORWARDED' => 'HTTP_FORWARDED', - 'no-collect-ip' => __( 'Do not collect IP', 'aryo-activity-log' ), - ), - 'desc' => __( 'Select the source of the visitor IP address. For example, if you are using Cloudflare, select <code>HTTP_CF_CONNECTING_IP</code>.', 'aryo-activity-log' ) - . '<br />' - . __( '<strong>Please note:</strong> if you choose "Do not collect IP", the IP column will be hidden in the log.', 'aryo-activity-log' ), - ) - ); + $build_dir = plugin_dir_path( ACTIVITY_LOG__FILE__ ) . 'assets/js/admin/build/'; + $asset_file = $build_dir . 'settings-index.asset.php'; - if ( apply_filters( 'aal_allow_option_erase_logs', true ) ) { - add_settings_field( - 'raw_delete_log_activities', - __( 'Delete Log Activities', 'aryo-activity-log' ), - array( 'AAL_Settings_Fields', 'raw_html' ), - $this->slug, - 'general_settings_section', - array( - 'html' => sprintf( __( '<a href="%s" id="%s">Reset Database</a>', 'aryo-activity-log' ), add_query_arg( array( - 'action' => 'aal_reset_items', - '_nonce' => wp_create_nonce( 'aal_reset_items' ), - ), admin_url( 'admin-ajax.php' ) ), 'aal-delete-log-activities' ), - 'desc' => __( 'Warning: Clicking this will delete all activities from the database.', 'aryo-activity-log' ), - ) - ); - } - break; + if ( ! file_exists( $asset_file ) || ! file_exists( $build_dir . 'settings-index.js' ) ) { + wp_admin_notice( + esc_html__( 'Activity Log: admin assets are not built. Run `npm run build` in the plugin directory.', 'aryo-activity-log' ), + array( 'type' => 'error' ) + ); + return; } - } - /** - * Returns the current section within AAL's setting pages - * - * @return string - */ - public function get_setup_section() { - if ( isset( $_REQUEST['aal_section'] ) ) - return strtolower( $_REQUEST['aal_section'] ); + $asset = include $asset_file; - return 'general'; - } + wp_enqueue_script( + 'aal-settings-app', + plugins_url( 'assets/js/admin/build/settings-index.js', ACTIVITY_LOG__FILE__ ), + $asset['dependencies'], + $asset['version'], + true + ); - /** - * Prints section tabs within the settings area - */ - private function menu_print_tabs() { - $current_section = $this->get_setup_section(); - $sections = array( - 'general' => __( 'General', 'aryo-activity-log' ), + wp_enqueue_style( 'wp-components' ); + + $bootstrap = array( + 'restBase' => rest_url( AAL_REST::NAMESPACE_V1 ), + 'settingsNonce' => wp_create_nonce( 'aal_settings' ), ); - $sections = apply_filters( 'aal_setup_sections', $sections ); + wp_add_inline_script( + 'aal-settings-app', + 'window.aalAdmin = ' . wp_json_encode( $bootstrap ) . ';', + 'before' + ); - if ( 1 >= count( $sections ) ) { - return; - } + wp_set_script_translations( 'aal-settings-app', 'aryo-activity-log' ); + } - foreach ( $sections as $section_key => $section_caption ) { - $active = $current_section === $section_key ? 'nav-tab-active' : ''; - $url = add_query_arg( 'aal_section', $section_key ); - echo '<a class="nav-tab ' . esc_attr( $active ) . '" href="' . esc_url( $url ) . '">' . esc_html( $section_caption ) . '</a>'; + public function register_settings() { + if ( ! get_option( $this->slug ) ) { + update_option( $this->slug, apply_filters( 'aal_default_options', array( + 'logs_lifespan' => '30', + 'logs_failed_login' => 'yes', + 'logs_email' => 'yes', + ) ) ); } } public function validate_options( $input ) { - $options = $this->options; // CTX,L1504 + $options = $this->options; - // @todo some data validation/sanitization should go here $output = apply_filters( 'aal_validate_options', $input, $options ); - // merge with current settings $output = array_merge( $options, $output ); return $output; @@ -231,62 +110,13 @@ public function validate_options( $input ) { public function display_settings_page() { ?> - <!-- Create a header in the default WordPress 'wrap' container --> <div class="wrap"> - <h1 class="aal-page-title"><?php esc_html_e( 'Activity Log Settings', 'aryo-activity-log' ); ?></h1> - <?php settings_errors(); ?> - <h2 class="nav-tab-wrapper"><?php $this->menu_print_tabs(); ?></h2> - - <form method="post" action="options.php"> - <?php - settings_fields( 'aal-options' ); - do_settings_sections( $this->slug ); - submit_button(); - ?> - </form> - - </div><!-- /.wrap --> + <div id="aal-settings-root"></div> + </div> <?php } - public function admin_notices() { - switch ( filter_input( INPUT_GET, 'message' ) ) { - case 'data_erased': - printf( '<div class="updated"><p>%s</p></div>', esc_html__( 'All activities have been successfully deleted.', 'aryo-activity-log' ) ); - break; - } - } - - public function admin_footer() { - // TODO: move to a separate file. - ?> - <script type="text/javascript"> - jQuery( document ).ready( function( $ ) { - $( '#aal-delete-log-activities' ).on( 'click', function( e ) { - if ( ! confirm( '<?php echo esc_js( __( 'Attention: We are going to DELETE ALL ACTIVITIES from the database. Are you sure you want to do that?', 'aryo-activity-log' ) ); ?>' ) ) { - e.preventDefault(); - } - } ); - } ); - </script> - <?php - } - - public function ajax_aal_reset_items() { - if ( ! check_ajax_referer( 'aal_reset_items', '_nonce', false ) || ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'aryo-activity-log' ) ); - } - - AAL_Main::instance()->api->erase_all_items(); - - wp_redirect( add_query_arg( array( - 'page' => 'activity-log-settings', - 'message' => 'data_erased', - ), admin_url( 'admin.php' ) ) ); - die(); - } - public function get_option( $key = '' ) { $settings = $this->get_options(); return ! empty( $settings[ $key ] ) ? $settings[ $key ] : false; @@ -299,7 +129,6 @@ public function get_option( $key = '' ) { * @return array */ public function get_options() { - // Allow other plugins to get AAL's options. if ( isset( $this->options ) && is_array( $this->options ) && ! empty( $this->options ) ) return $this->options; @@ -310,127 +139,3 @@ public function slug() { return $this->slug; } } - -// TODO: Need rewrite this class to useful tool. -final class AAL_Settings_Fields { - - public static function general_settings_section_header() { - ?> - <p><?php esc_html_e( 'These are some basic settings for Activity Log.', 'aryo-activity-log' ); ?></p> - <?php - } - - public static function raw_html( $args ) { - if ( empty( $args['html'] ) ) - return; - - echo wp_kses_post( $args['html'] ); - if ( ! empty( $args['desc'] ) ) : ?> - <p class="description"><?php echo wp_kses_post( $args['desc'] ); ?></p> - <?php endif; - } - - public static function text_field( $args ) { - self::_set_name_and_value( $args ); - extract( $args, EXTR_SKIP ); - - $args = wp_parse_args( $args, array( - 'classes' => array(), - ) ); - if ( empty( $args['id'] ) || empty( $args['page'] ) ) - return; - - ?> - <input type="text" id="<?php echo esc_attr( $args['id'] ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( $value ); ?>" class="<?php echo esc_attr( implode( ' ', $args['classes'] ) ); ?>" /> - <?php if ( ! empty( $desc ) ) : ?> - <p class="description"><?php echo wp_kses_post( $desc ); ?></p> - <?php endif; - } - - public static function textarea_field( $args ) { - self::_set_name_and_value( $args ); - extract( $args, EXTR_SKIP ); - - $args = wp_parse_args( $args, array( - 'classes' => array(), - 'rows' => 5, - 'cols' => 50, - ) ); - - if ( empty( $args['id'] ) || empty( $args['page'] ) ) - return; - - ?> - <textarea id="<?php echo esc_attr( $args['id'] ); ?>" name="<?php echo esc_attr( $name ); ?>" class="<?php echo esc_attr( implode( ' ', $args['classes'] ) ); ?>" rows="<?php echo absint( $args['rows'] ); ?>" cols="<?php echo absint( $args['cols'] ); ?>"><?php echo esc_textarea( $value ); ?></textarea> - - <?php if ( ! empty( $desc ) ) : ?> - <p class="description"><?php echo wp_kses_post( $desc ); ?></p> - <?php endif; - } - - public static function number_field( $args ) { - self::_set_name_and_value( $args ); - extract( $args, EXTR_SKIP ); - - $args = wp_parse_args( $args, array( - 'classes' => array(), - 'min' => '1', - 'step' => '1', - 'desc' => '', - ) ); - if ( empty( $args['id'] ) || empty( $args['page'] ) ) - return; - - ?> - <input type="number" id="<?php echo esc_attr( $args['id'] ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( $value ); ?>" class="<?php echo esc_attr( implode( ' ', $args['classes'] ) ); ?>" min="<?php echo esc_attr( $args['min'] ); ?>" step="<?php echo esc_attr( $args['step'] ); ?>" /> - <?php if ( ! empty( $args['sub_desc'] ) ) echo wp_kses_post( $args['sub_desc'] ); ?> - <?php if ( ! empty( $args['desc'] ) ) : ?> - <p class="description"><?php echo wp_kses_post( $args['desc'] ); ?></p> - <?php endif; - } - - public static function select_field( $args ) { - self::_set_name_and_value( $args ); - extract( $args, EXTR_SKIP ); - - if ( empty( $options ) || empty( $id ) || empty( $page ) ) - return; - - ?> - <select id="<?php echo esc_attr( $id ); ?>" name="<?php printf( '%s[%s]', esc_attr( $page ), esc_attr( $id ) ); ?>"> - <?php foreach ( $options as $name => $label ) : ?> - <option value="<?php echo esc_attr( $name ); ?>" <?php selected( $name, (string) $value ); ?>> - <?php echo esc_html( $label ); ?> - </option> - <?php endforeach; ?> - </select> - <?php if ( ! empty( $desc ) ) : ?> - <p class="description"><?php echo wp_kses_post( $desc ); ?></p> - <?php endif; ?> - <?php - } - - public static function yesno_field( $args ) { - self::_set_name_and_value( $args ); - extract( $args, EXTR_SKIP ); - - ?> - <label class="tix-yes-no description"><input type="radio" name="<?php echo esc_attr( $name ); ?>" value="1" <?php checked( $value, true ); ?>> <?php esc_html_e( 'Yes', 'aryo-activity-log' ); ?></label> - <label class="tix-yes-no description"><input type="radio" name="<?php echo esc_attr( $name ); ?>" value="0" <?php checked( $value, false ); ?>> <?php esc_html_e( 'No', 'aryo-activity-log' ); ?></label> - - <?php if ( isset( $args['description'] ) ) : ?> - <p class="description"><?php echo wp_kses_post( $args['description'] ); ?></p> - <?php endif; ?> - <?php - } - - private static function _set_name_and_value( &$args ) { - if ( ! isset( $args['name'] ) ) { - $args['name'] = sprintf( '%s[%s]', esc_attr( $args['page'] ), esc_attr( $args['id'] ) ); - } - - if ( ! isset( $args['value'] ) ) { - $args['value'] = AAL_Main::instance()->settings->get_option( $args['id'] ); - } - } -} diff --git a/package.json b/package.json index 06314fb..26abbfe 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "description": "Activity Log", "version": "2.14.1", "scripts": { - "build": "wp-scripts build assets/js/admin/src/index.js --output-path=assets/js/admin/build", - "start": "wp-scripts start assets/js/admin/src/index.js --output-path=assets/js/admin/build", + "build": "wp-scripts build assets/js/admin/src/index.js assets/js/admin/src/settings-index.js --output-path=assets/js/admin/build", + "start": "wp-scripts start assets/js/admin/src/index.js assets/js/admin/src/settings-index.js --output-path=assets/js/admin/build", "clean": "rimraf $npm_package_name $npm_package_name.*.zip", "package": "npm run clean && npm run build && rsync -av --exclude-from=.build-rsync-exclude . $npm_package_name", "package:zip": "npm run package && zip -r $npm_package_name.$npm_package_version.zip ./$npm_package_name/*", diff --git a/readme.txt b/readme.txt index cf7fec6..87f8818 100644 --- a/readme.txt +++ b/readme.txt @@ -1,4 +1,4 @@ -=== Activity Log - Monitor & Record User Changes === +=== Activity Log – Monitor User and Agent Changes === Contributors: elemntor, KingYes, ariel.k Tags: Activity Log, User Log, Audit Log, Security, Email Log, Requires at least: 6.2 @@ -143,6 +143,9 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro == Changelog == += 2.15.0 - 2026-09-07 = +* New: Settings page rebuilt with React UI and REST API + = 2.14.1 - 2026-09-02 = * Tweak: Moved Application Password badge from Source column to User column diff --git a/tests/phpunit/test-settings-rest.php b/tests/phpunit/test-settings-rest.php new file mode 100644 index 0000000..4ea6b54 --- /dev/null +++ b/tests/phpunit/test-settings-rest.php @@ -0,0 +1,210 @@ +<?php + +class AAL_Test_Settings_REST extends WP_UnitTestCase { + + private $admin; + private $editor; + private $nonce; + + public function set_up() { + parent::set_up(); + + $this->admin = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->editor = self::factory()->user->create( array( 'role' => 'editor' ) ); + + wp_set_current_user( $this->admin ); + $this->nonce = wp_create_nonce( 'aal_settings' ); + + update_option( 'activity-log-settings', array( + 'logs_lifespan' => '30', + 'logs_failed_login' => 'yes', + 'logs_email' => 'yes', + 'log_visitor_ip_source' => 'REMOTE_ADDR', + ) ); + + $this->reset_settings_cache(); + } + + private function reset_settings_cache() { + $settings = AAL_Main::instance()->settings; + $ref = new ReflectionProperty( $settings, 'options' ); + $ref->setAccessible( true ); + $ref->setValue( $settings, null ); + } + + private function do_request( $method, $route, $body = array(), $user = null, $nonce = null ) { + if ( null !== $user ) { + wp_set_current_user( $user ); + } + + $request = new WP_REST_Request( $method, '/activity-log/v1/' . $route ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_header( 'X-AAL-Settings-Nonce', null !== $nonce ? $nonce : $this->nonce ); + + if ( ! empty( $body ) ) { + $request->set_body( wp_json_encode( $body ) ); + } + + return rest_get_server()->dispatch( $request ); + } + + // --- Permission tests --- + + public function test_editor_cannot_get_settings() { + wp_set_current_user( $this->editor ); + $nonce = wp_create_nonce( 'aal_settings' ); + + $response = $this->do_request( 'GET', 'settings', array(), $this->editor, $nonce ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_editor_cannot_update_settings() { + wp_set_current_user( $this->editor ); + $nonce = wp_create_nonce( 'aal_settings' ); + + $response = $this->do_request( 'PUT', 'settings', array( 'logs_lifespan' => '60' ), $this->editor, $nonce ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_editor_cannot_erase_logs() { + wp_set_current_user( $this->editor ); + $nonce = wp_create_nonce( 'aal_settings' ); + + $response = $this->do_request( 'POST', 'logs/erase', array(), $this->editor, $nonce ); + $this->assertSame( 403, $response->get_status() ); + } + + // --- Nonce tests --- + + public function test_missing_nonce_forbidden() { + $response = $this->do_request( 'GET', 'settings', array(), $this->admin, '' ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_invalid_nonce_forbidden() { + $response = $this->do_request( 'GET', 'settings', array(), $this->admin, 'bad-nonce' ); + $this->assertSame( 403, $response->get_status() ); + } + + // --- GET settings --- + + public function test_get_settings_returns_fields_and_erase() { + $response = $this->do_request( 'GET', 'settings' ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'fields', $data ); + $this->assertArrayHasKey( 'canEraseLogs', $data ); + $this->assertArrayHasKey( 'logs_lifespan', $data['fields'] ); + $this->assertArrayHasKey( 'logs_failed_login', $data['fields'] ); + $this->assertArrayHasKey( 'logs_email', $data['fields'] ); + $this->assertArrayHasKey( 'log_visitor_ip_source', $data['fields'] ); + } + + // --- PUT settings (sanitization) --- + + public function test_update_valid_settings() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_lifespan' => '60', + 'logs_failed_login' => 'no', + 'logs_email' => 'no', + 'log_visitor_ip_source' => 'HTTP_CF_CONNECTING_IP', + ) ); + + $this->assertSame( 200, $response->get_status() ); + + $this->reset_settings_cache(); + $saved = get_option( 'activity-log-settings' ); + $this->assertSame( '60', $saved['logs_lifespan'] ); + $this->assertSame( 'no', $saved['logs_failed_login'] ); + $this->assertSame( 'no', $saved['logs_email'] ); + $this->assertSame( 'HTTP_CF_CONNECTING_IP', $saved['log_visitor_ip_source'] ); + } + + public function test_update_empty_lifespan_keeps_forever() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_lifespan' => '', + ) ); + + $this->assertSame( 200, $response->get_status() ); + + $this->reset_settings_cache(); + $saved = get_option( 'activity-log-settings' ); + $this->assertSame( '', $saved['logs_lifespan'] ); + } + + public function test_update_invalid_ip_source_rejected() { + $response = $this->do_request( 'PUT', 'settings', array( + 'log_visitor_ip_source' => 'INVALID_HEADER', + ) ); + + $this->assertSame( 400, $response->get_status() ); + + $this->reset_settings_cache(); + $saved = get_option( 'activity-log-settings' ); + $this->assertSame( 'REMOTE_ADDR', $saved['log_visitor_ip_source'] ); + } + + public function test_update_invalid_yesno_rejected() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_failed_login' => 'maybe', + ) ); + + $this->assertSame( 400, $response->get_status() ); + } + + public function test_update_ignores_unknown_keys() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_lifespan' => '45', + 'evil_key' => 'injected', + ) ); + + $this->assertSame( 200, $response->get_status() ); + + $this->reset_settings_cache(); + $saved = get_option( 'activity-log-settings' ); + $this->assertSame( '45', $saved['logs_lifespan'] ); + $this->assertArrayNotHasKey( 'evil_key', $saved ); + } + + public function test_update_merges_with_existing() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_lifespan' => '90', + ) ); + + $this->assertSame( 200, $response->get_status() ); + + $this->reset_settings_cache(); + $saved = get_option( 'activity-log-settings' ); + $this->assertSame( '90', $saved['logs_lifespan'] ); + $this->assertSame( 'yes', $saved['logs_failed_login'] ); + $this->assertSame( 'REMOTE_ADDR', $saved['log_visitor_ip_source'] ); + } + + public function test_update_negative_lifespan_rejected() { + $response = $this->do_request( 'PUT', 'settings', array( + 'logs_lifespan' => '-5', + ) ); + + $this->assertSame( 400, $response->get_status() ); + } + + // --- Erase --- + + public function test_erase_logs_success() { + $response = $this->do_request( 'POST', 'logs/erase' ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + } + + public function test_erase_blocked_by_filter() { + add_filter( 'aal_allow_option_erase_logs', '__return_false' ); + + $response = $this->do_request( 'POST', 'logs/erase' ); + $this->assertSame( 403, $response->get_status() ); + + remove_filter( 'aal_allow_option_erase_logs', '__return_false' ); + } +}