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' ) ); } /** * Adds a top-level menu entry with one submenu per tab. */ public function menu() { $this->hook = add_menu_page( __( 'M365 Login', 'm365-login' ), __( 'M365 Login', 'm365-login' ), 'manage_options', self::PAGE, array( $this, 'render' ), self::MENU_ICON, 81 ); foreach ( self::tabs() as $tab => $label ) { add_submenu_page( self::PAGE, $label . ' – ' . __( 'M365 Login', 'm365-login' ), $label, 'manage_options', 'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab ); } } /** * Tab slugs and labels. * * @return string[] */ public static function tabs() { return array( 'connection' => __( 'Connection', 'm365-login' ), 'button' => __( 'Button', 'm365-login' ), 'security' => __( 'Security', 'm365-login' ), '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 ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) { return; } $screen = get_current_screen(); if ( $screen && $this->hook === $screen->id ) { return; } if ( ! $screen || ! in_array( $screen->id, array( 'plugins', 'dashboard' ), true ) ) { return; } printf( '

%s %s

', esc_html__( 'M365 Login is active but not connected to Microsoft Entra ID yet.', 'm365-login' ), esc_url( self::url() ), esc_html__( 'Open the settings', 'm365-login' ) ); } /** * Loads assets on our screen only. * * @param string $hook Current screen hook. */ public function enqueue( $hook ) { if ( $hook !== $this->hook ) { return; } wp_enqueue_media(); wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_style( 'm365-login-admin', M365_LOGIN_URL . 'assets/css/admin.css', array( 'wp-color-picker' ), M365_LOGIN_VERSION ); wp_enqueue_script( 'm365-login-admin', M365_LOGIN_URL . 'assets/js/admin.js', array( 'jquery', 'wp-color-picker' ), M365_LOGIN_VERSION, true ); wp_localize_script( 'm365-login-admin', 'm365LoginAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_TEST ), 'action' => self::AJAX_TEST, 'groupAction' => self::AJAX_GROUPS, 'certAction' => self::AJAX_CERT, '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( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } $tenant = isset( $_POST['tenant'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['tenant'] ) ) ) : ''; if ( '' === $tenant || ! M365_Login_Settings::is_valid_tenant( $tenant ) ) { wp_send_json_error( array( 'message' => __( 'Please enter a valid tenant ID first.', 'm365-login' ) ) ); } $url = 'https://login.microsoftonline.com/' . rawurlencode( $tenant ) . '/v2.0/.well-known/openid-configuration'; $response = wp_remote_get( $url, array( 'timeout' => M365_Login_Auth::HTTP_TIMEOUT ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $body ) || empty( $body['issuer'] ) ) { wp_send_json_error( array( /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'Microsoft answered with HTTP %d. Is the tenant ID correct?', 'm365-login' ), $code ), ) ); } wp_send_json_success( array( 'issuer' => esc_url_raw( $body['issuer'] ), 'endpoint' => isset( $body['authorization_endpoint'] ) ? esc_url_raw( $body['authorization_endpoint'] ) : '', 'message' => __( 'Tenant reachable. The OpenID configuration was loaded successfully.', 'm365-login' ), ) ); } /** * AJAX: search Entra groups through Microsoft Graph. */ public function ajax_search_groups() { check_ajax_referer( self::NONCE_TEST, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } if ( ! $this->settings->is_configured() ) { wp_send_json_error( array( 'message' => __( 'Save the connection settings first, then search for groups.', 'm365-login' ) ) ); } $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : ''; $groups = $this->graph->search_groups( mb_substr( $query, 0, 100 ) ); if ( is_wp_error( $groups ) ) { $message = $groups->get_error_message(); if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) { $message = __( 'Microsoft Graph refused the request. Grant the application permission "GroupMember.Read.All" (or "Directory.Read.All") with admin consent in Entra ID.', 'm365-login' ); } wp_send_json_error( array( 'message' => $message ) ); } wp_send_json_success( array( 'groups' => $groups ) ); } /** * AJAX: generate a new self-signed certificate and store it (key encrypted). */ public function ajax_certificate() { check_ajax_referer( self::NONCE_TEST, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; if ( 'generate' !== $op ) { wp_send_json_error( array( 'message' => __( 'Unknown operation.', 'm365-login' ) ) ); } $host = wp_parse_url( home_url(), PHP_URL_HOST ); $pair = M365_Login_Certificate::generate( is_string( $host ) ? $host : 'wordpress' ); if ( is_wp_error( $pair ) ) { wp_send_json_error( array( 'message' => $pair->get_error_message() ) ); } $stored = $this->settings->store_certificate( $pair ); if ( is_wp_error( $stored ) ) { wp_send_json_error( array( 'message' => $stored->get_error_message() ) ); } $this->graph->flush_token(); $info = M365_Login_Certificate::info( $pair['certificate'] ); wp_send_json_success( array( 'message' => __( 'Certificate generated and stored. Download the .cer file and upload it in Entra ID.', 'm365-login' ), 'thumbprint' => $info ? $info['thumbprint'] : '', ) ); } /** * AJAX: run the user sync (dry run or live) with the saved settings. */ public function ajax_sync() { check_ajax_referer( self::NONCE_TEST, 'nonce' ); if ( ! current_user_can( 'manage_options' ) || ! 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 ); ?>
/>

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' ), ); ?>

group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected – all users of the tenant are imported.', 'm365-login' ) ); ?>

group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?>

$attribute ) : ?>

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

settings->is_multi_tenant() && '' !== $s['tenant_id'] ) : ?>

>

Bit RSA

>

__( '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 ) : ?>

[m365_login_button redirect="/dashboard/" divider="yes"]

<?php m365_login_button( array( 'redirect' => '/dashboard/' ) ); ?>

group_picker( 'allowed_groups', $this->settings->allowed_groups(), 'chips', __( 'No groups selected – every matched user may sign in.', 'm365-login' ) ); ?>

group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>

settings->fallback_url() ) : ?>
settings->fallback_url() ); ?>

define( \'M365_LOGIN_DISABLE_BUTTON_ONLY\', true );' ); ?>

render_sync_panel( $s ); ?>