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

', esc_html( $text ) ); } /** * Read-only "Microsoft 365" section on the profile screen. * * @param WP_User $user User being edited. */ public function profile_section( $user ) { $oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ); if ( '' === $oid && ! self::disabled_info( $user->ID ) ) { return; } $rows = array(); $disabled = self::disabled_info( $user->ID ); if ( $disabled ) { $reasons = array( 'disabled' => __( 'disabled in Microsoft 365', 'm365-login' ), 'deleted' => __( 'deleted in Microsoft 365', 'm365-login' ), 'scope' => __( 'no longer a member of the sync groups', 'm365-login' ), ); $text = sprintf( /* translators: 1: date, 2: reason */ __( 'Deactivated since %1$s (%2$s)', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $disabled['time'] ), isset( $reasons[ $disabled['reason'] ] ) ? $reasons[ $disabled['reason'] ] : __( 'manually', 'm365-login' ) ); $rows[ __( 'Status', 'm365-login' ) ] = $text; } if ( '' !== $oid ) { $rows[ __( 'Object ID', 'm365-login' ) ] = $oid; } $last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true ); if ( $last ) { $rows[ __( 'Last sync', 'm365-login' ) ] = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $last ); } foreach ( self::attributes() as $attribute ) { $target = (string) $attribute['target']; if ( 0 !== strpos( $target, 'm365_' ) ) { continue; } $value = (string) get_user_meta( $user->ID, $target, true ); if ( '' !== $value ) { $rows[ (string) $attribute['label'] ] = $value; } } ?>

$value ) : ?>