- Privileged accounts: the UPN rule also applies when bind_oid is off or the account is not bound; privileges are checked on every site of a multisite user, include code/HTML capabilities (unfiltered_html, plugins, themes, users) and the remembered roles of deactivated accounts. - send_auth_cookies protection also works on WordPress 6.0/6.1. - Run lock via INSERT IGNORE (atomic), refreshed during long runs; a shutdown handler reports fatal errors and frees the lock. - Deprovisioning only for accounts linked in the current tenant (tenant recorded per account; legacy links not found are left alone). - Safety stop based on the accounts linked before the run; new safety stop for removals of administrative roles. - Disable is idempotent; row-action nonces are bound to the state. - Profile photos are re-encoded to 240 px (drops EXIF and appended data), size-limited while downloading, removed on deactivation; index.php guard in the photo folder. - Privacy exporter and eraser for the copied data. - One-time migration hardens accounts deactivated by 1.0 and cleans a stored certificate bundle; the .cer download is always re-exported. - Password fields hidden in button-only mode even when the connection is broken; settings written non-autoloaded; robust user ID queries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1255 lines
75 KiB
PHP
1255 lines
75 KiB
PHP
<?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 AJAX_SYNC = 'm365_login_sync_run';
|
||
const POST_CERT = 'm365_login_download_cert';
|
||
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
|
||
const NONCE_TEST = 'm365_login_test';
|
||
const NONCE_GROUPS = 'm365_login_groups';
|
||
const NONCE_CERT = 'm365_login_cert';
|
||
const NONCE_SYNC = 'm365_login_sync';
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* User sync.
|
||
*
|
||
* @var M365_Login_Sync
|
||
*/
|
||
private $sync;
|
||
|
||
/**
|
||
* 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.
|
||
* @param M365_Login_Sync $sync User sync.
|
||
*/
|
||
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph, M365_Login_Sync $sync ) {
|
||
$this->settings = $settings;
|
||
$this->auth = $auth;
|
||
$this->graph = $graph;
|
||
$this->sync = $sync;
|
||
|
||
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( 'wp_ajax_' . self::AJAX_SYNC, array( $this, 'ajax_sync' ) );
|
||
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' ) );
|
||
add_filter( 'option_page_capability_' . self::GROUP, array( __CLASS__, 'capability' ) );
|
||
}
|
||
|
||
/**
|
||
* Capability needed for the settings, the connection test, certificates and the user sync.
|
||
*
|
||
* On multisite only super admins: the settings decide which Microsoft identities may sign in
|
||
* as which (network-wide) WordPress users, so a site administrator must not control them.
|
||
*
|
||
* @return string
|
||
*/
|
||
public static function capability() {
|
||
return is_multisite() ? 'manage_network_options' : 'manage_options';
|
||
}
|
||
|
||
/**
|
||
* 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' ),
|
||
self::capability(),
|
||
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,
|
||
self::capability(),
|
||
'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' ),
|
||
'sync' => __( 'User sync', '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 ( ! current_user_can( self::capability() ) ) {
|
||
return;
|
||
}
|
||
if ( ! $this->settings->is_configured() && $this->settings->button_only() ) {
|
||
printf(
|
||
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||
esc_html__( 'M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link.', 'm365-login' ),
|
||
esc_url( self::url() ),
|
||
esc_html__( 'Open the settings', 'm365-login' )
|
||
);
|
||
return;
|
||
}
|
||
if ( $this->settings->is_configured() ) {
|
||
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' ),
|
||
'nonces' => array(
|
||
'test' => wp_create_nonce( self::NONCE_TEST ),
|
||
'groups' => wp_create_nonce( self::NONCE_GROUPS ),
|
||
'cert' => wp_create_nonce( self::NONCE_CERT ),
|
||
'sync' => wp_create_nonce( self::NONCE_SYNC ),
|
||
),
|
||
'action' => self::AJAX_TEST,
|
||
'groupAction' => self::AJAX_GROUPS,
|
||
'certAction' => self::AJAX_CERT,
|
||
'syncAction' => self::AJAX_SYNC,
|
||
'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 server’s 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' ),
|
||
'syncRunning' => __( 'Sync is running, this can take a while for large directories…', 'm365-login' ),
|
||
'confirmSync' => __( 'Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first.', 'm365-login' ),
|
||
'syncFailed' => __( 'The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use "wp m365-login sync" (WP-CLI).', 'm365-login' ),
|
||
'unsaved' => __( 'You have unsaved changes. The sync uses the saved settings – save first.', 'm365-login' ),
|
||
'moveUp' => __( 'Move up', '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( self::capability() ) ) {
|
||
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_GROUPS, 'nonce' );
|
||
if ( ! current_user_can( self::capability() ) ) {
|
||
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_CERT, 'nonce' );
|
||
if ( ! current_user_can( self::capability() ) ) {
|
||
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'] : '',
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* AJAX: run the user sync (dry run or live) with the saved settings.
|
||
*/
|
||
public function ajax_sync() {
|
||
check_ajax_referer( self::NONCE_SYNC, 'nonce' );
|
||
if ( ! current_user_can( self::capability() ) || ! current_user_can( 'create_users' ) ) {
|
||
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'] ) ) : '';
|
||
$report = $this->sync->run( 'live' !== $op, 'manual' );
|
||
wp_send_json_success( array( 'html' => $this->report_markup( $report ) ) );
|
||
}
|
||
|
||
/**
|
||
* Markup of a sync report.
|
||
*
|
||
* @param array|null $report Report.
|
||
* @return string
|
||
*/
|
||
private function report_markup( $report ) {
|
||
if ( ! is_array( $report ) ) {
|
||
return '<p class="m365-sync-report__empty">' . esc_html__( 'The sync has not run yet.', 'm365-login' ) . '</p>';
|
||
}
|
||
|
||
$statuses = array(
|
||
'ok' => __( 'Finished', 'm365-login' ),
|
||
'failed' => __( 'Failed', 'm365-login' ),
|
||
'aborted' => __( 'Stopped by the safety limit', 'm365-login' ),
|
||
'locked' => __( 'Not started', 'm365-login' ),
|
||
);
|
||
$triggers = array(
|
||
'manual' => __( 'started manually', 'm365-login' ),
|
||
'cron' => __( 'scheduled', 'm365-login' ),
|
||
'cli' => __( 'WP-CLI', 'm365-login' ),
|
||
);
|
||
$labels = array(
|
||
'created' => $report['dry'] ? __( 'would be created', 'm365-login' ) : __( 'created', 'm365-login' ),
|
||
'updated' => $report['dry'] ? __( 'would be updated', 'm365-login' ) : __( 'updated', 'm365-login' ),
|
||
'linked' => $report['dry'] ? __( 'would be linked', 'm365-login' ) : __( 'linked', 'm365-login' ),
|
||
'unchanged' => __( 'unchanged', 'm365-login' ),
|
||
'disabled' => $report['dry'] ? __( 'would be deactivated', 'm365-login' ) : __( 'deactivated', 'm365-login' ),
|
||
'enabled' => $report['dry'] ? __( 'would be reactivated', 'm365-login' ) : __( 'reactivated', 'm365-login' ),
|
||
'deleted' => $report['dry'] ? __( 'would be deleted', 'm365-login' ) : __( 'deleted', 'm365-login' ),
|
||
'photos' => __( 'photos', 'm365-login' ),
|
||
'skipped' => __( 'skipped', 'm365-login' ),
|
||
'errors' => __( 'errors', 'm365-login' ),
|
||
);
|
||
|
||
$status = isset( $statuses[ $report['status'] ] ) ? $statuses[ $report['status'] ] : $report['status'];
|
||
$class = 'ok' === $report['status'] ? 'is-ok' : 'is-bad';
|
||
$when = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $report['started'] );
|
||
|
||
ob_start();
|
||
?>
|
||
<div class="m365-sync-report">
|
||
<p class="m365-sync-report__head">
|
||
<span class="m365-sync-report__status <?php echo esc_attr( $class ); ?>"><?php echo esc_html( $status ); ?></span>
|
||
<?php if ( $report['dry'] ) : ?>
|
||
<span class="m365-sync-report__dry"><?php esc_html_e( 'Dry run – nothing was changed', 'm365-login' ); ?></span>
|
||
<?php endif; ?>
|
||
<span class="m365-sync-report__meta">
|
||
<?php
|
||
/* translators: 1: date and time, 2: how the run was started, 3: duration in seconds */
|
||
echo esc_html( sprintf( __( '%1$s, %2$s, %3$d s', 'm365-login' ), $when, isset( $triggers[ $report['trigger'] ] ) ? $triggers[ $report['trigger'] ] : $report['trigger'], max( 0, (int) $report['finished'] - (int) $report['started'] ) ) );
|
||
?>
|
||
</span>
|
||
</p>
|
||
<ul class="m365-sync-report__counts">
|
||
<?php foreach ( $labels as $key => $label ) : ?>
|
||
<?php
|
||
$count = isset( $report['counts'][ $key ] ) ? (int) $report['counts'][ $key ] : 0;
|
||
if ( 0 === $count && ! in_array( $key, array( 'created', 'updated', 'disabled' ), true ) ) {
|
||
continue;
|
||
}
|
||
?>
|
||
<li class="m365-sync-report__count m365-sync-report__count--<?php echo esc_attr( $key ); ?>"><strong><?php echo esc_html( number_format_i18n( $count ) ); ?></strong> <?php echo esc_html( $label ); ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php if ( ! empty( $report['log'] ) ) : ?>
|
||
<details class="m365-sync-report__details" <?php echo 'ok' !== $report['status'] || $report['dry'] ? 'open' : ''; ?>>
|
||
<?php /* translators: %d: number of log entries */ ?>
|
||
<summary><?php echo esc_html( sprintf( _n( 'Log (%d entry)', 'Log (%d entries)', count( $report['log'] ), 'm365-login' ), count( $report['log'] ) ) ); ?></summary>
|
||
<ul class="m365-sync-log">
|
||
<?php foreach ( $report['log'] as $entry ) : ?>
|
||
<li class="m365-sync-log__item is-<?php echo esc_attr( $entry['level'] ); ?>"><?php echo esc_html( $entry['message'] ); ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
</details>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php
|
||
return (string) ob_get_clean();
|
||
}
|
||
|
||
/**
|
||
* Renders an Entra group picker.
|
||
*
|
||
* @param string $key Option key the picker writes to.
|
||
* @param array $selected Selected groups: id => name (chips) or id => array( name, role ) (roles).
|
||
* @param string $mode 'chips' or 'roles'.
|
||
* @param string $empty_text Text shown when nothing is selected.
|
||
*/
|
||
private function group_picker( $key, $selected, $mode, $empty_text ) {
|
||
$configured = $this->settings->is_configured();
|
||
$base = M365_LOGIN_OPTION . '[' . $key . ']';
|
||
$id = 'm365-picker-' . str_replace( '_', '-', $key );
|
||
?>
|
||
<div class="m365-picker" data-field="<?php echo esc_attr( $key ); ?>" data-mode="<?php echo esc_attr( $mode ); ?>">
|
||
<div class="m365-field">
|
||
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
|
||
<div class="m365-field__row">
|
||
<input type="search" id="<?php echo esc_attr( $id ); ?>" class="regular-text m365-picker__query" 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 m365-picker__search" <?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 class="m365-group-results m365-picker__results" hidden></div>
|
||
</div>
|
||
|
||
<div class="m365-field">
|
||
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
|
||
<ul class="m365-group-list m365-picker__list<?php echo 'roles' === $mode ? ' m365-group-list--roles' : ''; ?>" data-empty="<?php echo esc_attr( $empty_text ); ?>">
|
||
<?php foreach ( $selected as $gid => $value ) : ?>
|
||
<?php $gname = 'roles' === $mode ? $value['name'] : $value; ?>
|
||
<li class="m365-group-chip<?php echo 'roles' === $mode ? ' m365-group-chip--role' : ''; ?>" data-id="<?php echo esc_attr( $gid ); ?>">
|
||
<?php if ( 'roles' === $mode ) : ?>
|
||
<button type="button" class="m365-group-chip__up" aria-label="<?php esc_attr_e( 'Move up', 'm365-login' ); ?>">↑</button>
|
||
<?php endif; ?>
|
||
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
|
||
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
|
||
<?php if ( 'roles' === $mode ) : ?>
|
||
<span class="m365-group-chip__arrow" aria-hidden="true">→</span>
|
||
<select name="<?php echo esc_attr( $base . '[' . $gid . '][role]' ); ?>" aria-label="<?php esc_attr_e( 'WordPress role', 'm365-login' ); ?>">
|
||
<?php $this->role_options( $value['role'] ); ?>
|
||
</select>
|
||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||
<?php else : ?>
|
||
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
|
||
<?php endif; ?>
|
||
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">×</button>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php if ( 'roles' === $mode ) : ?>
|
||
<select class="m365-picker__role-template" hidden disabled>
|
||
<?php $this->role_options( 'editor' ); ?>
|
||
</select>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php
|
||
}
|
||
|
||
/**
|
||
* Prints <option> elements for all editable roles (escaped, unlike wp_dropdown_roles()).
|
||
*
|
||
* @param string $selected Selected role slug.
|
||
*/
|
||
private function role_options( $selected ) {
|
||
if ( ! function_exists( 'get_editable_roles' ) ) {
|
||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||
}
|
||
foreach ( array_reverse( get_editable_roles() ) as $role => $details ) {
|
||
printf(
|
||
'<option value="%s"%s>%s</option>',
|
||
esc_attr( $role ),
|
||
selected( $selected, $role, false ),
|
||
esc_html( translate_user_role( $details['name'] ) )
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sends the public certificate as a .cer download (never the private key).
|
||
*/
|
||
public function download_certificate() {
|
||
if ( ! current_user_can( self::capability() ) ) {
|
||
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
|
||
}
|
||
check_admin_referer( self::POST_CERT );
|
||
|
||
// Only ever the re-exported public certificate, whatever an older version stored.
|
||
$pem = M365_Login_Certificate::clean_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 "User sync" tab.
|
||
*
|
||
* @param array $s Settings.
|
||
*/
|
||
private function render_sync_panel( $s ) {
|
||
$field = function ( $key ) {
|
||
return esc_attr( M365_LOGIN_OPTION . '[' . $key . ']' );
|
||
};
|
||
$configured = $this->settings->is_configured();
|
||
$next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK );
|
||
$actions = array(
|
||
'none' => __( 'Do nothing', 'm365-login' ),
|
||
'disable' => __( 'Deactivate the WordPress account', 'm365-login' ),
|
||
'delete' => __( 'Delete the WordPress account', 'm365-login' ),
|
||
);
|
||
$selects = array(
|
||
'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ),
|
||
'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ),
|
||
'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ),
|
||
);
|
||
?>
|
||
<section class="m365-admin__panel" data-panel="sync">
|
||
<div class="m365-card">
|
||
<h2 class="m365-card__title"><?php esc_html_e( 'Import users from Microsoft 365', 'm365-login' ); ?></h2>
|
||
<p class="m365-card__intro"><?php esc_html_e( 'Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button.', 'm365-login' ); ?></p>
|
||
|
||
<label class="m365-check m365-check--block">
|
||
<input type="checkbox" name="<?php echo $field( 'sync_enabled' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_enabled'] ); ?> />
|
||
<span>
|
||
<strong><?php esc_html_e( 'Run the sync automatically', 'm365-login' ); ?></strong>
|
||
<em><?php esc_html_e( 'Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run "wp m365-login sync".', 'm365-login' ); ?></em>
|
||
</span>
|
||
</label>
|
||
|
||
<div class="m365-grid">
|
||
<div class="m365-field">
|
||
<label for="m365-sync-interval"><?php esc_html_e( 'Interval', 'm365-login' ); ?></label>
|
||
<select id="m365-sync-interval" name="<?php echo $field( 'sync_interval' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||
<option value="hourly" <?php selected( $s['sync_interval'], 'hourly' ); ?>><?php esc_html_e( 'Hourly', 'm365-login' ); ?></option>
|
||
<option value="twicedaily" <?php selected( $s['sync_interval'], 'twicedaily' ); ?>><?php esc_html_e( 'Twice daily', 'm365-login' ); ?></option>
|
||
<option value="daily" <?php selected( $s['sync_interval'], 'daily' ); ?>><?php esc_html_e( 'Daily', 'm365-login' ); ?></option>
|
||
</select>
|
||
<?php if ( $s['sync_enabled'] && $next ) : ?>
|
||
<?php /* translators: %s: date and time */ ?>
|
||
<p class="description"><?php echo esc_html( sprintf( __( 'Next run: %s', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) ) ); ?></p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<label class="m365-check m365-check--block">
|
||
<input type="checkbox" name="<?php echo $field( 'sync_guests' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_guests'] ); ?> />
|
||
<span>
|
||
<strong><?php esc_html_e( 'Also import guest users (B2B)', 'm365-login' ); ?></strong>
|
||
<em><?php esc_html_e( 'Guests are external people invited into your tenant. Off by default.', 'm365-login' ); ?></em>
|
||
</span>
|
||
</label>
|
||
|
||
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Which users? (optional)', 'm365-login' ); ?></h3>
|
||
<p class="description"><?php esc_html_e( 'Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well.', 'm365-login' ); ?></p>
|
||
<?php $this->group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected – all users of the tenant are imported.', 'm365-login' ) ); ?>
|
||
</div>
|
||
|
||
<div class="m365-card">
|
||
<h2 class="m365-card__title"><?php esc_html_e( 'Roles', 'm365-login' ); ?></h2>
|
||
|
||
<div class="m365-field">
|
||
<label for="m365-sync-role"><?php esc_html_e( 'Default role', 'm365-login' ); ?></label>
|
||
<select id="m365-sync-role" name="<?php echo $field( 'sync_default_role' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||
<?php $this->role_options( $s['sync_default_role'] ); ?>
|
||
</select>
|
||
<p class="description"><?php esc_html_e( 'Every imported user gets this role. The sync manages the roles of imported accounts – manual role changes are overwritten on the next run.', 'm365-login' ); ?></p>
|
||
</div>
|
||
|
||
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Additional roles from Microsoft 365 groups', 'm365-login' ); ?></h3>
|
||
<p class="description"><?php esc_html_e( 'Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync.', 'm365-login' ); ?></p>
|
||
<p class="m365-warning"><?php esc_html_e( 'Whoever can change a group\'s members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) – never public Microsoft 365 groups or Teams, which members can join themselves.', 'm365-login' ); ?></p>
|
||
<?php $this->group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?>
|
||
|
||
<div class="m365-field">
|
||
<span class="m365-field__label"><?php esc_html_e( 'How are mapped roles applied?', 'm365-login' ); ?></span>
|
||
<label class="m365-check">
|
||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="add" <?php checked( $s['sync_role_mode'], 'add' ); ?> />
|
||
<?php esc_html_e( 'In addition to the default role (a user can have several roles)', 'm365-login' ); ?>
|
||
</label>
|
||
<label class="m365-check">
|
||
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="replace" <?php checked( $s['sync_role_mode'], 'replace' ); ?> />
|
||
<?php esc_html_e( 'Instead of the default role – the first matching group in the list wins (use ↑ to reorder)', 'm365-login' ); ?>
|
||
</label>
|
||
</div>
|
||
|
||
<label class="m365-check m365-check--block">
|
||
<input type="checkbox" name="<?php echo $field( 'sync_manage_existing' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_manage_existing'] ); ?> />
|
||
<span>
|
||
<strong><?php esc_html_e( 'Also manage the roles of accounts that existed before the sync', 'm365-login' ); ?></strong>
|
||
<em><?php esc_html_e( 'Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed.', 'm365-login' ); ?></em>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="m365-card">
|
||
<h2 class="m365-card__title"><?php esc_html_e( 'Profile fields', 'm365-login' ); ?></h2>
|
||
<p class="m365-card__intro"><?php esc_html_e( 'Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with "m365_" – usable by themes and other plugins – and is shown on the profile screen.', 'm365-login' ); ?></p>
|
||
<div class="m365-attributes">
|
||
<?php foreach ( M365_Login_Sync::attributes() as $key => $attribute ) : ?>
|
||
<label class="m365-check">
|
||
<input type="checkbox" name="<?php echo $field( 'sync_attributes' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>[]" value="<?php echo esc_attr( $key ); ?>" <?php checked( in_array( $key, (array) $s['sync_attributes'], true ) ); ?> />
|
||
<span><?php echo esc_html( $attribute['label'] ); ?> <code><?php echo esc_html( $key ); ?></code></span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay).', 'm365-login' ); ?></p>
|
||
</div>
|
||
|
||
<div class="m365-card">
|
||
<h2 class="m365-card__title"><?php esc_html_e( 'Disabled and deleted Microsoft 365 accounts', 'm365-login' ); ?></h2>
|
||
<p class="m365-card__intro"><?php esc_html_e( 'Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account.', 'm365-login' ); ?></p>
|
||
|
||
<?php foreach ( $selects as $key => $label ) : ?>
|
||
<div class="m365-field">
|
||
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
|
||
<select id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
|
||
<?php foreach ( $actions as $value => $text ) : ?>
|
||
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $s[ $key ], $value ); ?>><?php echo esc_html( $text ); ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<?php if ( 'sync_scope_action' === $key ) : ?>
|
||
<p class="description"><?php esc_html_e( 'Only relevant when the import is limited to groups.', 'm365-login' ); ?></p>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
|
||
<div class="m365-field">
|
||
<label for="m365-sync-reassign"><?php esc_html_e( 'Posts of deleted accounts go to', 'm365-login' ); ?></label>
|
||
<?php
|
||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core function, escapes its output.
|
||
echo wp_dropdown_users(
|
||
array(
|
||
'name' => M365_LOGIN_OPTION . '[sync_reassign]',
|
||
'id' => 'm365-sync-reassign',
|
||
'selected' => (int) $s['sync_reassign'],
|
||
'show_option_none' => __( '— Select a user —', 'm365-login' ),
|
||
'option_none_value' => 0,
|
||
'capability' => array( 'edit_posts' ),
|
||
'echo' => false,
|
||
)
|
||
);
|
||
?>
|
||
<p class="description"><?php esc_html_e( 'Required for "Delete". Without a user, accounts are deactivated instead, so no content is ever lost.', 'm365-login' ); ?></p>
|
||
</div>
|
||
|
||
<p class="m365-warning"><?php esc_html_e( 'Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated.', 'm365-login' ); ?></p>
|
||
</div>
|
||
|
||
<div class="m365-card m365-card--accent">
|
||
<h2 class="m365-card__title"><?php esc_html_e( 'Run the sync', 'm365-login' ); ?></h2>
|
||
<p><?php esc_html_e( 'The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything.', 'm365-login' ); ?></p>
|
||
<div class="m365-field__row">
|
||
<button type="button" class="button button-primary m365-sync-run" data-op="dry" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Dry run', 'm365-login' ); ?></button>
|
||
<button type="button" class="button m365-sync-run" data-op="live" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Sync now', 'm365-login' ); ?></button>
|
||
</div>
|
||
<p class="description"><?php esc_html_e( 'Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used.', 'm365-login' ); ?></p>
|
||
<div id="m365-sync-report" class="m365-sync-report-wrap">
|
||
<?php echo $this->report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<?php
|
||
}
|
||
|
||
/**
|
||
* Renders the settings screen.
|
||
*/
|
||
public function render() {
|
||
if ( ! current_user_can( self::capability() ) ) {
|
||
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 6–24 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>
|
||
<?php if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) ) : ?>
|
||
<p class="m365-warning"><?php esc_html_e( 'AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database – right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective.', 'm365-login' ); ?></p>
|
||
<?php endif; ?>
|
||
</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><?php m365_login_button( array( 'redirect' => '/dashboard/' ) ); ?></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( 'Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync.', '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>
|
||
|
||
<?php $this->group_picker( 'allowed_groups', $this->settings->allowed_groups(), 'chips', __( 'No groups selected – every matched user may sign in.', 'm365-login' ) ); ?>
|
||
<div class="m365-field">
|
||
<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( 'Excluded Entra groups (optional)', 'm365-login' ); ?></h2>
|
||
<p class="m365-card__intro"><?php esc_html_e( 'Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count.', 'm365-login' ); ?></p>
|
||
|
||
<?php if ( $this->settings->is_multi_tenant() ) : ?>
|
||
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above.', 'm365-login' ); ?></p>
|
||
<?php endif; ?>
|
||
<?php $this->group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>
|
||
<div class="m365-field">
|
||
<p class="description"><?php esc_html_e( 'The plugin asks Microsoft Graph on every sign-in (application permission "User.Read.All" or "Directory.Read.All"), because a "groups" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed.', '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 sign-in with a normal password on the site – custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie.', '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 Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
|
||
<li><?php esc_html_e( 'Client secret encrypted at rest; sign-in never creates accounts or changes passwords.', 'm365-login' ); ?></li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<?php $this->render_sync_panel( $s ); ?>
|
||
|
||
<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 and the user sync: 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
|
||
}
|
||
}
|