wp-m365-login/includes/class-m365-login-admin.php
friloo 701e85ab88
Support custom login pages
- Add the button (and error messages) to every wp_login_form() form via
  login_form_top/login_form_bottom; in button-only mode the password
  fields are wrapped and hidden there.
- New template functions m365_login_button() and m365_login_messages();
  the shortcode gains divider and messages attributes.
- New setting for the custom login page URL: failed sign-ins, the
  fallback link and the logout redirect point there instead of
  wp-login.php. Must be a same-site URL.
- Button-only mode now blocks every interactive password sign-in
  through the authenticate filter, not only wp-login.php; XML-RPC, REST,
  WP-CLI and cron are exempt, plus a filter for trusted exceptions.
- Fallback key accepted on any page (init) instead of login_init only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
2026-09-22 14:52:11 +00:00

607 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Admin settings screen.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Registers and renders the settings page.
*/
class M365_Login_Admin {
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const NONCE_TEST = 'm365_login_test';
/**
* Settings.
*
* @var M365_Login_Settings
*/
private $settings;
/**
* Auth component (for endpoint URLs and discovery).
*
* @var M365_Login_Auth
*/
private $auth;
/**
* Graph client.
*
* @var M365_Login_Graph
*/
private $graph;
/**
* Screen hook suffix.
*
* @var string
*/
private $hook = '';
/**
* Constructor.
*
* @param M365_Login_Settings $settings Settings.
* @param M365_Login_Auth $auth Auth.
* @param M365_Login_Graph $graph Graph client.
*/
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph ) {
$this->settings = $settings;
$this->auth = $auth;
$this->graph = $graph;
add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_init', array( $this, 'register' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
}
/**
* Adds the menu entry under Settings.
*/
public function menu() {
$this->hook = add_options_page(
__( 'M365 Login', 'm365-login' ),
__( 'M365 Login', 'm365-login' ),
'manage_options',
self::PAGE,
array( $this, 'render' )
);
}
/**
* Registers the option with the Settings API.
*/
public function register() {
register_setting(
self::GROUP,
M365_LOGIN_OPTION,
array(
'type' => 'array',
'sanitize_callback' => array( $this->settings, 'sanitize' ),
'default' => $this->settings->defaults(),
)
);
}
/**
* Nudges administrators to finish the setup.
*/
public function setup_notice() {
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
if ( $screen && $this->hook === $screen->id ) {
return;
}
if ( ! $screen || ! in_array( $screen->id, array( 'plugins', 'dashboard' ), true ) ) {
return;
}
printf(
'<div class="notice notice-info is-dismissible"><p>%s <a href="%s">%s</a></p></div>',
esc_html__( 'M365 Login is active but not connected to Microsoft Entra ID yet.', 'm365-login' ),
esc_url( admin_url( 'options-general.php?page=' . self::PAGE ) ),
esc_html__( 'Open the settings', 'm365-login' )
);
}
/**
* Loads assets on our screen only.
*
* @param string $hook Current screen hook.
*/
public function enqueue( $hook ) {
if ( $hook !== $this->hook ) {
return;
}
wp_enqueue_media();
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_style( 'm365-login-admin', M365_LOGIN_URL . 'assets/css/admin.css', array( 'wp-color-picker' ), M365_LOGIN_VERSION );
wp_enqueue_script( 'm365-login-admin', M365_LOGIN_URL . 'assets/js/admin.js', array( 'jquery', 'wp-color-picker' ), M365_LOGIN_VERSION, true );
wp_localize_script(
'm365-login-admin',
'm365LoginAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
'useIcon' => __( 'Use this icon', 'm365-login' ),
'copied' => __( 'Copied!', 'm365-login' ),
'copy' => __( 'Copy', 'm365-login' ),
'testing' => __( 'Testing…', 'm365-login' ),
'testFailed' => __( 'The tenant could not be reached. Check the tenant ID and the servers outgoing connections.', 'm365-login' ),
'noGroups' => __( 'No groups found.', 'm365-login' ),
'searching' => __( 'Searching…', 'm365-login' ),
'add' => __( 'Add', 'm365-login' ),
'remove' => __( 'Remove', 'm365-login' ),
'saveFirst' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ),
'confirmKey' => __( 'Generate a new fallback key on save? The old link stops working.', 'm365-login' ),
),
)
);
}
/**
* AJAX: fetch the OpenID configuration for the tenant typed into the form.
*/
public function ajax_test_connection() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$tenant = isset( $_POST['tenant'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['tenant'] ) ) ) : '';
if ( '' === $tenant || ! M365_Login_Settings::is_valid_tenant( $tenant ) ) {
wp_send_json_error( array( 'message' => __( 'Please enter a valid tenant ID first.', 'm365-login' ) ) );
}
$url = 'https://login.microsoftonline.com/' . rawurlencode( $tenant ) . '/v2.0/.well-known/openid-configuration';
$response = wp_remote_get( $url, array( 'timeout' => M365_Login_Auth::HTTP_TIMEOUT ) );
if ( is_wp_error( $response ) ) {
wp_send_json_error( array( 'message' => $response->get_error_message() ) );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! is_array( $body ) || empty( $body['issuer'] ) ) {
wp_send_json_error(
array(
/* translators: %d: HTTP status code */
'message' => sprintf( __( 'Microsoft answered with HTTP %d. Is the tenant ID correct?', 'm365-login' ), $code ),
)
);
}
wp_send_json_success(
array(
'issuer' => esc_url_raw( $body['issuer'] ),
'endpoint' => isset( $body['authorization_endpoint'] ) ? esc_url_raw( $body['authorization_endpoint'] ) : '',
'message' => __( 'Tenant reachable. The OpenID configuration was loaded successfully.', 'm365-login' ),
)
);
}
/**
* AJAX: search Entra groups through Microsoft Graph.
*/
public function ajax_search_groups() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
if ( ! $this->settings->is_configured() ) {
wp_send_json_error( array( 'message' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ) ) );
}
$query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
$groups = $this->graph->search_groups( mb_substr( $query, 0, 100 ) );
if ( is_wp_error( $groups ) ) {
$message = $groups->get_error_message();
if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) {
$message = __( 'Microsoft Graph refused the request. Grant the application permission "GroupMember.Read.All" (or "Directory.Read.All") with admin consent in Entra ID.', 'm365-login' );
}
wp_send_json_error( array( 'message' => $message ) );
}
wp_send_json_success( array( 'groups' => $groups ) );
}
/**
* Renders the settings screen.
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
}
$s = $this->settings->all();
$configured = $this->settings->is_configured();
$has_secret = '' !== $this->settings->client_secret();
$option = M365_LOGIN_OPTION;
$field = function ( $key ) use ( $option ) {
return esc_attr( $option . '[' . $key . ']' );
};
?>
<div class="wrap m365-admin">
<header class="m365-admin__header">
<div class="m365-admin__brand">
<span class="m365-admin__logo"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
<div>
<h1><?php esc_html_e( 'M365 Login', 'm365-login' ); ?></h1>
<p><?php esc_html_e( 'Let existing users sign in with their Microsoft 365 / Entra ID account.', 'm365-login' ); ?></p>
</div>
</div>
<span class="m365-admin__status <?php echo $configured ? 'is-ok' : 'is-pending'; ?>">
<span class="m365-admin__status-dot"></span>
<?php echo $configured ? esc_html__( 'Connected', 'm365-login' ) : esc_html__( 'Setup incomplete', 'm365-login' ); ?>
</span>
</header>
<form method="post" action="options.php" class="m365-admin__form" novalidate>
<?php settings_fields( self::GROUP ); ?>
<nav class="m365-admin__tabs" role="tablist">
<button type="button" class="m365-admin__tab is-active" role="tab" data-tab="connection" aria-selected="true"><?php esc_html_e( 'Connection', 'm365-login' ); ?></button>
<button type="button" class="m365-admin__tab" role="tab" data-tab="button" aria-selected="false"><?php esc_html_e( 'Button', 'm365-login' ); ?></button>
<button type="button" class="m365-admin__tab" role="tab" data-tab="security" aria-selected="false"><?php esc_html_e( 'Security', 'm365-login' ); ?></button>
</nav>
<div class="m365-admin__layout">
<div class="m365-admin__main">
<!-- Connection -->
<section class="m365-admin__panel is-active" data-panel="connection">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Microsoft Entra ID app registration', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Enter the values from your app registration in the Microsoft Entra admin center.', 'm365-login' ); ?></p>
<div class="m365-field">
<label for="m365-tenant"><?php esc_html_e( 'Directory (tenant) ID', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="text" id="m365-tenant" name="<?php echo $field( 'tenant_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['tenant_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
<button type="button" class="button" id="m365-test"><?php esc_html_e( 'Test tenant', 'm365-login' ); ?></button>
</div>
<p class="description"><?php esc_html_e( 'Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. "organizations" allows any work or school account.', 'm365-login' ); ?></p>
<div id="m365-test-result" class="m365-inline-result" hidden></div>
</div>
<div class="m365-field">
<label for="m365-client-id"><?php esc_html_e( 'Application (client) ID', 'm365-login' ); ?></label>
<input type="text" id="m365-client-id" name="<?php echo $field( 'client_id' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['client_id'] ); ?>" class="regular-text code" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" spellcheck="false" />
</div>
<div class="m365-field">
<label for="m365-client-secret"><?php esc_html_e( 'Client secret', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="password" id="m365-client-secret" name="<?php echo $field( 'client_secret' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="" class="regular-text code" autocomplete="new-password" placeholder="<?php echo $has_secret ? esc_attr__( '•••••••••••• (stored, leave empty to keep)', 'm365-login' ) : esc_attr__( 'Paste the secret value', 'm365-login' ); ?>" />
<button type="button" class="button m365-toggle-secret" aria-label="<?php esc_attr_e( 'Show secret', 'm365-login' ); ?>"><span class="dashicons dashicons-visibility"></span></button>
</div>
<?php if ( $has_secret ) : ?>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'client_secret_clear' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" />
<?php esc_html_e( 'Remove the stored secret', 'm365-login' ); ?>
</label>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire note the expiry date in Entra ID.', 'm365-login' ); ?></p>
</div>
<div class="m365-field">
<label for="m365-prompt"><?php esc_html_e( 'Account prompt', 'm365-login' ); ?></label>
<select id="m365-prompt" name="<?php echo $field( 'prompt' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<option value="select_account" <?php selected( $s['prompt'], 'select_account' ); ?>><?php esc_html_e( 'Always let the user pick an account (recommended)', 'm365-login' ); ?></option>
<option value="none" <?php selected( $s['prompt'], 'none' ); ?>><?php esc_html_e( 'Use the current Microsoft session if available', 'm365-login' ); ?></option>
<option value="login" <?php selected( $s['prompt'], 'login' ); ?>><?php esc_html_e( 'Always require re-entering credentials', 'm365-login' ); ?></option>
</select>
</div>
</div>
</section>
<!-- Button -->
<section class="m365-admin__panel" data-panel="button">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Appearance', 'm365-login' ); ?></h2>
<div class="m365-preview">
<span class="m365-preview__label"><?php esc_html_e( 'Live preview', 'm365-login' ); ?></span>
<div class="m365-preview__stage">
<div class="m365-login m365-login--preview" id="m365-preview" style="<?php echo esc_attr( str_replace( array( '.m365-login{', '}' ), '', M365_Login::instance()->button->css_variables() ) ); ?>">
<div class="m365-login__divider"><span id="m365-preview-divider"><?php echo esc_html( $s['divider_text'] ); ?></span></div>
<a class="m365-login__button" href="#" onclick="return false;" id="m365-preview-button">
<span id="m365-preview-icon"><?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
<span class="m365-login__label" id="m365-preview-text"><?php echo esc_html( $s['button_text'] ); ?></span>
</a>
</div>
</div>
</div>
<div class="m365-grid">
<div class="m365-field">
<label for="m365-button-text"><?php esc_html_e( 'Button text', 'm365-login' ); ?></label>
<input type="text" id="m365-button-text" name="<?php echo $field( 'button_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_text'] ); ?>" class="regular-text" maxlength="80" data-preview="text" />
</div>
<div class="m365-field">
<label for="m365-divider-text"><?php esc_html_e( 'Divider text', 'm365-login' ); ?></label>
<input type="text" id="m365-divider-text" name="<?php echo $field( 'divider_text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['divider_text'] ); ?>" class="regular-text" maxlength="40" data-preview="divider" />
<p class="description"><?php esc_html_e( 'Leave empty to hide the divider line.', 'm365-login' ); ?></p>
</div>
</div>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Icon', 'm365-login' ); ?></span>
<label class="m365-check">
<input type="checkbox" name="<?php echo $field( 'button_show_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_show_icon'] ); ?> data-preview="show-icon" />
<?php esc_html_e( 'Show an icon on the button', 'm365-login' ); ?>
</label>
<div class="m365-icon-picker">
<div class="m365-icon-picker__thumb" id="m365-icon-thumb">
<?php if ( '' !== $s['button_icon'] ) : ?>
<img src="<?php echo esc_url( $s['button_icon'] ); ?>" alt="" />
<?php else : ?>
<?php echo M365_Login_Button::microsoft_logo(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?>
<?php endif; ?>
</div>
<div class="m365-icon-picker__controls">
<input type="url" id="m365-icon-url" name="<?php echo $field( 'button_icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_url( $s['button_icon'] ); ?>" class="regular-text code" placeholder="<?php esc_attr_e( 'Default: Microsoft logo', 'm365-login' ); ?>" data-preview="icon" />
<div class="m365-field__row">
<button type="button" class="button" id="m365-icon-choose"><?php esc_html_e( 'Choose from media library', 'm365-login' ); ?></button>
<button type="button" class="button-link m365-link-danger" id="m365-icon-reset"><?php esc_html_e( 'Use Microsoft logo', 'm365-login' ); ?></button>
</div>
<p class="description"><?php esc_html_e( 'PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best.', 'm365-login' ); ?></p>
</div>
</div>
</div>
<div class="m365-grid m365-grid--4">
<?php
$colors = array(
'button_bg' => __( 'Background', 'm365-login' ),
'button_bg_hover' => __( 'Background (hover)', 'm365-login' ),
'button_color' => __( 'Text colour', 'm365-login' ),
'button_border' => __( 'Border', 'm365-login' ),
);
foreach ( $colors as $key => $label ) :
?>
<div class="m365-field">
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
<input type="text" id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s[ $key ] ); ?>" class="m365-color" data-default-color="<?php echo esc_attr( $this->settings->defaults()[ $key ] ); ?>" data-preview="<?php echo esc_attr( str_replace( 'button_', '', $key ) ); ?>" />
</div>
<?php endforeach; ?>
</div>
<div class="m365-grid">
<div class="m365-field">
<label for="m365-radius"><?php esc_html_e( 'Corner radius', 'm365-login' ); ?> <span class="m365-range-value" id="m365-radius-value"><?php echo esc_html( $s['button_radius'] ); ?> px</span></label>
<input type="range" id="m365-radius" name="<?php echo $field( 'button_radius' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_attr( $s['button_radius'] ); ?>" min="0" max="50" step="1" data-preview="radius" />
</div>
<div class="m365-field">
<label for="m365-position"><?php esc_html_e( 'Position on the login page', 'm365-login' ); ?></label>
<select id="m365-position" name="<?php echo $field( 'button_position' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<option value="below" <?php selected( $s['button_position'], 'below' ); ?>><?php esc_html_e( 'Below the login form', 'm365-login' ); ?></option>
<option value="above" <?php selected( $s['button_position'], 'above' ); ?>><?php esc_html_e( 'Above the login form', 'm365-login' ); ?></option>
</select>
</div>
</div>
<div class="m365-presets">
<span class="m365-field__label"><?php esc_html_e( 'Quick presets', 'm365-login' ); ?></span>
<button type="button" class="m365-preset" data-preset='{"bg":"#2f2f2f","bg_hover":"#1a1a1a","color":"#ffffff","border":"#2f2f2f"}'><span style="background:#2f2f2f"></span><?php esc_html_e( 'Microsoft dark', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#ffffff","bg_hover":"#f3f3f3","color":"#5e5e5e","border":"#8c8c8c"}'><span style="background:#ffffff;border-color:#8c8c8c"></span><?php esc_html_e( 'Microsoft light', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#0078d4","bg_hover":"#106ebe","color":"#ffffff","border":"#0078d4"}'><span style="background:#0078d4"></span><?php esc_html_e( 'Azure blue', 'm365-login' ); ?></button>
<button type="button" class="m365-preset" data-preset='{"bg":"#2271b1","bg_hover":"#135e96","color":"#ffffff","border":"#2271b1"}'><span style="background:#2271b1"></span><?php esc_html_e( 'WordPress blue', 'm365-login' ); ?></button>
</div>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Custom login page', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Using your own login page instead of wp-login.php? Tell the plugin where it is so error messages, the fallback link and the post-logout redirect point there.', 'm365-login' ); ?></p>
<div class="m365-field">
<label for="m365-custom-login"><?php esc_html_e( 'URL of your login page', 'm365-login' ); ?></label>
<input type="url" id="m365-custom-login" name="<?php echo $field( 'custom_login_url' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="<?php echo esc_url( $s['custom_login_url'] ); ?>" class="regular-text code" placeholder="<?php echo esc_attr( home_url( '/login/' ) ); ?>" />
<p class="description"><?php esc_html_e( 'Must be on this site. Leave empty to use wp-login.php.', 'm365-login' ); ?></p>
</div>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'inject_form' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['inject_form'] ); ?> />
<span>
<strong><?php esc_html_e( 'Add the button to every wp_login_form() form automatically', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Covers themes and plugins that use the WordPress login form function. Page-builder widgets need the shortcode or the template function below.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Manual placement', 'm365-login' ); ?></span>
<p class="description"><?php esc_html_e( 'Shortcode (block editor, page builders):', 'm365-login' ); ?></p>
<code>[m365_login_button redirect="/dashboard/" divider="yes"]</code>
<p class="description"><?php esc_html_e( 'Template function (theme files):', 'm365-login' ); ?></p>
<code>&lt;?php m365_login_button( array( 'redirect' =&gt; '/dashboard/' ) ); ?&gt;</code>
<p class="description"><?php esc_html_e( 'Both show the error messages of the last attempt; use m365_login_messages() to place them separately.', 'm365-login' ); ?></p>
</div>
</div>
</section>
<!-- Security -->
<section class="m365-admin__panel" data-panel="security">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
<span>
<strong><?php esc_html_e( 'Bind WordPress accounts to the Microsoft object ID', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended.', 'm365-login' ); ?></em>
</span>
</label>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'upn_fallback' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['upn_fallback'] ); ?> />
<span>
<strong><?php esc_html_e( 'Fall back to the user principal name (UPN)', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'If the token contains no "email" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts.', 'm365-login' ); ?></em>
</span>
</label>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'remember_me' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['remember_me'] ); ?> />
<span>
<strong><?php esc_html_e( 'Keep users signed in ("Remember me")', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Issues a 14-day WordPress session instead of a browser session.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-field">
<label for="m365-domains"><?php esc_html_e( 'Allowed e-mail domains (optional)', 'm365-login' ); ?></label>
<textarea id="m365-domains" name="<?php echo $field( 'allowed_domains' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" rows="3" class="large-text code" placeholder="contoso.com, contoso.de"><?php echo esc_textarea( $s['allowed_domains'] ); ?></textarea>
<p class="description"><?php esc_html_e( 'One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant.', 'm365-login' ); ?></p>
</div>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Allowed Entra groups (optional)', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count.', 'm365-login' ); ?></p>
<div class="m365-field">
<label for="m365-group-search"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="search" id="m365-group-search" class="regular-text" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
<button type="button" class="button" id="m365-group-search-btn" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
</div>
<?php if ( ! $configured ) : ?>
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
<?php endif; ?>
<div id="m365-group-results" class="m365-group-results" hidden></div>
</div>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
<ul id="m365-group-list" class="m365-group-list" data-empty="<?php esc_attr_e( 'No groups selected every matched user may sign in.', 'm365-login' ); ?>">
<?php foreach ( $this->settings->allowed_groups() as $gid => $gname ) : ?>
<li class="m365-group-chip" data-id="<?php echo esc_attr( $gid ); ?>">
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
<input type="hidden" name="<?php echo esc_attr( $option . '[allowed_groups][' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">&times;</button>
</li>
<?php endforeach; ?>
</ul>
<p class="description"><?php esc_html_e( 'Membership is read from the "groups" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission "User.Read.All" or "Directory.Read.All"). If neither works, the sign-in is refused.', 'm365-login' ); ?></p>
</div>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Button-only mode', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'button_only' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_only'] ); ?> id="m365-button-only" />
<span>
<strong><?php esc_html_e( 'Show only the Microsoft button on the login page', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Becomes active once the connection is configured. Make sure your own account can sign in via Microsoft before enabling this.', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-fallback">
<span class="m365-field__label"><?php esc_html_e( 'Fallback link (keep it secret)', 'm365-login' ); ?></span>
<p class="description"><?php esc_html_e( 'Opening this link shows the password form again in that browser for 30 minutes and allows password sign-in there. Bookmark it somewhere safe it is your way back in if Microsoft sign-in ever breaks.', 'm365-login' ); ?></p>
<?php if ( '' !== $this->settings->fallback_url() ) : ?>
<div class="m365-copy">
<code id="m365-fallback-url"><?php echo esc_html( $this->settings->fallback_url() ); ?></code>
<button type="button" class="button button-small m365-copy__button" data-copy="m365-fallback-url"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button>
</div>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'fallback_regenerate' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" id="m365-fallback-regenerate" />
<?php esc_html_e( 'Generate a new key when saving', 'm365-login' ); ?>
</label>
<?php else : ?>
<p class="m365-inline-result"><?php esc_html_e( 'A key is generated automatically the first time you save these settings.', 'm365-login' ); ?></p>
<?php endif; ?>
<p class="description">
<?php
printf(
/* translators: %s: PHP constant */
esc_html__( 'Emergency switch: add %s to wp-config.php to disable button-only mode entirely.', 'm365-login' ),
'<code>define( \'M365_LOGIN_DISABLE_BUTTON_ONLY\', true );</code>'
);
?>
</p>
</div>
</div>
<div class="m365-card m365-card--muted">
<h2 class="m365-card__title"><?php esc_html_e( 'What the plugin does to keep sign-ins safe', 'm365-login' ); ?></h2>
<ul class="m365-list">
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) no tokens ever pass through the browser.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'ID token signature verified against Microsofts published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li>
</ul>
</div>
</section>
<div class="m365-admin__actions">
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
</div>
</div>
<aside class="m365-admin__sidebar">
<div class="m365-card m365-card--accent">
<h2 class="m365-card__title"><?php esc_html_e( 'Redirect URI', 'm365-login' ); ?></h2>
<p><?php esc_html_e( 'Register this URI in your app registration under Authentication → Web → Redirect URIs:', 'm365-login' ); ?></p>
<div class="m365-copy">
<code id="m365-redirect-uri"><?php echo esc_html( $this->settings->redirect_uri() ); ?></code>
<button type="button" class="button button-small m365-copy__button" data-copy="m365-redirect-uri"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button>
</div>
<?php if ( ! $this->settings->uses_pretty_callback() ) : ?>
<p class="description"><?php esc_html_e( 'Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID.', 'm365-login' ); ?></p>
<?php endif; ?>
<?php if ( ! is_ssl() && 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) : ?>
<p class="m365-warning"><?php esc_html_e( 'Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS.', 'm365-login' ); ?></p>
<?php endif; ?>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Setup in 5 steps', 'm365-login' ); ?></h2>
<ol class="m365-steps">
<li><?php esc_html_e( 'Open the Microsoft Entra admin center → App registrations → New registration.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Choose "Accounts in this organizational directory only", set the platform to Web and paste the redirect URI above.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Copy the Application (client) ID and Directory (tenant) ID from the overview page.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Under Certificates & secrets create a client secret and copy its value (not the ID).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Under Token configuration add the optional claim "email" for ID tokens (recommended), then save this page.', 'm365-login' ); ?></li>
</ol>
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
</div>
<div class="m365-card m365-card--muted">
<h2 class="m365-card__title"><?php esc_html_e( 'Shortcode', 'm365-login' ); ?></h2>
<p><?php esc_html_e( 'Place the button on a custom login page:', 'm365-login' ); ?></p>
<code>[m365_login_button redirect="/my-account/"]</code>
<p class="description"><?php esc_html_e( 'More options on the Button tab under "Custom login page".', 'm365-login' ); ?></p>
</div>
</aside>
</div>
</form>
</div>
<?php
}
}