settings = $settings;
$this->graph = $graph;
add_action( self::CRON_HOOK, array( $this, 'run_scheduled' ) );
add_action( 'init', array( $this, 'ensure_schedule' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) );
add_action( 'add_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) );
// Deactivated accounts: no password, application password, cookie or Microsoft sign-in.
add_filter( 'authenticate', array( $this, 'block_disabled_login' ), 100, 1 );
add_filter( 'determine_current_user', array( $this, 'drop_disabled_session' ), 100 );
add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 );
add_action( 'delete_user', array( $this, 'delete_photo' ) );
if ( is_admin() ) {
add_filter( 'manage_users_columns', array( $this, 'users_column' ) );
add_filter( 'manage_users_custom_column', array( $this, 'users_column_value' ), 10, 3 );
add_filter( 'user_row_actions', array( $this, 'user_row_actions' ), 10, 2 );
add_action( 'admin_post_' . self::POST_STATE, array( $this, 'handle_user_state' ) );
add_action( 'show_user_profile', array( $this, 'profile_section' ) );
add_action( 'edit_user_profile', array( $this, 'profile_section' ) );
add_action( 'admin_notices', array( $this, 'user_state_notice' ) );
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'm365-login sync', array( $this, 'cli' ) );
}
}
/* ------------------------------------------------------------------ */
/* Attributes */
/* ------------------------------------------------------------------ */
/**
* Microsoft Graph user properties that can be copied into WordPress profiles.
*
* The target is either a WordPress user field (display_name, first_name, last_name,
* locale), a user meta key or "avatar" for the profile photo.
*
* @return array Graph property => array( 'label' => string, 'target' => string ).
*/
public static function attributes() {
$attributes = array(
'displayName' => array(
'label' => __( 'Display name', 'm365-login' ),
'target' => 'display_name',
),
'givenName' => array(
'label' => __( 'First name', 'm365-login' ),
'target' => 'first_name',
),
'surname' => array(
'label' => __( 'Last name', 'm365-login' ),
'target' => 'last_name',
),
'photo' => array(
'label' => __( 'Profile photo (used as avatar)', 'm365-login' ),
'target' => 'avatar',
),
'jobTitle' => array(
'label' => __( 'Job title', 'm365-login' ),
'target' => 'm365_job_title',
),
'department' => array(
'label' => __( 'Department', 'm365-login' ),
'target' => 'm365_department',
),
'companyName' => array(
'label' => __( 'Company', 'm365-login' ),
'target' => 'm365_company_name',
),
'officeLocation' => array(
'label' => __( 'Office', 'm365-login' ),
'target' => 'm365_office_location',
),
'employeeId' => array(
'label' => __( 'Employee ID', 'm365-login' ),
'target' => 'm365_employee_id',
),
'businessPhones' => array(
'label' => __( 'Business phone', 'm365-login' ),
'target' => 'm365_business_phone',
),
'mobilePhone' => array(
'label' => __( 'Mobile phone', 'm365-login' ),
'target' => 'm365_mobile_phone',
),
'streetAddress' => array(
'label' => __( 'Street address', 'm365-login' ),
'target' => 'm365_street_address',
),
'postalCode' => array(
'label' => __( 'Postal code', 'm365-login' ),
'target' => 'm365_postal_code',
),
'city' => array(
'label' => __( 'City', 'm365-login' ),
'target' => 'm365_city',
),
'state' => array(
'label' => __( 'State / province', 'm365-login' ),
'target' => 'm365_state',
),
'country' => array(
'label' => __( 'Country', 'm365-login' ),
'target' => 'm365_country',
),
'preferredLanguage' => array(
'label' => __( 'Language (sets the admin language if installed)', 'm365-login' ),
'target' => 'locale',
),
);
/**
* Filters the Graph properties offered for the profile sync.
*
* Add entries as 'graphProperty' => array( 'label' => .., 'target' => 'meta_key' ).
*
* @param array $attributes Attributes.
*/
return (array) apply_filters( 'm365_login_sync_attributes', $attributes );
}
/**
* Selected attributes that exist in the registry.
*
* @return array Graph property => target.
*/
private function selected_attributes() {
$all = self::attributes();
$out = array();
foreach ( (array) $this->settings->get( 'sync_attributes', array() ) as $key ) {
if ( isset( $all[ $key ]['target'] ) ) {
$out[ $key ] = (string) $all[ $key ]['target'];
}
}
return $out;
}
/* ------------------------------------------------------------------ */
/* Scheduling */
/* ------------------------------------------------------------------ */
/**
* Schedules the cron event if the sync is enabled but no event is queued.
*/
public function ensure_schedule() {
if ( $this->settings->get( 'sync_enabled' ) && ! wp_next_scheduled( self::CRON_HOOK ) ) {
$this->reschedule();
}
}
/**
* (Re)creates or removes the cron event after the settings changed.
*/
public function reschedule() {
$this->settings->flush();
wp_clear_scheduled_hook( self::CRON_HOOK );
if ( $this->settings->get( 'sync_enabled' ) ) {
wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, (string) $this->settings->get( 'sync_interval', 'daily' ), self::CRON_HOOK );
}
}
/**
* Removes the cron event (plugin deactivation).
*/
public static function unschedule() {
wp_clear_scheduled_hook( self::CRON_HOOK );
}
/**
* Cron callback.
*/
public function run_scheduled() {
if ( $this->settings->get( 'sync_enabled' ) ) {
$this->run( false, 'cron' );
}
}
/**
* WP-CLI: synchronise users from Microsoft 365.
*
* ## OPTIONS
*
* [--dry-run]
* : Only report what would change.
*
* ## EXAMPLES
*
* wp m365-login sync --dry-run
*
* @param array $args Positional arguments.
* @param array $assoc_args Flags.
*/
public function cli( $args, $assoc_args ) {
$report = $this->run( ! empty( $assoc_args['dry-run'] ), 'cli' );
foreach ( $report['log'] as $entry ) {
WP_CLI::log( sprintf( '[%s] %s', $entry['level'], $entry['message'] ) );
}
foreach ( $report['counts'] as $key => $count ) {
WP_CLI::log( sprintf( '%s: %d', $key, $count ) );
}
if ( 'ok' === $report['status'] ) {
WP_CLI::success( $report['dry'] ? 'Dry run finished.' : 'Sync finished.' );
} else {
WP_CLI::error( 'Sync failed or was aborted.' );
}
}
/* ------------------------------------------------------------------ */
/* Run */
/* ------------------------------------------------------------------ */
/**
* Last stored report or null.
*
* @return array|null
*/
public static function last_report() {
$report = get_option( self::REPORT_OPTION, null );
return is_array( $report ) ? $report : null;
}
/**
* Runs a full sync.
*
* @param bool $dry Only simulate.
* @param string $trigger 'manual', 'cron' or 'cli'.
* @return array Report.
*/
public function run( $dry = false, $trigger = 'manual' ) {
$this->dry = (bool) $dry;
$this->report = array(
'started' => time(),
'finished' => 0,
'dry' => $this->dry,
'trigger' => $trigger,
'status' => 'ok',
'counts' => array_fill_keys( array( 'created', 'updated', 'linked', 'unchanged', 'disabled', 'enabled', 'deleted', 'photos', 'skipped', 'errors' ), 0 ),
'log' => array(),
);
if ( get_transient( self::LOCK ) ) {
$this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) );
return $this->finish( 'locked', false );
}
set_transient( self::LOCK, time(), self::LOCK_TTL );
if ( function_exists( 'set_time_limit' ) ) {
set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync.
}
wp_raise_memory_limit( 'admin' );
require_once ABSPATH . 'wp-admin/includes/user.php';
// No "your e-mail/password changed" mails for changes made by the sync.
add_filter( 'send_email_change_email', '__return_false', 99 );
add_filter( 'send_password_change_email', '__return_false', 99 );
try {
$status = $this->sync();
} finally {
remove_filter( 'send_email_change_email', '__return_false', 99 );
remove_filter( 'send_password_change_email', '__return_false', 99 );
delete_transient( self::LOCK );
}
return $this->finish( $status, true );
}
/**
* Stores and returns the report.
*
* @param string $status 'ok', 'failed', 'aborted' or 'locked'.
* @param bool $store Whether to persist it.
* @return array
*/
private function finish( $status, $store ) {
$this->report['status'] = $status;
$this->report['finished'] = time();
if ( $store ) {
update_option( self::REPORT_OPTION, $this->report, false );
}
/**
* Fires after a user sync run.
*
* @param array $report Report (counts, log, status, dry).
*/
do_action( 'm365_login_sync_finished', $this->report );
return $this->report;
}
/**
* The actual sync.
*
* @return string Status.
*/
private function sync() {
if ( ! $this->settings->is_configured() ) {
$this->log( 'error', __( 'The connection to Microsoft Entra ID is not configured yet.', 'm365-login' ) );
return 'failed';
}
if ( $this->settings->is_multi_tenant() ) {
$this->log( 'error', __( 'The user sync needs a pinned tenant ID (GUID) on the Connection tab.', 'm365-login' ) );
return 'failed';
}
if ( ! get_role( (string) $this->settings->get( 'sync_default_role' ) ) ) {
$this->log( 'error', __( 'The default role does not exist. Please check the sync settings.', 'm365-login' ) );
return 'failed';
}
// 1. Read the directory. Any error aborts the run before anything is changed.
$select = $this->graph_select();
$people = $this->fetch_people( $select );
if ( is_wp_error( $people ) ) {
$this->log( 'error', $this->graph_error_text( $people ) );
return 'failed';
}
/* translators: %d: number of users */
$this->log( 'info', sprintf( _n( '%d user read from Microsoft 365.', '%d users read from Microsoft 365.', count( $people ), 'm365-login' ), count( $people ) ) );
$memberships = $this->fetch_role_groups();
if ( is_wp_error( $memberships ) ) {
$this->log( 'error', $this->graph_error_text( $memberships ) );
return 'failed';
}
// 2. Create, link and update accounts.
$linked = $this->linked_users();
$seen = array();
$pending = array(); // Deactivations/deletions, applied after the safety check.
$photo_of = array(); // oid => WP_User whose photo is kept in sync.
$photo_on = array_key_exists( 'photo', $this->selected_attributes() );
foreach ( $people as $person ) {
$oid = strtolower( (string) $person['id'] );
$seen[ $oid ] = true;
$result = $this->sync_person( $person, $linked, $memberships );
if ( is_array( $result ) ) {
$pending[] = $result;
} elseif ( $result instanceof WP_User ) {
$photo_of[ $oid ] = $result;
}
}
// Profile photos: new, changed and removed photos, or cleanup when the photo sync was switched off.
if ( $photo_on ) {
$this->sync_photos( $photo_of );
} else {
$this->remove_all_photos();
}
// 3. Linked accounts that were not part of the directory listing.
foreach ( $linked as $oid => $user_id ) {
if ( isset( $seen[ $oid ] ) ) {
continue;
}
$action = $this->classify_missing( $oid, $user_id );
if ( is_wp_error( $action ) ) {
$this->log( 'error', $this->graph_error_text( $action ) );
return 'failed';
}
if ( null !== $action ) {
$pending[] = $action;
}
}
// 4. Safety net: never deactivate or delete a large part of the linked accounts in one go.
$pending = array_values( array_filter( $pending, array( $this, 'is_effective_action' ) ) );
$limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( count( $linked ) * 0.2 ) ), count( $linked ) );
if ( count( $pending ) > $limit ) {
$this->log(
'error',
sprintf(
/* translators: 1: number of accounts, 2: limit */
__( 'Safety stop: %1$d accounts would be deactivated or deleted, more than the limit of %2$d per run. No account was deactivated or deleted. Check the sync groups and the tenant, then run the sync again (the limit can be changed with the m365_login_sync_deprovision_limit filter).', 'm365-login' ),
count( $pending ),
$limit
)
);
return 'aborted';
}
foreach ( $pending as $action ) {
$this->apply_action( $action );
}
return 'ok';
}
/**
* Graph properties to read for every user.
*
* @return string[]
*/
private function graph_select() {
$select = array( 'id', 'accountEnabled', 'mail', 'userPrincipalName', 'userType', 'displayName' );
foreach ( array_keys( $this->selected_attributes() ) as $key ) {
if ( 'photo' !== $key && preg_match( '/^[A-Za-z]+$/', $key ) ) {
$select[] = $key;
}
}
return array_values( array_unique( $select ) );
}
/**
* Users in scope: the whole tenant or the (nested) members of the sync groups.
*
* @param string[] $select Properties.
* @return array[]|WP_Error
*/
private function fetch_people( $select ) {
$groups = $this->settings->sync_scope_groups();
if ( empty( $groups ) ) {
return $this->graph->list_users( $select );
}
$people = array();
foreach ( $groups as $group_id => $name ) {
$members = $this->graph->list_group_users( $group_id, $select );
if ( is_wp_error( $members ) ) {
return $members;
}
foreach ( $members as $member ) {
$people[ strtolower( (string) $member['id'] ) ] = $member;
}
}
return array_values( $people );
}
/**
* Members (object IDs) of every group used in the role mapping.
*
* @return array|WP_Error group ID => array( oid => true ).
*/
private function fetch_role_groups() {
$out = array();
foreach ( array_keys( $this->settings->sync_role_map() ) as $group_id ) {
$members = $this->graph->list_group_users( $group_id, array( 'id' ) );
if ( is_wp_error( $members ) ) {
return $members;
}
$out[ $group_id ] = array();
foreach ( $members as $member ) {
$out[ $group_id ][ strtolower( (string) $member['id'] ) ] = true;
}
}
return $out;
}
/**
* WordPress users linked to a Microsoft object ID (this site only).
*
* @return array oid => user ID.
*/
private function linked_users() {
$users = get_users(
array(
'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_compare' => 'EXISTS',
'fields' => array( 'ID' ),
'number' => -1,
)
);
$out = array();
foreach ( $users as $row ) {
$oid = strtolower( (string) get_user_meta( (int) $row->ID, M365_Login_Auth::META_OID, true ) );
if ( M365_Login_Settings::is_guid( $oid ) ) {
$out[ $oid ] = (int) $row->ID;
}
}
return $out;
}
/**
* Creates, links or updates the account of one directory user.
*
* @param array $person Graph user.
* @param array $linked oid => user ID (updated when an account is linked or created).
* @param array $memberships Role group memberships.
* @return WP_User|array|null The synced account, a pending deprovision action, or null when skipped.
*/
private function sync_person( $person, &$linked, $memberships ) {
$oid = strtolower( (string) $person['id'] );
$upn = isset( $person['userPrincipalName'] ) ? (string) $person['userPrincipalName'] : $oid;
$enabled = ! isset( $person['accountEnabled'] ) || false !== $person['accountEnabled'];
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return null;
}
$user = isset( $linked[ $oid ] ) ? get_userdata( $linked[ $oid ] ) : false;
if ( ! $user && isset( $person['userType'] ) && 'Guest' === $person['userType'] && ! $this->settings->get( 'sync_guests' ) ) {
return null; // Guests are not imported (they may still be linked through a sign-in).
}
$email = $this->email_of( $person );
if ( '' === $email ) {
if ( ! $user ) {
/* translators: %s: user principal name */
$this->skip( sprintf( __( '%s: no usable e-mail address, skipped.', 'm365-login' ), $upn ) );
}
return $user ? $user : null;
}
if ( ! $this->domain_allowed( $email ) ) {
if ( ! $user ) {
/* translators: %s: e-mail address */
$this->skip( sprintf( __( '%s: e-mail domain is not on the allow-list, skipped.', 'm365-login' ), $email ) );
}
return null;
}
// Not linked yet: match an existing account by e-mail address.
if ( ! $user ) {
$by_mail = get_user_by( 'email', $email );
if ( $by_mail instanceof WP_User ) {
$stored = strtolower( (string) get_user_meta( $by_mail->ID, M365_Login_Auth::META_OID, true ) );
if ( '' !== $stored && $stored !== $oid ) {
/* translators: %s: e-mail address */
$this->skip( sprintf( __( '%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped.', 'm365-login' ), $email ) );
return null;
}
$user = $by_mail;
$this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: existing account linked.', 'm365-login' ), $email ) );
$this->count( 'linked' );
if ( ! $this->dry ) {
update_user_meta( $user->ID, M365_Login_Auth::META_OID, $oid );
}
$linked[ $oid ] = $user->ID;
}
}
// Disabled in Microsoft 365.
if ( ! $enabled ) {
if ( ! $user ) {
return null; // Nothing to create for disabled people.
}
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) );
}
if ( ! $user ) {
return $this->create_user( $person, $oid, $email, $linked, $memberships );
}
if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) {
if ( ! $this->dry ) {
add_user_to_blog( get_current_blog_id(), $user->ID, (string) $this->settings->get( 'sync_default_role' ) );
}
$this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: added to this site.', 'm365-login' ), $email ) );
}
// Accounts deactivated by the sync come back when the person is active again.
$disabled = self::disabled_info( $user->ID );
if ( $disabled && 'sync' === $disabled['by'] ) {
if ( ! $this->dry ) {
self::enable( $user->ID );
}
$this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: reactivated (active in Microsoft 365 again).', 'm365-login' ), $email ) );
$this->count( 'enabled' );
}
$changes = $this->update_profile( $user, $person, $email );
if ( $this->manages_roles( $user ) ) {
$changes = array_merge( $changes, $this->update_roles( $user, $this->desired_roles( $oid, $memberships ) ) );
}
if ( $changes ) {
/* translators: 1: e-mail address, 2: list of changed fields */
$this->log( 'info', sprintf( __( '%1$s: updated (%2$s).', 'm365-login' ), $email, implode( ', ', $changes ) ) );
$this->count( 'updated' );
} else {
$this->count( 'unchanged' );
}
if ( ! $this->dry ) {
update_user_meta( $user->ID, self::META_LAST_SYNC, time() );
}
return $user;
}
/**
* Creates a new WordPress account for a directory user.
*
* @param array $person Graph user.
* @param string $oid Object ID.
* @param string $email E-mail address.
* @param array $linked oid => user ID.
* @param array $memberships Role group memberships.
* @return WP_User|null
*/
private function create_user( $person, $oid, $email, &$linked, $memberships ) {
$roles = $this->desired_roles( $oid, $memberships );
/* translators: 1: e-mail address, 2: role names */
$this->log( 'info', sprintf( __( '%1$s: account created (%2$s).', 'm365-login' ), $email, $this->role_names( $roles ) ) );
$this->count( 'created' );
if ( $this->dry ) {
return null;
}
$data = array(
'user_login' => $this->unique_login( $email ),
'user_email' => $email,
'user_pass' => wp_generate_password( 40, true, true ),
'role' => $roles[0],
'display_name' => ! empty( $person['displayName'] ) ? sanitize_text_field( (string) $person['displayName'] ) : $email,
);
/**
* Filters the data used to create a WordPress account for a Microsoft 365 user.
*
* @param array $data Arguments for wp_insert_user().
* @param array $person Graph user object.
*/
$data = apply_filters( 'm365_login_sync_new_user_data', $data, $person );
$user_id = wp_insert_user( $data );
if ( is_wp_error( $user_id ) ) {
$this->count( 'created', -1 );
$this->count( 'errors' );
/* translators: 1: e-mail address, 2: error message */
$this->log( 'error', sprintf( __( '%1$s: account could not be created: %2$s', 'm365-login' ), $email, $user_id->get_error_message() ) );
return null;
}
update_user_meta( $user_id, M365_Login_Auth::META_OID, $oid );
update_user_meta( $user_id, self::META_SYNCED, time() );
update_user_meta( $user_id, self::META_LAST_SYNC, time() );
$linked[ $oid ] = (int) $user_id;
$user = get_userdata( $user_id );
$this->update_profile( $user, $person, $email );
$this->update_roles( $user, $roles );
/**
* Fires after the sync created a WordPress account.
*
* @param WP_User $user New user.
* @param array $person Graph user object.
*/
do_action( 'm365_login_sync_user_created', $user, $person );
return $user;
}
/**
* Unique user_login derived from the e-mail address.
*
* @param string $email E-mail address.
* @return string
*/
private function unique_login( $email ) {
$base = sanitize_user( strtok( $email, '@' ), true );
$base = '' === $base ? 'm365user' : mb_substr( $base, 0, 50 );
$login = $base;
$suffix = 2;
while ( username_exists( $login ) ) {
$login = $base . $suffix;
++$suffix;
}
return $login;
}
/**
* Copies e-mail address and selected attributes into the profile.
*
* @param WP_User $user User.
* @param array $person Graph user.
* @param string $email E-mail from the directory.
* @return string[] Changed fields (for the log).
*/
private function update_profile( $user, $person, $email ) {
$changes = array();
$fields = array();
if ( strtolower( $user->user_email ) !== $email ) {
$owner = get_user_by( 'email', $email );
if ( $owner && $owner->ID !== $user->ID ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: e-mail address is used by another WordPress account and was not changed.', 'm365-login' ), $email ) );
} else {
$fields['user_email'] = $email;
$changes[] = __( 'e-mail', 'm365-login' );
}
}
$labels = self::attributes();
foreach ( $this->selected_attributes() as $key => $target ) {
if ( 'avatar' === $target ) {
continue;
}
$value = $this->attribute_value( $person, $key, $target );
if ( null === $value ) {
continue;
}
if ( in_array( $target, array( 'display_name', 'first_name', 'last_name', 'locale' ), true ) ) {
if ( '' === $value && 'display_name' === $target ) {
continue;
}
if ( (string) $user->$target !== $value ) {
$fields[ $target ] = $value;
$changes[] = $labels[ $key ]['label'];
}
continue;
}
$meta_key = sanitize_key( $target );
if ( (string) get_user_meta( $user->ID, $meta_key, true ) !== $value ) {
if ( ! $this->dry ) {
if ( '' === $value ) {
delete_user_meta( $user->ID, $meta_key );
} else {
update_user_meta( $user->ID, $meta_key, $value );
}
}
$changes[] = $labels[ $key ]['label'];
}
}
// Fields that are no longer selected are removed from the profile (only the plugin's own m365_* keys).
$selected = $this->selected_attributes();
foreach ( $labels as $key => $attribute ) {
$target = isset( $attribute['target'] ) ? (string) $attribute['target'] : '';
if ( isset( $selected[ $key ] ) || 0 !== strpos( $target, 'm365_' ) ) {
continue;
}
if ( '' !== (string) get_user_meta( $user->ID, $target, true ) ) {
if ( ! $this->dry ) {
delete_user_meta( $user->ID, $target );
}
/* translators: %s: profile field */
$changes[] = sprintf( __( '%s removed', 'm365-login' ), $attribute['label'] );
}
}
if ( $fields && ! $this->dry ) {
$fields['ID'] = $user->ID;
$result = wp_update_user( $fields );
if ( is_wp_error( $result ) ) {
$this->count( 'errors' );
/* translators: 1: e-mail address, 2: error message */
$this->log( 'error', sprintf( __( '%1$s: profile could not be updated: %2$s', 'm365-login' ), $email, $result->get_error_message() ) );
return array();
}
clean_user_cache( $user->ID );
}
return $changes;
}
/**
* Normalised value of one attribute, or null to leave the field alone.
*
* @param array $person Graph user.
* @param string $key Graph property.
* @param string $target Target field.
* @return string|null
*/
private function attribute_value( $person, $key, $target ) {
if ( ! array_key_exists( $key, $person ) ) {
return null;
}
$raw = $person[ $key ];
if ( is_array( $raw ) ) {
$raw = isset( $raw[0] ) && is_scalar( $raw[0] ) ? $raw[0] : '';
}
$value = null === $raw ? '' : sanitize_text_field( (string) $raw );
if ( 'locale' === $target ) {
$locale = str_replace( '-', '_', $value );
if ( '' === $locale ) {
return null;
}
if ( 'en_US' !== $locale && ! in_array( $locale, get_available_languages(), true ) ) {
return null; // Language pack not installed: keep the site default.
}
return $locale;
}
return mb_substr( $value, 0, 250 );
}
/**
* Roles a person should have, in order (the first one is the primary role).
*
* @param string $oid Object ID.
* @param array $memberships Role group memberships.
* @return string[]
*/
private function desired_roles( $oid, $memberships ) {
$default = (string) $this->settings->get( 'sync_default_role' );
$mapped = array();
foreach ( $this->settings->sync_role_map() as $group_id => $row ) {
if ( isset( $memberships[ $group_id ][ $oid ] ) && get_role( $row['role'] ) ) {
$mapped[] = $row['role'];
}
}
$mapped = array_values( array_unique( $mapped ) );
if ( 'replace' === $this->settings->get( 'sync_role_mode' ) ) {
$roles = $mapped ? array( $mapped[0] ) : array( $default );
} else {
$roles = array_values( array_unique( array_merge( array( $default ), $mapped ) ) );
}
/**
* Filters the WordPress roles the sync assigns to a Microsoft 365 user.
*
* @param string[] $roles Role slugs, the first is the primary role.
* @param string $oid Microsoft object ID.
*/
$roles = array_values( array_filter( (array) apply_filters( 'm365_login_sync_roles', $roles, $oid ), 'get_role' ) );
return $roles ? $roles : array( $default );
}
/**
* Whether the sync may change the roles of this account.
*
* @param WP_User $user User.
* @return bool
*/
private function manages_roles( $user ) {
if ( $this->is_protected( $user ) ) {
return false;
}
return (bool) get_user_meta( $user->ID, self::META_SYNCED, true ) || (bool) $this->settings->get( 'sync_manage_existing' );
}
/**
* Applies the desired roles.
*
* @param WP_User $user User.
* @param string[] $roles Desired roles.
* @return string[] Changes for the log.
*/
private function update_roles( $user, $roles ) {
$current = array_values( $user->roles );
$same = count( $current ) === count( $roles ) && ! array_diff( $current, $roles ) && reset( $current ) === $roles[0];
if ( $same ) {
return array();
}
if ( ! $this->dry ) {
$user->set_role( $roles[0] );
foreach ( array_slice( $roles, 1 ) as $role ) {
$user->add_role( $role );
}
}
/* translators: %s: role names */
return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) );
}
/**
* Human readable role list.
*
* @param string[] $roles Role slugs.
* @return string
*/
private function role_names( $roles ) {
$names = wp_roles()->get_names();
$out = array();
foreach ( $roles as $role ) {
$out[] = isset( $names[ $role ] ) ? translate_user_role( $names[ $role ] ) : $role;
}
return implode( ', ', $out );
}
/**
* Decides what happens to a linked account that was not in the directory listing.
*
* @param string $oid Object ID.
* @param int $user_id User ID.
* @return array|null|WP_Error Pending action, null for none.
*/
private function classify_missing( $oid, $user_id ) {
$user = get_userdata( $user_id );
if ( ! $user ) {
return null;
}
// Double-check with Graph: only a 404 proves that the account was deleted.
$person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) );
if ( is_wp_error( $person ) ) {
if ( M365_Login_Graph::is_not_found( $person ) ) {
return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ) );
}
return $person;
}
if ( isset( $person['accountEnabled'] ) && false === $person['accountEnabled'] ) {
return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) );
}
if ( $this->settings->sync_scope_groups() && ( ! isset( $person['userType'] ) || 'Guest' !== $person['userType'] || $this->settings->get( 'sync_guests' ) ) ) {
return $this->action( (string) $this->settings->get( 'sync_scope_action' ), $user, 'scope', __( 'no longer a member of the sync groups', 'm365-login' ) );
}
return null;
}
/**
* Builds a pending deprovision action.
*
* @param string $what 'none', 'disable' or 'delete'.
* @param WP_User $user User.
* @param string $reason Machine reason.
* @param string $label Human reason.
* @return array|null
*/
private function action( $what, $user, $reason, $label ) {
if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) {
return null;
}
if ( $this->is_protected( $user ) ) {
/* translators: 1: e-mail address, 2: reason */
$this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $user->user_email, $label ) );
return null;
}
return array(
'what' => $what,
'user' => $user,
'reason' => $reason,
'label' => $label,
);
}
/**
* Filters out actions that would not change anything (already deactivated).
*
* @param array $action Pending action.
* @return bool
*/
private function is_effective_action( $action ) {
return 'delete' === $action['what'] || ! self::disabled_info( $action['user']->ID );
}
/**
* Deactivates or deletes an account.
*
* @param array $action Pending action.
*/
private function apply_action( $action ) {
$user = $action['user'];
$reassign = (int) $this->settings->get( 'sync_reassign' );
$what = $action['what'];
if ( 'delete' === $what && ( ! $reassign || $reassign === $user->ID || ! get_userdata( $reassign ) ) ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted.', 'm365-login' ), $user->user_email ) );
$what = 'disable';
if ( self::disabled_info( $user->ID ) ) {
return;
}
}
if ( 'delete' === $what ) {
/* translators: 1: e-mail address, 2: reason */
$this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) );
$this->count( 'deleted' );
if ( ! $this->dry ) {
wp_delete_user( $user->ID, $reassign ); // The delete_user hook removes the photo.
}
return;
}
/* translators: 1: e-mail address, 2: reason */
$this->log( 'info', sprintf( __( '%1$s: account deactivated (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) );
$this->count( 'disabled' );
if ( ! $this->dry ) {
self::disable( $user->ID, 'sync', $action['reason'] );
}
}
/**
* Accounts the sync never deactivates, deletes or re-roles.
*
* Administrators that existed before the sync are protected; accounts the sync
* created (and may have promoted through a group mapping) are fully managed.
*
* @param WP_User $user User.
* @return bool
*/
private function is_protected( $user ) {
$protected = get_current_user_id() === $user->ID
|| ( is_multisite() && is_super_admin( $user->ID ) )
|| ( ! get_user_meta( $user->ID, self::META_SYNCED, true ) && user_can( $user, 'manage_options' ) );
/**
* Filters whether the sync must leave an account alone (no role changes, deactivation or deletion).
*
* @param bool $protected Whether the account is protected.
* @param WP_User $user User.
*/
return (bool) apply_filters( 'm365_login_sync_protect_user', $protected, $user );
}
/**
* E-mail address of a directory user (mail, else a usable UPN).
*
* @param array $person Graph user.
* @return string Lowercase address or ''.
*/
private function email_of( $person ) {
$candidates = array();
if ( ! empty( $person['mail'] ) ) {
$candidates[] = (string) $person['mail'];
}
if ( ! empty( $person['userPrincipalName'] ) && false === stripos( (string) $person['userPrincipalName'], '#ext#' ) ) {
$candidates[] = (string) $person['userPrincipalName'];
}
foreach ( $candidates as $candidate ) {
$candidate = strtolower( trim( $candidate ) );
if ( is_email( $candidate ) ) {
/**
* Filters the e-mail address the sync uses for a Microsoft 365 user.
*
* @param string $email Address.
* @param array $person Graph user object.
*/
return strtolower( (string) apply_filters( 'm365_login_sync_email', $candidate, $person ) );
}
}
return '';
}
/**
* Domain allow-list from the Security tab.
*
* @param string $email E-mail.
* @return bool
*/
private function domain_allowed( $email ) {
$allowed = $this->settings->allowed_domains();
return empty( $allowed ) || in_array( strtolower( substr( strrchr( $email, '@' ), 1 ) ), $allowed, true );
}
/**
* Friendlier text for common Graph permission errors.
*
* @param WP_Error $error Error.
* @return string
*/
private function graph_error_text( $error ) {
$message = $error->get_error_message();
if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) {
return __( 'Microsoft Graph refused the request. Grant the application permissions "User.Read.All" and "GroupMember.Read.All" with admin consent in Entra ID.', 'm365-login' );
}
/* translators: %s: error message */
return sprintf( __( 'Microsoft Graph error: %s', 'm365-login' ), $message );
}
/**
* Adds a log line.
*
* @param string $level 'info', 'warning' or 'error'.
* @param string $message Message.
*/
private function log( $level, $message ) {
if ( count( $this->report['log'] ) < self::LOG_LIMIT ) {
$this->report['log'][] = array(
'level' => $level,
'message' => $message,
);
} elseif ( count( $this->report['log'] ) === self::LOG_LIMIT ) {
$this->report['log'][] = array(
'level' => 'warning',
'message' => __( 'Log truncated.', 'm365-login' ),
);
}
}
/**
* Logs a skipped person.
*
* @param string $message Message.
*/
private function skip( $message ) {
$this->log( 'warning', $message );
$this->count( 'skipped' );
}
/**
* Increments a counter.
*
* @param string $key Counter.
* @param int $delta Amount.
*/
private function count( $key, $delta = 1 ) {
$this->report['counts'][ $key ] += $delta;
}
/* ------------------------------------------------------------------ */
/* Profile photos */
/* ------------------------------------------------------------------ */
/**
* Brings the stored photos in line with Microsoft 365: downloads new and changed
* photos and deletes photos that were removed in Microsoft 365.
*
* Photo versions are compared on every run (20 users per Graph batch request);
* only changed photos are downloaded.
*
* @param WP_User[] $users oid => user.
*/
private function sync_photos( $users ) {
/**
* Minimum number of seconds between two photo checks of the same user (0 = every run).
*
* @param int $interval Interval.
*/
$interval = (int) apply_filters( 'm365_login_sync_photo_interval', 0 );
/**
* Maximum number of photo downloads per sync run (the rest follows in later runs).
*
* @param int $limit Limit.
*/
$limit = (int) apply_filters( 'm365_login_sync_photo_limit', 500 );
$check = array();
foreach ( $users as $oid => $user ) {
$stored = $this->stored_photo( $user->ID );
if ( $interval > 0 && ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) {
continue;
}
$check[ $oid ] = $user;
}
if ( empty( $check ) ) {
return;
}
$versions = $this->graph->photo_versions( array_keys( $check ) );
$downloads = 0;
$deferred = 0;
foreach ( $check as $oid => $user ) {
$version = array_key_exists( $oid, $versions ) ? $versions[ $oid ] : new WP_Error( 'graph_photo', 'No answer.' );
$stored = $this->stored_photo( $user->ID );
// Never delete anything because of an error – only a clear "no photo" removes it.
if ( is_wp_error( $version ) ) {
/* translators: 1: e-mail address, 2: error message */
$this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $version->get_error_message() ) );
continue;
}
if ( null === $version ) {
if ( ! empty( $stored['file'] ) ) {
$this->remove_photo( $user );
} elseif ( ! $this->dry ) {
update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) );
}
continue;
}
if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && file_exists( self::photo_path( $stored['file'] ) ) ) {
if ( ! $this->dry ) {
$stored['checked'] = time();
update_user_meta( $user->ID, self::META_PHOTO, $stored );
}
continue;
}
// New or changed photo.
if ( $this->dry ) {
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
continue;
}
if ( $downloads >= $limit ) {
++$deferred;
continue;
}
++$downloads;
$bytes = $this->graph->photo_bytes( $oid );
if ( null === $bytes ) {
if ( ! empty( $stored['file'] ) ) {
$this->remove_photo( $user );
}
continue;
}
if ( is_wp_error( $bytes ) || '' === $bytes || strlen( $bytes ) > self::PHOTO_MAX ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo could not be downloaded.', 'm365-login' ), $user->user_email ) );
continue;
}
$file = $this->store_photo( $user->ID, $oid, $version, $bytes );
if ( '' === $file ) {
/* translators: %s: e-mail address */
$this->log( 'warning', sprintf( __( '%s: profile photo is not a valid image or could not be saved.', 'm365-login' ), $user->user_email ) );
continue;
}
if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) {
wp_delete_file( self::photo_path( $stored['file'] ) );
}
update_user_meta(
$user->ID,
self::META_PHOTO,
array(
'file' => $file,
'etag' => $version,
'checked' => time(),
)
);
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
}
if ( $deferred ) {
/* translators: %d: number of photos */
$this->log( 'info', sprintf( _n( '%d changed profile photo will be downloaded in the next run (download limit per run reached).', '%d changed profile photos will be downloaded in the next run (download limit per run reached).', $deferred, 'm365-login' ), $deferred ) );
}
}
/**
* Removes the stored photo of a user whose photo was deleted in Microsoft 365.
*
* @param WP_User $user User.
*/
private function remove_photo( $user ) {
if ( ! $this->dry ) {
$this->delete_photo( $user->ID );
update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) );
}
/* translators: %s: e-mail address */
$this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) );
$this->count( 'photos' );
}
/**
* Deletes every stored photo (the photo sync was switched off).
*/
private function remove_all_photos() {
$users = get_users(
array(
'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_compare' => 'EXISTS',
'fields' => array( 'ID' ),
'number' => -1,
)
);
$removed = 0;
foreach ( $users as $row ) {
$stored = $this->stored_photo( (int) $row->ID );
if ( ! empty( $stored['file'] ) ) {
++$removed;
}
if ( ! $this->dry ) {
$this->delete_photo( (int) $row->ID );
}
}
if ( $removed ) {
/* translators: %d: number of photos */
$this->log( 'info', sprintf( _n( 'Profile photo sync is off: %d stored photo removed.', 'Profile photo sync is off: %d stored photos removed.', $removed, 'm365-login' ), $removed ) );
$this->count( 'photos', $removed );
}
}
/**
* Stored photo record of a user.
*
* @param int $user_id User ID.
* @return array
*/
private function stored_photo( $user_id ) {
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
return is_array( $stored ) ? $stored : array();
}
/**
* Writes the image into uploads/m365-login-avatars/.
*
* @param int $user_id User ID.
* @param string $oid Object ID.
* @param string $etag Photo version.
* @param string $bytes Image data.
* @return string File path relative to the uploads base directory, or ''.
*/
private function store_photo( $user_id, $oid, $etag, $bytes ) {
$size = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid data is expected to fail quietly.
$exts = array(
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
);
if ( ! is_array( $size ) || empty( $size['mime'] ) || ! isset( $exts[ $size['mime'] ] ) ) {
return '';
}
$name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $exts[ $size['mime'] ];
$subdir = static function ( $dirs ) {
$dirs['subdir'] = '/' . self::PHOTO_DIR;
$dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
$dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
return $dirs;
};
add_filter( 'upload_dir', $subdir );
$existing = wp_upload_dir();
if ( file_exists( trailingslashit( $existing['path'] ) . $name ) ) {
wp_delete_file( trailingslashit( $existing['path'] ) . $name );
}
$upload = wp_upload_bits( $name, null, $bytes );
remove_filter( 'upload_dir', $subdir );
if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) {
return '';
}
$uploads = wp_get_upload_dir();
return ltrim( str_replace( wp_normalize_path( $uploads['basedir'] ), '', wp_normalize_path( $upload['file'] ) ), '/' );
}
/**
* Absolute path of a stored photo.
*
* @param string $file Relative path.
* @return string
*/
private static function photo_path( $file ) {
$uploads = wp_get_upload_dir();
return trailingslashit( $uploads['basedir'] ) . ltrim( $file, '/' );
}
/**
* Deletes a user's stored photo (also hooked to user deletion).
*
* @param int $user_id User ID.
*/
public function delete_photo( $user_id ) {
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) {
wp_delete_file( self::photo_path( $stored['file'] ) );
}
delete_user_meta( $user_id, self::META_PHOTO );
}
/**
* Uses the synced Microsoft 365 photo as avatar.
*
* @param array $args Avatar data.
* @param mixed $id_or_email User ID, e-mail, WP_User, WP_Post or WP_Comment.
* @return array
*/
public function avatar_data( $args, $id_or_email ) {
if ( ! in_array( 'photo', (array) $this->settings->get( 'sync_attributes', array() ), true ) ) {
return $args;
}
$user_id = 0;
if ( is_numeric( $id_or_email ) ) {
$user_id = (int) $id_or_email;
} elseif ( $id_or_email instanceof WP_User ) {
$user_id = $id_or_email->ID;
} elseif ( $id_or_email instanceof WP_Post ) {
$user_id = (int) $id_or_email->post_author;
} elseif ( $id_or_email instanceof WP_Comment ) {
$user_id = (int) $id_or_email->user_id;
} elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) {
$user = get_user_by( 'email', $id_or_email );
$user_id = $user ? $user->ID : 0;
}
if ( ! $user_id ) {
return $args;
}
$stored = get_user_meta( $user_id, self::META_PHOTO, true );
if ( ! is_array( $stored ) || empty( $stored['file'] ) ) {
return $args;
}
$uploads = wp_get_upload_dir();
$args['url'] = trailingslashit( $uploads['baseurl'] ) . ltrim( $stored['file'], '/' );
$args['found_avatar'] = true;
return $args;
}
/* ------------------------------------------------------------------ */
/* Deactivated accounts */
/* ------------------------------------------------------------------ */
/**
* Deactivation details or null when the account is active.
*
* @param int $user_id User ID.
* @return array|null
*/
public static function disabled_info( $user_id ) {
$info = get_user_meta( (int) $user_id, self::META_DISABLED, true );
if ( ! is_array( $info ) || empty( $info['time'] ) ) {
return null;
}
return wp_parse_args(
$info,
array(
'by' => 'manual',
'reason' => '',
)
);
}
/**
* Deactivates an account and ends all of its sessions.
*
* @param int $user_id User ID.
* @param string $by 'sync' or 'manual'.
* @param string $reason Machine reason.
*/
public static function disable( $user_id, $by, $reason = '' ) {
update_user_meta(
$user_id,
self::META_DISABLED,
array(
'time' => time(),
'by' => $by,
'reason' => $reason,
)
);
WP_Session_Tokens::get_instance( $user_id )->destroy_all();
/**
* Fires after an account was deactivated.
*
* @param int $user_id User ID.
* @param string $by 'sync' or 'manual'.
* @param string $reason 'disabled', 'deleted', 'scope' or ''.
*/
do_action( 'm365_login_user_disabled', $user_id, $by, $reason );
}
/**
* Reactivates an account.
*
* @param int $user_id User ID.
*/
public static function enable( $user_id ) {
delete_user_meta( $user_id, self::META_DISABLED );
/**
* Fires after an account was reactivated.
*
* @param int $user_id User ID.
*/
do_action( 'm365_login_user_enabled', $user_id );
}
/**
* Refuses every sign-in (password, application password, Microsoft) of deactivated accounts.
*
* @param null|WP_User|WP_Error $user Result so far.
* @return null|WP_User|WP_Error
*/
public function block_disabled_login( $user ) {
if ( $user instanceof WP_User && self::disabled_info( $user->ID ) ) {
return new WP_Error( 'm365_login_disabled', __( 'This account has been deactivated.', 'm365-login' ) );
}
return $user;
}
/**
* Treats existing sessions of deactivated accounts as logged out.
*
* @param int|false $user_id Detected user.
* @return int|false
*/
public function drop_disabled_session( $user_id ) {
if ( $user_id && self::disabled_info( (int) $user_id ) ) {
return false;
}
return $user_id;
}
/* ------------------------------------------------------------------ */
/* Users screen and profile */
/* ------------------------------------------------------------------ */
/**
* Adds the "Microsoft 365" column to the users list.
*
* @param string[] $columns Columns.
* @return string[]
*/
public function users_column( $columns ) {
$columns['m365_login'] = __( 'Microsoft 365', 'm365-login' );
return $columns;
}
/**
* Renders the "Microsoft 365" column.
*
* @param string $output Output so far.
* @param string $column Column.
* @param int $user_id User ID.
* @return string
*/
public function users_column_value( $output, $column, $user_id ) {
if ( 'm365_login' !== $column ) {
return $output;
}
$parts = array();
if ( self::disabled_info( $user_id ) ) {
$parts[] = '' . esc_html__( 'Deactivated', 'm365-login' ) . '';
}
if ( get_user_meta( $user_id, self::META_SYNCED, true ) ) {
$parts[] = esc_html__( 'Imported', 'm365-login' );
} elseif ( get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) ) {
$parts[] = esc_html__( 'Linked', 'm365-login' );
}
return $parts ? implode( '
', $parts ) : '—';
}
/**
* "Deactivate" / "Reactivate" row actions.
*
* @param string[] $actions Actions.
* @param WP_User $user User.
* @return string[]
*/
public function user_row_actions( $actions, $user ) {
if ( ! current_user_can( 'edit_user', $user->ID ) || get_current_user_id() === $user->ID ) {
return $actions;
}
$disabled = (bool) self::disabled_info( $user->ID );
$url = wp_nonce_url(
add_query_arg(
array(
'action' => self::POST_STATE,
'user_id' => $user->ID,
'state' => $disabled ? 'enable' : 'disable',
),
admin_url( 'admin-post.php' )
),
self::POST_STATE . '_' . $user->ID
);
$actions['m365_login_state'] = '' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . '';
return $actions;
}
/**
* Handles the row actions.
*/
public function handle_user_state() {
$user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
check_admin_referer( self::POST_STATE . '_' . $user_id );
if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) || get_current_user_id() === $user_id ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
$state = isset( $_GET['state'] ) ? sanitize_key( wp_unslash( $_GET['state'] ) ) : '';
if ( 'disable' === $state ) {
self::disable( $user_id, 'manual' );
} else {
self::enable( $user_id );
}
wp_safe_redirect( add_query_arg( 'm365_user_state', 'disable' === $state ? 'disabled' : 'enabled', admin_url( 'users.php' ) ) );
exit;
}
/**
* Confirmation after a row action.
*/
public function user_state_notice() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
$state = isset( $_GET['m365_user_state'] ) ? sanitize_key( wp_unslash( $_GET['m365_user_state'] ) ) : '';
if ( '' === $state ) {
return;
}
$text = 'disabled' === $state
? __( 'The account has been deactivated and signed out everywhere.', 'm365-login' )
: __( 'The account has been reactivated.', 'm365-login' );
printf( '
%s