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(
'
',
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(
'',
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 '' . esc_html__( 'The sync has not run yet.', 'm365-login' ) . '
';
}
$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();
?>
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 );
?>
$value ) : ?>
↑
→
role_options( $value['role'] ); ?>
×
role_options( 'editor' ); ?>
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(
'%s ',
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 );
$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' ),
);
?>
$label ) : ?>
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,
)
);
?>
>
>
report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?>
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 . ']' );
};
?>