options = $options; add_action( 'admin_init', [ $this, 'init' ] ); add_action( 'wp_ajax_adminoptim_check_login_slug', [ $this, 'ajax_check_slug' ] ); add_action( 'wp_ajax_adminoptim_login_url_pending', [ $this, 'ajax_pending_status' ] ); add_action( 'admin_post_ao_login_url_keep', [ $this, 'handle_keep' ] ); add_action( 'admin_post_ao_login_url_revert', [ $this, 'handle_revert' ] ); } /** * Start the engine running * * @return void */ public function init() { $this->settings_options = [ [ 'option_group' => Custom_Login_Url::OPTION_NAME, 'option_name' => Custom_Login_Url::OPTION_NAME, 'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ], ], ]; $this->settings_sections = [ [ 'id' => 'adminoptimizer-custom-login', 'title' => '', 'description' => '', 'menu_slug' => Custom_Login_Url::OPTION_NAME, 'option_name' => Custom_Login_Url::OPTION_NAME, 'fields' => [ [ 'type' => 'callback', 'title' => __( 'Custom Login URL', 'admin-optimizer' ), 'id' => 'custom-url', 'name' => 'custom_slug', 'desc' => __( 'Replaces wp-login.php for all users.', 'admin-optimizer' ), 'callback' => [ $this, 'render_custom_login_url_field' ], ], [ 'type' => 'callback', 'title' => __( 'Logged-out visitors', 'admin-optimizer' ), 'id' => 'redirect-mode', 'name' => 'redirect_mode', 'desc' => __( 'What visitors get when they open wp-login.php or wp-admin while logged out.', 'admin-optimizer' ), 'callback' => [ $this, 'render_redirection_url_field' ], ], [ 'type' => 'callback', 'title' => __( 'Email URL to Admin on Change', 'admin-optimizer' ), 'id' => 'email-on-change', 'name' => 'email_on_change', 'desc' => __( 'Send the login URL to the admin when it changes.', 'admin-optimizer' ), 'callback' => [ $this, 'render_email_field' ], ], ], ], ]; $this->setup(); } /** * Callback to render Custom Login URL field * * @return void */ public function render_custom_login_url_field() { $effective = ao_get_effective_login_slug( $this->options ); $state = self::get_constant_state( $effective ); $value = $this->options['custom_slug'] ?? ''; $cache_url = '' === $effective['slug'] ? '/{slug}/' : ( get_option( 'permalink_structure' ) ? '/' . $effective['slug'] . '/' : '/?' . $effective['slug'] ); $compat = Custom_Login_Url_Compat::detect(); ?>
render_constant_slug_row( $state, $effective, (string) $value ); ?>
/

render_recovery_line( $state ); ?>

. The name is never translated. * * @param string $text Constant name, command or slug. * * @return string */ private static function code( string $text ): string { return '' . esc_html( $text ) . ''; } /** * Fill the %s of a translated string with a name. The string is * escaped first, so the result is safe to print. * * @param string $format Translated format with one %s. * @param string $text Constant name or slug for the element. * * @return string */ private static function with_code( string $format, string $text ): string { return sprintf( esc_html( $format ), self::code( $text ) ); } /** * The slug row while a wp-config.php constant is in control: no input, no * Generate button and no acknowledgment, so nothing can be mistaken for the * live URL. The saved value, if any, is a muted line of text. * * @param string $state Result of get_constant_state(). * @param array{slug:string,source:string,enabled:bool,error:string} $effective Result of ao_get_effective_login_slug(). * @param string $saved The saved slug (inactive while a constant is in force). * * @return void */ private function render_constant_slug_row( string $state, array $effective, string $saved ): void { if ( 'override' === $state ) : $url = Custom_Login_Url::build_url( $effective['slug'] ); ?>

' . esc_html__( 'Learn more', 'admin-optimizer' ) . ''; ?>

options ) ? $this->options : [] ); $name = Custom_Login_Url::OPTION_NAME . '[redirect_mode]'; $modes = [ '404' => [ __( 'Show a 404 page', 'admin-optimizer' ), __( 'Visitors see a "page not found" page.', 'admin-optimizer' ) ], 'home' => [ __( 'Redirect to the home page', 'admin-optimizer' ), __( 'Visitors are sent to your home page.', 'admin-optimizer' ) ], 'custom' => [ __( 'Redirect to a custom URL', 'admin-optimizer' ), __( 'Visitors are sent to a URL you choose.', 'admin-optimizer' ) ], ]; ?>

$labels ) : ?>
>

/
options['email_on_change'] ) || ! empty( $this->options['email_on_change'] ); ?>

last_sanitized && $options === $this->last_sanitized ) { return $options; } $options = is_array( $options ) ? $options : []; $stored = get_option( Custom_Login_Url::OPTION_NAME, [] ); $stored = is_array( $stored ) ? $stored : []; $sanitized_options = $stored; // --- 1. Email setting. --- if ( isset( $options['email_on_change'] ) ) { $sanitized_options['email_on_change'] = empty( $options['email_on_change'] ) ? 0 : 1; } // --- 2. Login slug. A wp-config.php constant owns the value, so the posted one is ignored. --- $effective = ao_get_effective_login_slug( $stored ); if ( 'db' === $effective['source'] && isset( $options['custom_slug'] ) ) { $candidate = strtolower( trim( trim( (string) $options['custom_slug'] ), '/' ) ); $stored_slug = isset( $stored['custom_slug'] ) ? strtolower( (string) $stored['custom_slug'] ) : ''; // Unchanged slug: keep it and skip validation. A stored slug that is now reserved or common // (for example the old "login" default) must not block unrelated saves; the screen shows a notice. if ( $candidate !== $stored_slug ) { $redirect_for_check = 'custom' === ( $options['redirect_mode'] ?? '' ) ? sanitize_title_with_dashes( $options['redirection_slug'] ?? '' ) : ''; $result = Custom_Login_Url_Validator::validate( $candidate, [ 'redirection_slug' => $redirect_for_check ] ); if ( Custom_Login_Url_Validator::STATUS_BLOCK === $result['status'] ) { add_settings_error( Custom_Login_Url::OPTION_NAME, 'ao_clu_blocked', $result['message'], 'error' ); $this->last_sanitized = $stored; return $stored; } if ( Custom_Login_Url_Validator::STATUS_WARN === $result['status'] && empty( $options['slug_ack'] ) ) { add_settings_error( Custom_Login_Url::OPTION_NAME, 'ao_clu_unacknowledged', $result['message'] . ' ' . __( 'Tick "Use this slug anyway" next to the field to save it.', 'admin-optimizer' ), 'error' ); $this->last_sanitized = $stored; return $stored; } $candidate = $result['slug']; } $sanitized_options['custom_slug'] = $candidate; } // --- 3. Redirect target: a mode, plus a slug for "custom". Nothing is migrated. --- // The login slug in force: the saved one, or the wp-config.php constant's when one controls it. $login_slug = 'db' === $effective['source'] ? ( $sanitized_options['custom_slug'] ?? '' ) : $effective['slug']; if ( isset( $options['redirect_mode'] ) ) { $mode = in_array( $options['redirect_mode'], [ '404', 'home', 'custom' ], true ) ? $options['redirect_mode'] : '404'; if ( 'custom' === $mode ) { $redirect_slug = sanitize_title_with_dashes( $options['redirection_slug'] ?? '' ); $stored_slug = isset( $stored['redirection_slug'] ) ? (string) $stored['redirection_slug'] : ''; if ( $redirect_slug !== $stored_slug || 'custom' !== ( ao_get_login_redirect_target( $stored )['mode'] ) ) { $result = Custom_Login_Url_Validator::validate( $redirect_slug, [ 'purpose' => 'redirect', 'login_slug' => $login_slug, ] ); if ( Custom_Login_Url_Validator::STATUS_BLOCK === $result['status'] ) { add_settings_error( Custom_Login_Url::OPTION_NAME, 'ao_clu_redirect_blocked', $result['message'], 'error' ); $this->last_sanitized = $stored; return $stored; } $redirect_slug = $result['slug']; } $sanitized_options['redirection_slug'] = $redirect_slug; } $sanitized_options['redirect_mode'] = $mode; } elseif ( isset( $options['redirection_slug'] ) ) { // Older form without a mode: keep the slug-only behavior. $sanitized_options['redirection_slug'] = sanitize_title_with_dashes( $options['redirection_slug'] ); } // --- 4. Safe-save: a changed slug saved from this screen must be confirmed or it is reverted. --- // Export/Import and the CLI write the option without posting this form, so they never start one. if ( ( $sanitized_options['custom_slug'] ?? '' ) !== ( $stored['custom_slug'] ?? '' ) ) { $from_screen = isset( $_POST['option_page'] ) && Custom_Login_Url::OPTION_NAME === $_POST['option_page']; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- options.php verified the nonce before calling this sanitizer. if ( $from_screen && 'db' === $effective['source'] && Module_Registry::is_enabled( Custom_Login_Url::MODULE_ID ) ) { $sanitized_options = Custom_Login_Url_Safe_Save::begin( $stored, $sanitized_options, time() ); } else { unset( $sanitized_options[ Custom_Login_Url_Safe_Save::PENDING_KEY ] ); } } $this->last_sanitized = $sanitized_options; return $sanitized_options; } /** * AJAX: run the server validator for the inline check. It sees what the * browser cannot (existing pages, rewrite bases); the save handler still * validates again. * * @return void */ public function ajax_check_slug() { check_ajax_referer( 'adminoptim_check_login_slug', '_wpnonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do this.', 'admin-optimizer' ) ], 403 ); } $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : ''; $redirection = isset( $_POST['redirection'] ) ? sanitize_title_with_dashes( wp_unslash( $_POST['redirection'] ) ) : ''; wp_send_json_success( Custom_Login_Url_Validator::validate( $slug, [ 'redirection_slug' => $redirection ] ) ); } /** * The status block's inner markup for one state, or '' for `none`. Used by * the page render and by the poll, so the server is the only source of the * block. Every state is a standard inline notice, so colors come from the * admin: info while waiting, success on confirm, warning on revert. * * @param string $state Result of Custom_Login_Url_Safe_Save::select_status(). * @param array|null $pending Pending state (for `waiting`). * * @return string */ public function get_status_html( string $state, ?array $pending ): string { $texts = [ 'confirmed' => [ 'success', __( 'New login URL confirmed.', 'admin-optimizer' ) ], 'kept' => [ 'success', __( 'New login URL kept. It was not verified, so bookmark it and test it in a private window.', 'admin-optimizer' ) ], 'reverted' => [ 'warning', __( 'The new login URL was not confirmed in time, so the previous login URL was restored.', 'admin-optimizer' ) ], 'restored' => [ 'success', __( 'The previous login URL was restored.', 'admin-optimizer' ) ], 'noop' => [ 'info', __( 'Nothing to change: there was no pending login URL change.', 'admin-optimizer' ) ], ]; ob_start(); if ( 'waiting' === $state && null !== $pending ) { $verify_url = Custom_Login_Url_Safe_Save::get_verify_url( $pending ); $remaining = max( 0, $pending['expires'] - time() ); ?>

options['custom_slug'] ?? '' ) ); ?>

%3$s

', esc_attr( $texts[ $state ][0] ), esc_attr( $state ), esc_html( $texts[ $state ][1] ) ); } return (string) ob_get_clean(); } /** * Output the status block inside the form card, above the first row. The * settings API prints a section's callback there, so this overrides the * (otherwise description-only) one. The wrapper is always present and is * the polite live region the poll swaps content into. * * @param array $args Section arguments. * * @return void */ public function render_section_description( $args ) { echo '
'; echo $this->get_status_html( $this->status_state, $this->status_pending ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped where it is built. echo '
'; parent::render_section_description( $args ); } /** * The two POST forms behind "Revert now" and "Keep without checking". They * live outside the settings form (forms cannot nest); the buttons in the * status block point at them with the `form` attribute, so they work with * JavaScript off. * * @return void */ private function render_status_forms(): void { foreach ( [ 'ao-clu-keep-form' => 'ao_login_url_keep', 'ao-clu-revert-form' => 'ao_login_url_revert', ] as $form_id => $action ) { ?>
handle_safe_save_action( 'keep' ); } /** * "Revert now": restore the previous login URL at once, without an email. * * @return void */ public function handle_revert() { $this->handle_safe_save_action( 'revert' ); } /** * Shared by the two buttons: nonce and capability first, record the * one-shot outcome, then go back to the clean settings URL. * * @param string $which 'keep' or 'revert'. * * @return void */ private function handle_safe_save_action( string $which ): void { check_admin_referer( 'ao_login_url_safe_save' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'admin-optimizer' ), '', [ 'response' => 403 ] ); } if ( 'keep' === $which ) { $state = Custom_Login_Url_Safe_Save::clear() ? 'kept' : 'noop'; } else { $state = Custom_Login_Url_Safe_Save::revert( false ) ? 'restored' : 'noop'; } Custom_Login_Url_Safe_Save::set_status( $state ); wp_safe_redirect( Custom_Login_Url::get_module_admin_url() ); exit; } /** * AJAX: where the pending state stands, for the status block's poll. While * pending it returns the seconds left; once settled it returns the final * block, consuming the one-shot outcome, so the page can swap it in place. * Reading it also settles an expired state, so the answer is never stale. * * @return void */ public function ajax_pending_status() { check_ajax_referer( 'adminoptim_login_url_pending', '_wpnonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do this.', 'admin-optimizer' ) ], 403 ); } $slug = isset( $_POST['slug'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['slug'] ) ) ) : ''; $options = get_option( Custom_Login_Url::OPTION_NAME, [] ); $options = is_array( $options ) ? $options : []; $pending = Custom_Login_Url_Safe_Save::get_pending( $options ); if ( null !== $pending && Custom_Login_Url_Safe_Save::is_expired( $pending, time() ) ) { Custom_Login_Url_Safe_Save::maybe_expire(); $options = get_option( Custom_Login_Url::OPTION_NAME, [] ); $options = is_array( $options ) ? $options : []; $pending = Custom_Login_Url_Safe_Save::get_pending( $options ); } if ( null !== $pending ) { wp_send_json_success( [ 'state' => 'waiting', 'remaining' => max( 0, $pending['expires'] - time() ), ] ); } // Settled. Prefer the recorded outcome; otherwise infer it from the stored slug. $state = Custom_Login_Url_Safe_Save::select_status( null, Custom_Login_Url_Safe_Save::take_status(), false ); if ( 'none' === $state ) { $state = strtolower( (string) ( $options['custom_slug'] ?? '' ) ) === $slug ? 'confirmed' : 'reverted'; } wp_send_json_success( [ 'state' => $state, 'slug' => (string) ( $options['custom_slug'] ?? '' ), 'url' => '' === (string) ( $options['custom_slug'] ?? '' ) ? '' : Custom_Login_Url::build_url( (string) $options['custom_slug'] ), 'html' => $this->get_status_html( $state, null ), ] ); } /** * One copy snippet: the text in a and its Copy button, joined in a * single bordered unit. The one component for every copyable command or * line on this screen; the existing copy script reads data-ao-copy. * * @param string $text The text shown and copied. * @param string $label Accessible name of the button (not translated for commands). * * @return void */ private static function render_snippet( string $text, string $label ): void { ?> , closed by default, no script needed. It sits outside the form, so * it has no named inputs; its buttons are copy buttons handled by the screen's * existing script. Nothing here prints the stored slug. * * @param array{slug:string,source:string,enabled:bool,error:string} $effective Result of ao_get_effective_login_slug(). * * @return void */ public function render_recovery_section( array $effective ): void { $by_constant = 'db' !== $effective['source']; $cli = [ 'wp ao login-url get' => __( 'Shows the login URL and where it comes from.', 'admin-optimizer' ), 'wp ao login-url reset' => __( 'Restores the default login.', 'admin-optimizer' ), 'wp ao login-url disable' => __( 'Turns the module off and keeps your settings.', 'admin-optimizer' ), ]; $constants = [ [ "define( 'AO_DISABLE_CUSTOM_LOGIN', true );", __( 'Restores the default login.', 'admin-optimizer' ), 'constant-disable' ], [ "define( 'AO_CUSTOM_LOGIN_URL', '" . self::EXAMPLE_SLUG . "' );", __( 'Sets the custom login URL from the file.', 'admin-optimizer' ), 'constant-override' ], ]; $automation = [ 'wp ao login-url set ' . self::EXAMPLE_SLUG => __( 'Sets the login URL with the same checks as this screen.', 'admin-optimizer' ), 'wp ao login-url generate --set' => __( 'Generates and saves a random URL.', 'admin-optimizer' ), ]; ?>

    • $purpose ) : ?>

    $purpose ) : ?>

options ); $pending = 'db' === $effective['source'] ? Custom_Login_Url_Safe_Save::get_pending( is_array( $this->options ) ? $this->options : [] ) : null; // One status block, one state. The one-shot outcome is read (and so consumed) on every view, // even when a pending state hides it, so a stale "confirmed" can never come back later. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only; core already verified the nonce on options.php. $just_saved = ! empty( $_GET['settings-updated'] ); $has_error = false; foreach ( get_settings_errors( Custom_Login_Url::OPTION_NAME ) as $settings_error ) { if ( 'error' === $settings_error['type'] ) { $has_error = true; } } $this->status_pending = $pending; $this->status_state = Custom_Login_Url_Safe_Save::select_status( $pending, Custom_Login_Url_Safe_Save::take_status(), $just_saved && ! $has_error && 'db' === $effective['source'] && $effective['enabled'] ); ?> options['custom_slug'] ?? '' ); if ( 'db' === $effective['source'] && '' !== $stored_slug ) { $target = ao_get_login_redirect_target( is_array( $this->options ) ? $this->options : [] ); $check = Custom_Login_Url_Validator::validate( $stored_slug, [ 'redirection_slug' => $target['slug'] ] ); if ( Custom_Login_Url_Validator::STATUS_OK !== $check['status'] ) : ?>

render_settings_on_page( Custom_Login_Url::OPTION_NAME ); ?> render_recovery_section( $effective ); ?>
status_state ) { $this->render_status_forms(); } } /** * Enqueue module settings assets. Called only on this module's screen. * * @param string $hook_suffix Current admin page hook suffix. */ public function enqueue_settings_assets( string $hook_suffix ): void { wp_enqueue_style( 'adminoptim-custom-login-url-settings', Custom_Login_Url::MODULE_URL . 'assets/css/custom-login-url-settings.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-custom-login-url-settings', Custom_Login_Url::MODULE_URL . 'assets/js/custom-login-url-settings.min.js', [], ADMINOPTIMIZER_VERSION, true ); // The panel's script exists only while a safe-save is pending. $options = is_array( $this->options ) ? $this->options : []; if ( null !== Custom_Login_Url_Safe_Save::get_pending( $options ) && 'db' === ao_get_effective_login_slug( $options )['source'] ) { wp_enqueue_script( 'adminoptim-custom-login-url-safe-save', Custom_Login_Url::MODULE_URL . 'assets/js/custom-login-url-safe-save.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-custom-login-url-safe-save', 'adminoptimLoginUrlPending', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'adminoptim_login_url_pending' ), 'i18n' => [ /* translators: %s: time left, for example 5:19. */ 'timeLeft' => __( '%s left', 'admin-optimizer' ), /* translators: %s: time left, for example 5:19. */ 'startNote' => __( '%s left to open the new URL.', 'admin-optimizer' ), 'minuteNote' => __( 'One minute left to open the new URL.', 'admin-optimizer' ), ], ] ); } wp_localize_script( 'adminoptim-custom-login-url-settings', 'adminoptimLoginUrl', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'adminoptim_check_login_slug' ), 'reserved' => Custom_Login_Url_Validator::get_reserved_slugs(), 'common' => Custom_Login_Url_Validator::get_common_slugs(), 'giveaway' => Custom_Login_Url_Validator::get_giveaway_words(), 'minLength' => Custom_Login_Url_Validator::MIN_LENGTH, 'maxLength' => Custom_Login_Url_Validator::MAX_LENGTH, 'shortBelow' => Custom_Login_Url_Validator::SHORT_WARN_BELOW, 'messages' => Custom_Login_Url_Validator::get_messages(), 'words' => Custom_Login_Url_Service::get_words(), 'i18n' => [ 'generated' => __( 'New URL generated. Save changes to apply it, and bookmark it first.', 'admin-optimizer' ), 'copied' => __( 'Copied', 'admin-optimizer' ), 'copyFailed' => __( 'Press Ctrl+C to copy', 'admin-optimizer' ), 'ackNeeded' => __( 'Tick "Use this slug anyway" to save this URL.', 'admin-optimizer' ), ], ] ); } }