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_recovery_line( $state ); ?>
' . 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' ) ],
];
?>
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' ),
];
?>
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' ),
],
]
);
}
}