wp-m365-login/includes/class-m365-login-admin.php
friloo 662756bc75
Move the settings screen to its own top-level menu
Adds an "M365 Login" menu entry with a Microsoft-style icon and one
submenu per tab (Connection, Button, Security). The active tab is taken
from the URL, kept in the post-save redirect and highlighted in the
submenu. Notices are printed explicitly because top-level pages do not
include options-head.php. Links, docs and screenshots updated.

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

864 lines
51 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 AJAX_CERT = 'm365_login_certificate';
const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
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( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
add_filter( 'submenu_file', array( $this, 'highlight_submenu' ) );
}
/**
* Adds a top-level menu entry with one submenu per tab.
*/
public function menu() {
$this->hook = add_menu_page(
__( 'M365 Login', 'm365-login' ),
__( 'M365 Login', 'm365-login' ),
'manage_options',
self::PAGE,
array( $this, 'render' ),
self::MENU_ICON,
81
);
foreach ( self::tabs() as $tab => $label ) {
add_submenu_page(
self::PAGE,
$label . ' ' . __( 'M365 Login', 'm365-login' ),
$label,
'manage_options',
'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab
);
}
}
/**
* Tab slugs and labels.
*
* @return string[]
*/
public static function tabs() {
return array(
'connection' => __( 'Connection', 'm365-login' ),
'button' => __( 'Button', 'm365-login' ),
'security' => __( 'Security', 'm365-login' ),
);
}
/**
* Currently requested tab (from ?tab=).
*
* @return string
*/
private function current_tab() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only UI state.
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
return array_key_exists( $tab, self::tabs() ) ? $tab : '';
}
/**
* Highlights the submenu entry matching the requested tab.
*
* @param string|null $submenu_file Current submenu file.
* @return string|null
*/
public function highlight_submenu( $submenu_file ) {
$screen = get_current_screen();
if ( ! $screen || $this->hook !== $screen->id ) {
return $submenu_file;
}
$tab = $this->current_tab();
if ( '' === $tab || 'connection' === $tab ) {
return self::PAGE;
}
return 'admin.php?page=' . self::PAGE . '&tab=' . $tab;
}
/**
* URL of the settings screen (optionally a specific tab).
*
* @param string $tab Tab slug.
* @return string
*/
public static function url( $tab = '' ) {
$url = admin_url( 'admin.php?page=' . self::PAGE );
return '' === $tab ? $url : add_query_arg( 'tab', $tab, $url );
}
/**
* 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( self::url() ),
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,
'certAction' => self::AJAX_CERT,
'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' ),
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method 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 ) );
}
/**
* AJAX: generate a new self-signed certificate and store it (key encrypted).
*/
public function ajax_certificate() {
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 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
if ( 'generate' !== $op ) {
wp_send_json_error( array( 'message' => __( 'Unknown operation.', 'm365-login' ) ) );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$pair = M365_Login_Certificate::generate( is_string( $host ) ? $host : 'wordpress' );
if ( is_wp_error( $pair ) ) {
wp_send_json_error( array( 'message' => $pair->get_error_message() ) );
}
$stored = $this->settings->store_certificate( $pair );
if ( is_wp_error( $stored ) ) {
wp_send_json_error( array( 'message' => $stored->get_error_message() ) );
}
$this->graph->flush_token();
$info = M365_Login_Certificate::info( $pair['certificate'] );
wp_send_json_success(
array(
'message' => __( 'Certificate generated and stored. Download the .cer file and upload it in Entra ID.', 'm365-login' ),
'thumbprint' => $info ? $info['thumbprint'] : '',
)
);
}
/**
* Sends the public certificate as a .cer download (never the private key).
*/
public function download_certificate() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
check_admin_referer( self::POST_CERT );
$pem = $this->settings->certificate_pem();
if ( '' === $pem ) {
wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 );
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$name = 'm365-login-' . sanitize_file_name( is_string( $host ) ? $host : 'wordpress' ) . '.cer';
nocache_headers();
header( 'Content-Type: application/x-x509-ca-cert' );
header( 'Content-Disposition: attachment; filename="' . $name . '"' );
header( 'Content-Length: ' . strlen( $pem ) );
echo $pem; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PEM text, public certificate only.
exit;
}
/**
* 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();
$method = $this->settings->auth_method();
$cert_info = $this->settings->certificate_info();
$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>
<?php settings_errors(); ?>
<form method="post" action="options.php" class="m365-admin__form" novalidate>
<?php settings_fields( self::GROUP ); ?>
<input type="hidden" name="_wp_http_referer" value="<?php echo esc_attr( self::url( $this->current_tab() ) ); ?>" id="m365-referer" />
<nav class="m365-admin__tabs" role="tablist" data-initial-tab="<?php echo esc_attr( $this->current_tab() ); ?>">
<?php foreach ( self::tabs() as $tab => $label ) : ?>
<button type="button" class="m365-admin__tab<?php echo 'connection' === $tab ? ' is-active' : ''; ?>" role="tab" data-tab="<?php echo esc_attr( $tab ); ?>" aria-selected="<?php echo 'connection' === $tab ? 'true' : 'false'; ?>"><?php echo esc_html( $label ); ?></button>
<?php endforeach; ?>
</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>
<?php if ( $this->settings->is_multi_tenant() && '' !== $s['tenant_id'] ) : ?>
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their "email" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID.', 'm365-login' ); ?></p>
<?php endif; ?>
<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">
<span class="m365-field__label"><?php esc_html_e( 'How should WordPress authenticate to Microsoft?', 'm365-login' ); ?></span>
<div class="m365-method">
<label class="m365-method__option" data-method="secret">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="secret" <?php checked( $method, 'secret' ); ?> />
<span>
<strong><?php esc_html_e( 'Client secret', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Quick to set up. A password-like value created in Entra ID that expires after 624 months and must be renewed.', 'm365-login' ); ?></em>
</span>
</label>
<label class="m365-method__option" data-method="certificate">
<input type="radio" name="<?php echo $field( 'auth_method' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="certificate" <?php checked( $method, 'certificate' ); ?> />
<span>
<strong><?php esc_html_e( 'Certificate', 'm365-login' ); ?><span class="m365-method__badge"><?php esc_html_e( 'Recommended', 'm365-login' ); ?></span></strong>
<em><?php esc_html_e( 'The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years.', 'm365-login' ); ?></em>
</span>
</label>
</div>
</div>
<!-- Secret -->
<div class="m365-auth-panel" data-method="secret">
<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>
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
<summary><?php esc_html_e( 'Step-by-step: create a client secret in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Open entra.microsoft.com and sign in with an account that has the "Application Administrator" or "Global Administrator" role.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Enter a description such as "WordPress login" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Click Add. Copy the Value column immediately it is shown only once. The Secret ID column is NOT what you need.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Paste the value into the Client secret field above and save this page.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'When the secret expires, sign-ins fail with "Could not complete the sign-in with Microsoft". Create a new secret, paste it here, save, then delete the old one in Entra ID.', 'm365-login' ); ?></p>
</div>
</details>
</div>
<!-- Certificate -->
<div class="m365-auth-panel" data-method="certificate">
<div class="m365-cert">
<?php if ( $cert_info ) : ?>
<?php
$days_left = (int) floor( ( $cert_info['not_after'] - time() ) / DAY_IN_SECONDS );
if ( $days_left < 0 ) {
$status_class = 'is-bad';
$status_text = __( 'Expired', 'm365-login' );
} elseif ( $days_left < 30 ) {
$status_class = 'is-warn';
/* translators: %d: number of days */
$status_text = sprintf( __( 'Expires in %d days', 'm365-login' ), $days_left );
} else {
$status_class = 'is-ok';
$status_text = __( 'Valid', 'm365-login' );
}
?>
<span class="m365-cert__status <?php echo esc_attr( $status_class ); ?>"><?php echo esc_html( $status_text ); ?></span>
<dl class="m365-cert__grid">
<dt><?php esc_html_e( 'Thumbprint (SHA-1)', 'm365-login' ); ?></dt>
<dd><code id="m365-cert-thumbprint"><?php echo esc_html( $cert_info['thumbprint'] ); ?></code> <button type="button" class="button button-small m365-copy__button" data-copy="m365-cert-thumbprint"><?php esc_html_e( 'Copy', 'm365-login' ); ?></button></dd>
<dt><?php esc_html_e( 'Subject', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['subject'] ); ?></dd>
<dt><?php esc_html_e( 'Key size', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( $cert_info['bits'] ); ?> Bit RSA</dd>
<dt><?php esc_html_e( 'Valid until', 'm365-login' ); ?></dt>
<dd><?php echo esc_html( wp_date( get_option( 'date_format' ), $cert_info['not_after'] ) ); ?></dd>
</dl>
<div class="m365-cert__actions">
<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=' . self::POST_CERT ), self::POST_CERT ) ); ?>"><?php esc_html_e( 'Download certificate (.cer)', 'm365-login' ); ?></a>
<button type="button" class="button" id="m365-cert-generate" data-replace="1"><?php esc_html_e( 'Generate new certificate', 'm365-login' ); ?></button>
<label class="m365-check m365-check--inline">
<input type="checkbox" name="<?php echo $field( 'cert_remove' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" id="m365-cert-remove" />
<?php esc_html_e( 'Remove certificate when saving', 'm365-login' ); ?>
</label>
</div>
<?php else : ?>
<p class="m365-cert__empty"><?php esc_html_e( 'No certificate stored yet.', 'm365-login' ); ?></p>
<div class="m365-cert__actions">
<button type="button" class="button button-primary" id="m365-cert-generate"><?php esc_html_e( 'Generate certificate', 'm365-login' ); ?></button>
<span class="description"><?php esc_html_e( '3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable.', 'm365-login' ); ?></span>
</div>
<?php endif; ?>
<div id="m365-cert-result" class="m365-inline-result" hidden></div>
<p class="description" style="margin-top:12px"><a href="#" id="m365-cert-paste-toggle"><?php esc_html_e( 'Use your own certificate instead (paste PEM)', 'm365-login' ); ?></a></p>
<div id="m365-cert-paste" hidden>
<div class="m365-field">
<label for="m365-cert-key"><?php esc_html_e( 'Private key (PEM, unencrypted)', 'm365-login' ); ?></label>
<textarea id="m365-cert-key" name="<?php echo $field( 'cert_key_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div>
<div class="m365-field">
<label for="m365-cert-cert"><?php esc_html_e( 'Certificate (PEM)', 'm365-login' ); ?></label>
<textarea id="m365-cert-cert" name="<?php echo $field( 'cert_cert_pem' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" class="large-text m365-pem" rows="6" placeholder="-----BEGIN CERTIFICATE-----" spellcheck="false"></textarea>
<p class="description"><?php esc_html_e( 'RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards.', 'm365-login' ); ?></p>
</div>
</div>
</div>
<details class="m365-guide" <?php echo $cert_info ? 'open' : ''; ?>>
<summary><?php esc_html_e( 'Step-by-step: register the certificate in Entra ID', 'm365-login' ); ?></summary>
<div class="m365-guide__body">
<ol>
<li><?php esc_html_e( 'Click Generate certificate above (or paste your own). Then click Download certificate (.cer) the file contains only the public part.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations and open your app.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Select the downloaded .cer file, add a description such as "WordPress login" and click Add.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Compare the thumbprint Entra ID shows with the thumbprint above they must match exactly.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now.', 'm365-login' ); ?></li>
</ol>
<p class="m365-guide__note"><?php esc_html_e( 'How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted.', 'm365-login' ); ?></p>
<p class="m365-guide__warn"><?php esc_html_e( 'Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch.', 'm365-login' ); ?></p>
</div>
</details>
</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 guide: app registration', 'm365-login' ); ?></h2>
<ol class="m365-steps">
<li><?php esc_html_e( 'Open entra.microsoft.com → Identity → Applications → App registrations → New registration.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Name: e.g. "WordPress login". Supported account types: "Accounts in this organizational directory only" (single tenant).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Redirect URI: choose the platform Web and paste the URI shown above. Then click Register.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Authentication: leave "ID tokens" unchecked (the plugin uses the authorization code flow) and "Allow public client flows" on No.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Token configuration → Add optional claim → ID → tick "email" → Add. Confirm the API permission prompt.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', '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
}
}