From 1708bae91a686e7154fce83b5a1e8d2163a4540f Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:34:19 +0000 Subject: [PATCH 1/9] Fix certificate storage being undone by the settings sanitiser store_certificate() and remove_certificate() wrote the option with update_option(), which runs the registered sanitize() callback in the admin (including admin-ajax). sanitize() expects raw form input, so it restored the previous certificate fields and encrypted the stored client secret a second time: "Generate certificate" did not keep the new certificate and broke an existing client secret. Internal writes now bypass the form sanitiser. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-m365-login-settings.php | 30 +++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index 8fcbfaf..a4b8bd6 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -19,6 +19,13 @@ class M365_Login_Settings { */ private $cache = null; + /** + * Set while the plugin writes already sanitised values (skips the form sanitiser). + * + * @var bool + */ + private $raw_write = false; + /** * Default settings. * @@ -199,8 +206,7 @@ class M365_Login_Settings { $all = $this->all(); $all['cert_private_key'] = $enc; $all['cert_certificate'] = $pair['certificate']; - update_option( M365_LOGIN_OPTION, $all ); - $this->cache = null; + $this->write( $all ); return true; } @@ -211,8 +217,22 @@ class M365_Login_Settings { $all = $this->all(); $all['cert_private_key'] = ''; $all['cert_certificate'] = ''; + $this->write( $all ); + } + + /** + * Stores already sanitised settings. + * + * The option is registered with sanitize() as callback, which expects raw form input + * (it would, for example, encrypt the stored client secret a second time). + * + * @param array $all Complete settings. + */ + private function write( $all ) { + $this->raw_write = true; update_option( M365_LOGIN_OPTION, $all ); - $this->cache = null; + $this->raw_write = false; + $this->cache = null; } /** @@ -379,6 +399,10 @@ class M365_Login_Settings { * @return array */ public function sanitize( $input ) { + if ( $this->raw_write ) { + return $input; + } + $defaults = $this->defaults(); $current = $this->all(); $input = is_array( $input ) ? $input : array(); From 4edf20bc45c8aab55f8d81ac505f6a76fc47c641 Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:34:30 +0000 Subject: [PATCH 2/9] Add Microsoft 365 user sync with roles, profile fields and deprovisioning New "User sync" tab that imports Microsoft 365 / Entra ID users as WordPress accounts and keeps them up to date: - Scope: whole tenant or the (nested) members of selected groups, guests optional, e-mail domain allow-list respected. Existing accounts are linked by e-mail address. - Roles: selectable default role plus a group -> role mapping (in addition to or instead of the default role, first match wins). Roles of pre-existing accounts are only managed on request. - Profile: selectable Graph attributes (names, job title, department, phones, address, language, ...) and the profile photo as avatar. - Deprovisioning: accounts disabled or deleted in Microsoft 365 (or removed from the sync groups) are deactivated or deleted; accounts deactivated by the sync are reactivated automatically. Deactivated accounts lose every sign-in path and all sessions. - Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning, abort on any Graph error, "deleted" only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. - Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`. - Users screen column with deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen. The Graph client gains paging, retry on throttling and user, group member and photo endpoints. The group picker is now reusable. Version 1.1.0, German translations (du/Sie), docs and audit addendum. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 19 + README.md | 134 +- assets/css/admin.css | 174 +++ assets/js/admin.js | 222 ++-- bin/make-pot.py | 4 +- docs/security-audit.md | 34 +- includes/class-m365-login-admin.php | 411 +++++- includes/class-m365-login-auth.php | 5 + includes/class-m365-login-graph.php | 234 +++- includes/class-m365-login-settings.php | 242 +++- includes/class-m365-login-sync.php | 1578 ++++++++++++++++++++++++ includes/class-m365-login.php | 10 +- languages/m365-login-de_DE.mo | Bin 33628 -> 51249 bytes languages/m365-login-de_DE.po | 1092 ++++++++++++---- languages/m365-login-de_DE_formal.mo | Bin 33637 -> 51316 bytes languages/m365-login-de_DE_formal.po | 1092 ++++++++++++---- languages/m365-login.pot | 1197 +++++++++++++----- m365-login.php | 8 +- readme.txt | 39 +- uninstall.php | 19 +- 20 files changed, 5532 insertions(+), 982 deletions(-) create mode 100644 includes/class-m365-login-sync.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 739ce8f..99887a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented in this file. The format foll [Keep a Changelog](https://keepachangelog.com/) and the plugin adheres to [Semantic Versioning](https://semver.org/). +## [1.1.0] – 2026-09-23 + +### Added +- User sync (new "User sync" tab): imports Microsoft 365 / Entra ID users as WordPress accounts – the whole tenant or the (nested) members of selected groups, guests optional – and links existing accounts by e-mail address. +- Default role plus group → role mapping with a Graph-powered group picker; mapped roles either added to or replacing the default role (first match wins, reorderable). Roles of accounts that existed before the sync are only managed on request. +- Selectable profile attributes (display name, first/last name, job title, department, company, office, employee ID, phones, address, language) and the profile photo, which replaces the Gravatar. +- Deactivation or deletion of WordPress accounts whose Microsoft 365 account was disabled, deleted or removed from the sync groups; automatic reactivation. Deactivated accounts cannot sign in at all (Microsoft, password, application passwords) and lose all sessions. +- Safeguards: dry run, safety stop above 20 % deprovisioning (at least 5 accounts), abort on any Graph error, deletion only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. +- Scheduled sync via WP-Cron (hourly, twice daily, daily), `wp m365-login sync [--dry-run]`, report of the last run in the settings. +- "Microsoft 365" column and deactivate/reactivate row actions on the users screen; read-only Microsoft 365 section on the profile screen. +- Graph client: paging, retry on throttling (429/503/504), user, group member and photo endpoints. +- Filters and actions for the sync (`m365_login_sync_*`, `m365_login_user_disabled`, `m365_login_user_enabled`). + +### Changed +- The group picker is reusable (security groups, sync groups, role mapping). + +### Fixed +- "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser). + ## [1.0.0] – 2026-09-22 ### Added diff --git a/README.md b/README.md index a071a9c..f0958d1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Gruppen-Beschränkung](#gruppen-beschränkung) - [Nur-Button-Modus & Fallback](#nur-button-modus--fallback) - [Eigene Login-Seite](#eigene-login-seite) + - [Benutzer-Sync](#benutzer-sync) - [Sicherheitskonzept](#sicherheitskonzept) - [Shortcode & Hooks](#shortcode--hooks) - [Fehlerbehebung](#fehlerbehebung) @@ -46,7 +47,8 @@ | | | | --- | --- | | 🔑 **Login per Microsoft** | Ein Klick auf der Anmeldeseite, Anmeldung bei Microsoft, zurück in WordPress – fertig. | -| 📧 **Zuordnung über die E-Mail-Adresse** | Es werden **keine Benutzer angelegt**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | +| 📧 **Zuordnung über die E-Mail-Adresse** | Der Login legt **keine Benutzer an**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | +| 🔄 **Benutzer-Sync (optional)** | Importiert Microsoft-365-Benutzer als WordPress-Konten – mit Standardrolle, zusätzlichen Rollen per Gruppen-Zuordnung, wählbaren Profilfeldern und Profilbild. In Microsoft 365 deaktivierte oder gelöschte Konten werden in WordPress deaktiviert oder gelöscht. | | 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. | | 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. | | 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. | @@ -170,7 +172,19 @@ Benutzer liefert Microsoft keinen `groups`-Claim mehr („Overage“); dann grei -Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365. +
+Zusätzlich für den Benutzer-Sync + +| Berechtigung (Anwendung, mit Administratorzustimmung) | Wofür | +| --- | --- | +| `User.Read.All` | Benutzer, Kontostatus, Profilfelder und Profilbilder lesen | +| `GroupMember.Read.All` | Nur nötig, wenn Sync-Gruppen oder Rollen-Zuordnungen verwendet werden | + +Außerdem muss im Tab *Verbindung* die **Tenant-GUID** eingetragen sein (nicht `organizations`/`common`). + +
+ +Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365 – von Hand angelegt oder vom [Benutzer-Sync](#benutzer-sync) importiert. --- @@ -272,6 +286,65 @@ Das Plugin funktioniert auch, wenn die Anmeldung nicht über `wp-login.php` läu Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, weil das Plugin durchgehend `wp_login_url()` verwendet. +### Benutzer-Sync + +Tab *Benutzer-Sync*. Legt WordPress-Konten für Microsoft-365-Benutzer an und hält sie aktuell – manuell per Knopfdruck, +automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI. + +**Welche Benutzer?** Ohne Auswahl alle Mitglieder des Tenants; optional nur Mitglieder bestimmter Gruppen +(verschachtelte Mitgliedschaften zählen). Gäste (B2B) nur auf Wunsch. Die Domain-Allowlist aus dem Tab *Sicherheit* gilt auch hier. + +**Was passiert pro Benutzer?** + +| Situation | Ergebnis | +| --- | --- | +| Kein WordPress-Konto vorhanden | Konto wird angelegt: Benutzername aus der E-Mail, Zufallspasswort, **keine E-Mail an den Benutzer**, Standardrolle + zugeordnete Rollen. Die Anmeldung läuft über den Microsoft-Button. | +| Konto mit derselben E-Mail existiert schon | Wird mit der Microsoft-Objekt-ID verknüpft, Profilfelder werden aktualisiert. Rollen bleiben unangetastet, außer *„Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten“* ist aktiv. | +| Bereits verknüpft | E-Mail-Adresse, Profilfelder, Profilbild und (bei importierten Konten) Rollen werden aktualisiert. | +| In Microsoft 365 **deaktiviert** | Wahlweise nichts tun, WordPress-Konto **deaktivieren** oder **löschen**. | +| In Microsoft 365 **gelöscht** | Wahlweise nichts tun, deaktivieren oder löschen. | +| Nicht mehr in den Sync-Gruppen | Wahlweise nichts tun, deaktivieren oder löschen. | +| Wieder aktiv in Microsoft 365 | Vom Sync deaktivierte Konten werden automatisch reaktiviert (von Hand deaktivierte nicht). | + +**Rollen.** Jeder importierte Benutzer bekommt die *Standardrolle*. Darunter lassen sich Microsoft-365-Gruppen per Suche +auswählen und je einer WordPress-Rolle zuordnen (z. B. „Redaktion“ → Redakteur). Zwei Modi: + +- *Zusätzlich zur Standardrolle* – der Benutzer hat danach mehrere Rollen. +- *Anstelle der Standardrolle* – die erste passende Gruppe der Liste gewinnt (Reihenfolge per ↑). + +Verlässt jemand eine Gruppe, wird die Rolle beim nächsten Lauf entfernt. Die Rollen importierter Konten verwaltet der Sync +vollständig – manuelle Änderungen werden überschrieben. + +**Profilfelder.** Frei wählbar: Anzeigename, Vor- und Nachname, Profilbild, Position, Abteilung, Firma, Büro, Personalnummer, +Telefon (geschäftlich/mobil), Adresse, Sprache. Namen landen in den normalen WordPress-Feldern, alles andere in User-Meta mit +dem Präfix `m365_` (z. B. `m365_department`) und wird auf der Profilseite angezeigt. Das **Profilbild** wird nach +`wp-content/uploads/m365-login-avatars/` geladen (Dateiname mit gesalzenem Hash statt Objekt-ID) und ersetzt überall den +Gravatar; es wird etwa einmal täglich pro Benutzer geprüft. Achtung: Avatare sind öffentlich sichtbar, wo WordPress sie anzeigt. + +**Deaktivierte Konten** können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort, +Anwendungspasswort oder bestehender Session (alle Sessions werden beendet). In der Benutzerliste zeigt die Spalte +*Microsoft 365* den Status; per Zeilenaktion lassen sich Konten auch von Hand deaktivieren und reaktivieren. + +**Löschen** braucht einen Benutzer, der die Beiträge übernimmt. Ohne Auswahl wird stattdessen deaktiviert – es gehen nie Inhalte verloren. + +**Schutzmechanismen** + +- **Testlauf:** zeigt vollständig, was angelegt, geändert, deaktiviert oder gelöscht würde – ohne etwas zu ändern. +- **Sicherheitsstopp:** Würde ein Lauf mehr als 20 % der verknüpften Konten (mindestens 5) deaktivieren oder löschen, + passiert gar nichts (Filter `m365_login_sync_deprovision_limit`). +- **Fehler = Abbruch:** Schlägt eine Graph-Anfrage fehl, bricht der Lauf ab, bevor irgendein Konto deaktiviert wird. + „Gelöscht“ gilt ein Konto nur, wenn Graph für genau diese Objekt-ID `404` liefert. +- **Geschützte Konten:** Administratoren, die schon vor dem Sync existierten, und das eigene Konto werden nie + deaktiviert, gelöscht oder umgestuft (Filter `m365_login_sync_protect_user`). +- **Sperre gegen Parallelläufe**, Protokoll der letzten Ausführung im Backend. + +**WP-CLI** – empfehlenswert für große Verzeichnisse oder exakte Zeiten per System-Cron: + +```bash +wp m365-login sync --dry-run # Testlauf +wp m365-login sync # echter Lauf +``` + --- ## Sicherheitskonzept @@ -284,7 +357,9 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, | Gefälschte Tokens | Signaturprüfung gegen Microsofts JWKS (`RS256` only; `alg=none`/HMAC werden abgelehnt), Schlüssel-Rollover wird automatisch nachgeladen. `iss`, `aud`, `tid`, `exp`, `nbf`, `iat` werden geprüft. | | Fremde Tenants | Bei konfigurierter Tenant-GUID **Tenant-Pinning**; sonst Issuer-Konsistenz mit `tid`. | | Kontoübernahme per E-Mail-Recycling | **Bindung an die Objekt-ID** (`oid`) beim ersten Login. | -| Unbefugte Konten | Kein Provisioning, optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). | +| Unbefugte Konten | Der Login legt keine Konten an; optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). Konten entstehen nur durch den explizit gestarteten bzw. aktivierten Benutzer-Sync. | +| Ausgeschiedene Mitarbeitende | Benutzer-Sync deaktiviert oder löscht Konten, die in Microsoft 365 deaktiviert/gelöscht wurden; deaktivierte Konten verlieren sofort alle Sessions und jeden Anmeldeweg. | +| Massen-Deprovisionierung durch Fehlkonfiguration | Testlauf, Sicherheitsstopp (> 20 % / min. 5), Abbruch bei jedem Graph-Fehler, „gelöscht“ nur bei `404` für die konkrete Objekt-ID, geschützte Administratoren. | | Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. | | Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). | | Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). | @@ -346,6 +421,38 @@ add_filter( 'm365_login_block_password_login', function ( $block, WP_User $user add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m365-login/callback' ); ``` +**Benutzer-Sync** + +```php +// Weiteres Graph-Attribut anbieten (landet in User-Meta "m365_cost_center") +add_filter( 'm365_login_sync_attributes', function ( $attributes ) { + $attributes['costCenter'] = array( 'label' => 'Kostenstelle', 'target' => 'm365_cost_center' ); + return $attributes; +} ); + +// Rollen pro Person anpassen (erste Rolle = Hauptrolle) +add_filter( 'm365_login_sync_roles', function ( array $roles, $oid ) { + return $roles; +}, 10, 2 ); + +// Daten für neu angelegte Konten (Argumente für wp_insert_user) +add_filter( 'm365_login_sync_new_user_data', function ( array $data, array $person ) { + $data['user_login'] = strtolower( $person['userPrincipalName'] ); + return $data; +}, 10, 2 ); + +// Weitere Konten vom Sync ausnehmen +add_filter( 'm365_login_sync_protect_user', function ( $protected, WP_User $user ) { + return $protected || in_array( 'shop_manager', $user->roles, true ); +}, 10, 2 ); + +// Sicherheitsstopp anheben (Standard: 20 % der verknüpften Konten, mindestens 5) +add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 10, $linked * 0.5 ), 10, 2 ); + +// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval +// Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled +``` + --- ## Fehlerbehebung @@ -361,6 +468,15 @@ add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m3 | *This WordPress account is linked to a different Microsoft account.* | Objekt-ID weicht ab. Wenn gewollt (neues Microsoft-Konto): User-Meta `_m365_login_oid` beim Benutzer löschen. | | *… not a member of a group that is allowed …* | Benutzer ist in keiner der ausgewählten Gruppen. | | *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. | +| *This account has been deactivated.* | Das Konto wurde vom Benutzer-Sync oder von Hand deaktiviert. *Benutzer → Zeilenaktion „Reaktivieren“* – ist die Person in Microsoft 365 noch deaktiviert, deaktiviert der nächste Sync sie wieder. | + +| Meldung im Sync-Protokoll | Ursache & Lösung | +| --- | --- | +| *Microsoft Graph refused the request …* | Anwendungsberechtigung `User.Read.All` (und bei Gruppen `GroupMember.Read.All`) fehlt oder keine Administratorzustimmung. | +| *The user sync needs a pinned tenant ID …* | Im Tab *Verbindung* die Tenant-GUID statt `organizations`/`common` eintragen. | +| *Safety stop: … accounts would be deactivated or deleted …* | Sync-Gruppen oder Tenant prüfen, Testlauf ansehen; bei gewollter Massenänderung das Limit per Filter anheben. | +| *… is protected … and was not changed.* | Bestehender Administrator oder eigenes Konto – bewusst ausgenommen. | +| Zeitüberschreitung beim Klick auf *Jetzt synchronisieren* | Der Lauf geht serverseitig weiter; Seite später neu laden. Für große Verzeichnisse `wp m365-login sync` verwenden. | Logging: Mit `WP_DEBUG` und `WP_DEBUG_LOG` schreibt das Plugin Fehlerdetails mit Präfix `[M365 Login]` nach `wp-content/debug.log`. Es werden nie Tokens oder Secrets geloggt. @@ -376,7 +492,8 @@ includes/ class-m365-login-crypto.php AES-256-GCM für das Client Secret class-m365-login-jwt.php RS256-Verifikation, JWKS → PEM class-m365-login-auth.php OAuth-Flow, Callback, Benutzerzuordnung, Nur-Button-Modus - class-m365-login-graph.php Client-Credentials-Token, Gruppensuche, checkMemberGroups + class-m365-login-graph.php Client-Credentials-Token, Paging, Benutzer/Gruppen/Fotos, checkMemberGroups + class-m365-login-sync.php Benutzer-Sync, Rollen, Profilfelder/-bilder, Deaktivierung, WP-CLI class-m365-login-button.php Ausgabe auf wp-login.php, Shortcode class-m365-login-admin.php Einstellungsseite, AJAX assets/ CSS/JS für Login-Seite und Backend (unminifiziert) @@ -412,7 +529,7 @@ Das Plugin bringt alles mit, was das Review-Team verlangt: `readme.txt` mit *Ext
Kann ich Benutzer automatisch anlegen lassen? -Nein, bewusst nicht. Der Admin entscheidet, wer ein Konto hat. Wer Auto-Provisioning braucht, kann es über den Hook m365_login_allow_user nicht nachrüsten – das wäre ein anderes Sicherheitsmodell. +Ja, mit dem Benutzer-Sync: Er importiert alle (oder ausgewählte) Microsoft-365-Benutzer vorab als WordPress-Konten. Der Login selbst legt weiterhin nie Konten an – wer nicht importiert oder von Hand angelegt wurde, kommt nicht rein.
@@ -427,7 +544,12 @@ Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Sup
Was passiert beim Deinstallieren? -Einstellungen, Caches (Transients) und die pro Benutzer gespeicherte Objekt-ID werden entfernt – auch in Multisite. +Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (m365_*) bleiben erhalten. Deaktivierte Konten sind danach wieder aktiv; wer sie sperren will, sollte sie vorher löschen. +
+ +
+Benutzer-Sync und Multisite? +Der Sync arbeitet pro Site: Neue Konten werden zur aktuellen Site hinzugefügt, „Löschen“ entfernt das Konto nur aus dieser Site. Die Deaktivierung gilt netzwerkweit, weil sie am Benutzer hängt.
diff --git a/assets/css/admin.css b/assets/css/admin.css index c627454..d38524d 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -945,3 +945,177 @@ color: #8a2424; border: 1px solid #f0b8bd; } + +/* User sync */ +.m365-card__subtitle { + font-size: 13px; + font-weight: 600; + margin: 24px 0 4px; +} + +.m365-picker { + margin-top: 12px; +} + +.m365-group-list--roles { + flex-direction: column; + align-items: stretch; +} + +.m365-group-chip--role { + border-radius: 8px; + padding: 6px 8px; +} + +.m365-group-chip--role .m365-group-chip__name { + flex: 0 1 auto; +} + +.m365-group-chip--role .m365-group-chip__id { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m365-field .m365-group-chip select { + width: auto; + min-height: 28px; + flex: 0 0 auto; +} + +.m365-group-chip__arrow { + color: var(--m365-muted); +} + +.m365-group-chip__up { + appearance: none; + border: 0; + background: #fff; + color: var(--m365-accent); + width: 22px; + height: 22px; + border-radius: 50%; + line-height: 1; + cursor: pointer; +} + +.m365-group-chip--role:first-child .m365-group-chip__up { + visibility: hidden; +} + +.m365-attributes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 20px; + margin-bottom: 12px; +} + +.m365-attributes code { + font-size: 11px; + color: var(--m365-muted); + background: transparent; + padding: 0; +} + +@media (max-width: 782px) { + .m365-attributes { + grid-template-columns: 1fr; + } +} + +.m365-sync-report-wrap { + margin-top: 16px; +} + +.m365-sync-report__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 10px; +} + +.m365-sync-report__status { + padding: 2px 10px; + border-radius: 999px; + font-weight: 600; + font-size: 12px; + background: #e6f4ea; + color: #1e6b31; +} + +.m365-sync-report__status.is-bad { + background: #fcf0f1; + color: #8a1f22; +} + +.m365-sync-report__dry { + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + background: #f0f6fc; + color: #0a4b78; +} + +.m365-sync-report__meta { + color: var(--m365-muted); + font-size: 12px; +} + +.m365-sync-report__counts { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 10px; + padding: 0; + list-style: none; +} + +.m365-sync-report__count { + margin: 0; + padding: 4px 10px; + border: 1px solid var(--m365-border); + border-radius: 6px; + background: #fff; + font-size: 12px; +} + +.m365-sync-report__count--disabled strong, +.m365-sync-report__count--deleted strong, +.m365-sync-report__count--errors strong { + color: #b32d2e; +} + +.m365-sync-report__details summary { + cursor: pointer; + font-weight: 600; +} + +.m365-sync-log { + margin: 8px 0 0; + padding: 8px 12px; + max-height: 360px; + overflow-y: auto; + background: #fff; + border: 1px solid var(--m365-border); + border-radius: 6px; + font-size: 12px; + list-style: none; +} + +.m365-sync-log__item { + margin: 0; + padding: 3px 0 3px 10px; + border-left: 3px solid #c5d9ed; + margin-bottom: 2px; +} + +.m365-sync-log__item.is-warning { + border-left-color: #dba617; +} + +.m365-sync-log__item.is-error { + border-left-color: #b32d2e; + color: #8a1f22; +} diff --git a/assets/js/admin.js b/assets/js/admin.js index a8cbb45..322fd04 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -221,101 +221,165 @@ } } ); - /* ---------------- Entra group picker ---------------- */ - var $groupList = $( '#m365-group-list' ); - var $groupResults = $( '#m365-group-results' ); + /* ---------------- Entra group pickers ---------------- */ var optionName = ( $( '#m365-tenant' ).attr( 'name' ) || '' ).replace( /\[tenant_id\]$/, '' ); + var guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; function escapeHtml( str ) { return $( '
' ).text( str || '' ).html(); } - function refreshGroupList() { - $groupList.toggleClass( 'is-empty', 0 === $groupList.children( 'li' ).length ); - } + $( '.m365-picker' ).each( function () { + var $picker = $( this ); + var field = $picker.data( 'field' ); + var mode = $picker.data( 'mode' ); + var $list = $picker.find( '.m365-picker__list' ); + var $results = $picker.find( '.m365-picker__results' ); + var $query = $picker.find( '.m365-picker__query' ); + var base = optionName + '[' + field + ']'; - function addGroup( id, name ) { - id = ( id || '' ).toLowerCase(); - if ( ! /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( id ) ) { - return; + function refresh() { + $list.toggleClass( 'is-empty', 0 === $list.children( 'li' ).length ); } - if ( $groupList.find( 'li[data-id="' + id + '"]' ).length ) { - return; + + function has( id ) { + return $list.find( 'li[data-id="' + id + '"]' ).length > 0; } - var $li = $( '
  • ' ).attr( 'data-id', id ); - $li.append( $( '' ).text( name || id ) ); - $li.append( $( '' ).text( id ) ); - $li.append( $( '' ).attr( 'name', optionName + '[allowed_groups][' + id + ']' ).val( name || id ) ); - $li.append( $( '' ) ); - $groupList.append( $li ); - refreshGroupList(); - } - $groupList.on( 'click', '.m365-group-chip__remove', function () { - $( this ).closest( 'li' ).remove(); - refreshGroupList(); - } ); - refreshGroupList(); - - function searchGroups() { - var query = $.trim( $( '#m365-group-search' ).val() ); - $groupResults.prop( 'hidden', false ).removeClass( 'is-error' ).html( '

    ' + escapeHtml( i18n.searching ) + '

    ' ); - - $.post( cfg.ajaxUrl, { - action: cfg.groupAction, - nonce: cfg.nonce, - query: query - } ).done( function ( res ) { - if ( ! res || ! res.success ) { - var msg = ( res && res.data && res.data.message ) || i18n.testFailed; - $groupResults.addClass( 'is-error' ).html( '

    ' + escapeHtml( msg ) + '

    ' ); - // Allow adding a pasted GUID even when Graph is unavailable. - if ( /^[0-9a-f-]{36}$/i.test( query ) ) { - $groupResults.append( buildResult( { id: query, name: query, type: '', description: '' } ) ); - } + function add( id, name ) { + id = ( id || '' ).toLowerCase(); + if ( ! guidPattern.test( id ) || has( id ) ) { return; } - var groups = res.data.groups || []; - if ( ! groups.length ) { - $groupResults.html( '

    ' + escapeHtml( i18n.noGroups ) + '

    ' ); - return; + var $li = $( '
  • ' ).attr( 'data-id', id ); + if ( 'roles' === mode ) { + $li.addClass( 'm365-group-chip--role' ); + $li.append( $( '' ).attr( 'aria-label', i18n.moveUp || '' ) ); } - $groupResults.empty(); - $.each( groups, function ( i, g ) { - $groupResults.append( buildResult( g ) ); + $li.append( $( '' ).text( name || id ) ); + $li.append( $( '' ).text( id ) ); + if ( 'roles' === mode ) { + $li.append( '' ); + var $select = $picker.find( '.m365-picker__role-template' ).clone(); + $select.removeClass( 'm365-picker__role-template' ).prop( 'hidden', false ).prop( 'disabled', false ).attr( 'name', base + '[' + id + '][role]' ); + $li.append( $select ); + $li.append( $( '' ).attr( 'name', base + '[' + id + '][name]' ).val( name || id ) ); + } else { + $li.append( $( '' ).attr( 'name', base + '[' + id + ']' ).val( name || id ) ); + } + $li.append( $( '' ).attr( 'aria-label', i18n.remove || '' ) ); + $list.append( $li ); + refresh(); + markDirty(); + } + + $list.on( 'click', '.m365-group-chip__remove', function () { + $( this ).closest( 'li' ).remove(); + refresh(); + markDirty(); + } ); + $list.on( 'click', '.m365-group-chip__up', function () { + var $li = $( this ).closest( 'li' ); + $li.prev().before( $li ); + markDirty(); + } ); + refresh(); + + function buildResult( g ) { + var $row = $( '
    ' ); + var $meta = $( '
    ' ); + $meta.append( $( '' ).text( g.name ) ); + if ( g.type ) { + $meta.append( $( '' ).text( g.type ) ); + } + $meta.append( $( '' ).text( g.id ) ); + if ( g.description ) { + $meta.append( $( '' ).text( g.description ) ); + } + var $btn = $( ' +
    + +

    + +

    + + +
    + +
    + +
      + $value ) : ?> + +
    • + + + + + + + + + + + + + +
    • + +
    + + + +
    +
  • + settings->is_configured(); + $next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK ); + $actions = array( + 'none' => __( 'Do nothing', 'm365-login' ), + 'disable' => __( 'Deactivate the WordPress account', 'm365-login' ), + 'delete' => __( 'Delete the WordPress account', 'm365-login' ), + ); + $selects = array( + 'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ), + 'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ), + 'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ), + ); + ?> +
    +
    +

    +

    + + + +
    +
    + + + + +

    + +
    +
    + + + +

    +

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

    + +
    + + +

    +
    + +

    +

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

    +

    +
    + $attribute ) : ?> + + +
    +

    +
    + +
    +

    +

    + + $label ) : ?> +
    + + + +

    + +
    + + +
    + + M365_LOGIN_OPTION . '[sync_reassign]', + 'id' => 'm365-sync-reassign', + 'selected' => (int) $s['sync_reassign'], + 'show_option_none' => __( '— Select a user —', 'm365-login' ), + 'option_none_value' => 0, + 'capability' => array( 'edit_posts' ), + 'echo' => false, + ) + ); + ?> +

    +
    + +

    +
    + +
    +

    +

    +
    + + +
    +

    +
    + report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?> +
    +
    +
    +

    -

    +

    @@ -807,11 +1126,13 @@ class M365_Login_Admin {
  • -
  • +
  • + render_sync_panel( $s ); ?> +
    @@ -846,7 +1167,7 @@ class M365_Login_Admin {
  • -

    +

    diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 757d9b3..810e99a 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -441,6 +441,10 @@ class M365_Login_Auth { $this->fail( 'no_user' ); } + if ( M365_Login_Sync::disabled_info( $user->ID ) ) { + $this->fail( 'account_disabled' ); + } + $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; // Entra group restriction. @@ -857,6 +861,7 @@ class M365_Login_Auth { 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ), 'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ), + 'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ), ); } diff --git a/includes/class-m365-login-graph.php b/includes/class-m365-login-graph.php index ba1f6e5..027c485 100644 --- a/includes/class-m365-login-graph.php +++ b/includes/class-m365-login-graph.php @@ -8,7 +8,7 @@ defined( 'ABSPATH' ) || exit; /** - * Obtains app-only tokens via client credentials and queries groups. + * Obtains app-only tokens via client credentials and queries users and groups. */ class M365_Login_Graph { @@ -98,6 +98,63 @@ class M365_Login_Graph { return (string) $body['access_token']; } + /** + * Performs an authenticated Graph request and returns the raw HTTP response. + * + * Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504). + * + * @param string $method HTTP method. + * @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links). + * @param array|null $json JSON body for POST requests. + * @param array $headers Extra headers. + * @return array|WP_Error Response array from wp_remote_request(). + */ + private function raw_request( $method, $path, $json = null, $headers = array() ) { + $url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path; + if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) { + return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' ); + } + + for ( $attempt = 1; ; $attempt++ ) { + $token = $this->app_token(); + if ( is_wp_error( $token ) ) { + return $token; + } + + $args = array( + 'method' => $method, + 'timeout' => self::HTTP_TIMEOUT, + 'headers' => array_merge( + array( + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ), + $headers + ), + ); + if ( null !== $json ) { + $args['headers']['Content-Type'] = 'application/json'; + $args['body'] = wp_json_encode( $json ); + } + + $response = wp_remote_request( $url, $args ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 401 === $code ) { + $this->flush_token(); + } + if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) { + $wait = (int) wp_remote_retrieve_header( $response, 'retry-after' ); + sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) ); + continue; + } + return $response; + } + } + /** * Performs an authenticated Graph request. * @@ -105,31 +162,10 @@ class M365_Login_Graph { * @param string $path Path relative to the v1.0 base (with query string). * @param array|null $json JSON body for POST requests. * @param array $headers Extra headers. - * @return array|WP_Error Decoded JSON. + * @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data. */ private function request( $method, $path, $json = null, $headers = array() ) { - $token = $this->app_token(); - if ( is_wp_error( $token ) ) { - return $token; - } - - $args = array( - 'method' => $method, - 'timeout' => self::HTTP_TIMEOUT, - 'headers' => array_merge( - array( - 'Authorization' => 'Bearer ' . $token, - 'Accept' => 'application/json', - ), - $headers - ), - ); - if ( null !== $json ) { - $args['headers']['Content-Type'] = 'application/json'; - $args['body'] = wp_json_encode( $json ); - } - - $response = wp_remote_request( self::GRAPH_BASE . $path, $args ); + $response = $this->raw_request( $method, $path, $json, $headers ); if ( is_wp_error( $response ) ) { return $response; } @@ -137,18 +173,156 @@ class M365_Login_Graph { $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( 401 === $code ) { - $this->flush_token(); - } if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) { - $graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code; - $message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : ''; - return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) ); + return $this->error_from( $code, $body ); } return $body; } + /** + * Builds a WP_Error from a failed Graph response. + * + * @param int $code HTTP status. + * @param array|null $body Decoded body. + * @return WP_Error + */ + private function error_from( $code, $body ) { + $graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code; + $message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : ''; + return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) ); + } + + /** + * Whether a Graph error means "object does not exist". + * + * @param WP_Error $error Error. + * @return bool + */ + public static function is_not_found( $error ) { + $data = $error->get_error_data(); + return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status']; + } + + /** + * Follows @odata.nextLink until every page of a collection is read. + * + * @param string $path First page (relative path). + * @param array $headers Extra headers. + * @return array[]|WP_Error All items. + */ + private function collect( $path, $headers = array() ) { + $items = array(); + $next = $path; + $pages = 0; + while ( '' !== $next ) { + if ( ++$pages > 1000 ) { + return new WP_Error( 'graph_paging', 'Too many result pages.' ); + } + $result = $this->request( 'GET', $next, null, $headers ); + if ( is_wp_error( $result ) ) { + return $result; + } + if ( isset( $result['value'] ) && is_array( $result['value'] ) ) { + foreach ( $result['value'] as $item ) { + if ( is_array( $item ) && ! empty( $item['id'] ) ) { + $items[] = $item; + } + } + } + $next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : ''; + } + return $items; + } + + /** + * Lists every user of the tenant. + * + * @param string[] $select Properties to read. + * @return array[]|WP_Error + */ + public function list_users( $select ) { + return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' ); + } + + /** + * Lists the users that are (directly or through nested groups) members of a group. + * + * @param string $group_id Group object ID. + * @param string[] $select Properties to read. + * @return array[]|WP_Error + */ + public function list_group_users( $group_id, $select ) { + if ( ! M365_Login_Settings::is_guid( $group_id ) ) { + return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' ); + } + return $this->collect( + '/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true', + array( 'ConsistencyLevel' => 'eventual' ) + ); + } + + /** + * Reads a single user. + * + * @param string $oid User object ID. + * @param string[] $select Properties to read. + * @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore). + */ + public function get_user( $oid, $select ) { + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); + } + return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) ); + } + + /** + * Metadata of a user's profile photo (prefers the 240×240 rendition). + * + * @param string $oid User object ID. + * @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo. + */ + public function photo_info( $oid ) { + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); + } + $base = '/users/' . rawurlencode( strtolower( $oid ) ); + foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) { + $meta = $this->request( 'GET', $path ); + if ( is_wp_error( $meta ) ) { + if ( self::is_not_found( $meta ) ) { + continue; + } + return $meta; + } + $etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : ''; + return array( + 'path' => $path, + 'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ), + ); + } + return null; + } + + /** + * Downloads photo bytes. + * + * @param string $path Photo path returned by photo_info(). + * @return string|WP_Error Binary image data. + */ + public function photo_bytes( $path ) { + $response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) ); + if ( is_wp_error( $response ) ) { + return $response; + } + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + if ( 200 !== $code ) { + return $this->error_from( $code, json_decode( $body, true ) ); + } + return $body; + } + /** * Searches groups by display name. * diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index a4b8bd6..5852f00 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -34,36 +34,50 @@ class M365_Login_Settings { public function defaults() { return array( // Connection. - 'tenant_id' => '', - 'client_id' => '', - 'client_secret' => '', // Stored encrypted. - 'auth_method' => 'secret', // 'secret' or 'certificate'. - 'cert_private_key' => '', // PEM, stored encrypted. - 'cert_certificate' => '', // PEM (public). - 'prompt' => 'select_account', + 'tenant_id' => '', + 'client_id' => '', + 'client_secret' => '', // Stored encrypted. + 'auth_method' => 'secret', // 'secret' or 'certificate'. + 'cert_private_key' => '', // PEM, stored encrypted. + 'cert_certificate' => '', // PEM (public). + 'prompt' => 'select_account', // Security / matching. - 'upn_fallback' => 1, - 'bind_oid' => 1, - 'allowed_domains' => '', - 'allowed_groups' => array(), // id => display name. - 'remember_me' => 0, + 'upn_fallback' => 1, + 'bind_oid' => 1, + 'allowed_domains' => '', + 'allowed_groups' => array(), // id => display name. + 'remember_me' => 0, // Button-only mode. - 'button_only' => 0, - 'fallback_key' => '', + 'button_only' => 0, + 'fallback_key' => '', // Button appearance. - 'button_text' => __( 'Sign in with Microsoft', 'm365-login' ), - 'button_icon' => '', // Empty = bundled Microsoft logo. - 'button_show_icon' => 1, - 'button_bg' => '#2f2f2f', - 'button_bg_hover' => '#1a1a1a', - 'button_color' => '#ffffff', - 'button_border' => '#2f2f2f', - 'button_radius' => 4, - 'button_position' => 'below', - 'divider_text' => __( 'or', 'm365-login' ), + 'button_text' => __( 'Sign in with Microsoft', 'm365-login' ), + 'button_icon' => '', // Empty = bundled Microsoft logo. + 'button_show_icon' => 1, + 'button_bg' => '#2f2f2f', + 'button_bg_hover' => '#1a1a1a', + 'button_color' => '#ffffff', + 'button_border' => '#2f2f2f', + 'button_radius' => 4, + 'button_position' => 'below', + 'divider_text' => __( 'or', 'm365-login' ), // Custom login pages. - 'custom_login_url' => '', - 'inject_form' => 1, // Add the button to wp_login_form() output. + 'custom_login_url' => '', + 'inject_form' => 1, // Add the button to wp_login_form() output. + // User sync. + 'sync_enabled' => 0, // Scheduled sync via WP-Cron. + 'sync_interval' => 'daily', + 'sync_guests' => 0, + 'sync_scope_groups' => array(), // id => display name; empty = whole tenant. + 'sync_default_role' => 'subscriber', + 'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order. + 'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role). + 'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync. + 'sync_attributes' => array( 'displayName', 'givenName', 'surname' ), + 'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete. + 'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete. + 'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete. + 'sync_reassign' => 0, // User ID that receives content of deleted users. ); } @@ -80,6 +94,13 @@ class M365_Login_Settings { return $this->cache; } + /** + * Drops the cached settings (after the option was written). + */ + public function flush() { + $this->cache = null; + } + /** * Returns a single setting. * @@ -309,7 +330,47 @@ class M365_Login_Settings { * @return array */ public function allowed_groups() { - $raw = $this->get( 'allowed_groups', array() ); + return self::guid_map( $this->get( 'allowed_groups', array() ) ); + } + + /** + * Groups that limit the user sync (lowercase GUID => name); empty = whole tenant. + * + * @return array + */ + public function sync_scope_groups() { + return self::guid_map( $this->get( 'sync_scope_groups', array() ) ); + } + + /** + * Group → role mapping in priority order. + * + * @return array lowercase GUID => array( 'name' => string, 'role' => string ). + */ + public function sync_role_map() { + $raw = $this->get( 'sync_role_map', array() ); + $out = array(); + if ( is_array( $raw ) ) { + foreach ( $raw as $id => $row ) { + $id = strtolower( (string) $id ); + if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) { + $out[ $id ] = array( + 'name' => isset( $row['name'] ) ? (string) $row['name'] : $id, + 'role' => (string) $row['role'], + ); + } + } + } + return $out; + } + + /** + * Keeps GUID keys (lowercased) of an id => name array. + * + * @param mixed $raw Stored value. + * @return array + */ + private static function guid_map( $raw ) { $out = array(); if ( is_array( $raw ) ) { foreach ( $raw as $id => $name ) { @@ -449,8 +510,8 @@ class M365_Login_Settings { // Certificate: keep the stored pair unless a new one is pasted or removal is requested. $out['cert_private_key'] = $current['cert_private_key']; $out['cert_certificate'] = $current['cert_certificate']; - $pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : ''; - $pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : ''; + $pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : ''; + $pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : ''; if ( ! empty( $input['cert_remove'] ) ) { $out['cert_private_key'] = ''; $out['cert_certificate'] = ''; @@ -485,26 +546,12 @@ class M365_Login_Settings { $out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1; $out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1; - $domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : ''; - $domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains ); + $domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : ''; + $domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains ); $out['allowed_domains'] = trim( (string) $domains ); // Allowed groups: GUID => name. - $groups = array(); - if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) { - foreach ( $input['allowed_groups'] as $id => $name ) { - $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); - if ( ! self::is_guid( $id ) ) { - continue; - } - $name = sanitize_text_field( wp_unslash( (string) $name ) ); - $groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 ); - if ( count( $groups ) >= 100 ) { - break; - } - } - } - $out['allowed_groups'] = $groups; + $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() ); // Button-only mode + fallback key. $out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; @@ -551,11 +598,114 @@ class M365_Login_Settings { $divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : ''; $out['divider_text'] = mb_substr( $divider, 0, 40 ); + $out = $this->sanitize_sync( $input, $out ); + $this->cache = null; return $out; } + /** + * Sanitises the user sync settings. + * + * @param array $input Raw input. + * @param array $out Settings sanitised so far. + * @return array + */ + private function sanitize_sync( $input, $out ) { + $defaults = $this->defaults(); + + $out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1; + $out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1; + $out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1; + + $interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : ''; + $out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval']; + + $mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : ''; + $out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode']; + + $role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : ''; + $out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role']; + + $out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() ); + + $map = array(); + if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) { + foreach ( $input['sync_role_map'] as $id => $row ) { + $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); + if ( ! self::is_guid( $id ) || ! is_array( $row ) ) { + continue; + } + $map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : ''; + if ( '' === $map_role || ! get_role( $map_role ) ) { + continue; + } + $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : ''; + $map[ $id ] = array( + 'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ), + 'role' => $map_role, + ); + if ( count( $map ) >= 100 ) { + break; + } + } + } + $out['sync_role_map'] = $map; + + $attributes = array(); + if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) { + $known = array_keys( M365_Login_Sync::attributes() ); + foreach ( $input['sync_attributes'] as $attribute ) { + $attribute = sanitize_text_field( wp_unslash( (string) $attribute ) ); + if ( in_array( $attribute, $known, true ) ) { + $attributes[] = $attribute; + } + } + } + $out['sync_attributes'] = array_values( array_unique( $attributes ) ); + + foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) { + $action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : ''; + $out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ]; + } + + $reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0; + $out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0; + + $deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true ); + if ( $deletes && ! $out['sync_reassign'] ) { + add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' ); + } + + return $out; + } + + /** + * Sanitises a GUID => name list posted by a group picker. + * + * @param mixed $raw Raw input. + * @return array + */ + private static function sanitize_group_list( $raw ) { + $groups = array(); + if ( empty( $raw ) || ! is_array( $raw ) ) { + return $groups; + } + foreach ( $raw as $id => $name ) { + $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); + if ( ! self::is_guid( $id ) ) { + continue; + } + $name = sanitize_text_field( wp_unslash( (string) $name ) ); + $groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 ); + if ( count( $groups ) >= 100 ) { + break; + } + } + return $groups; + } + /** * Checks a GUID. * diff --git a/includes/class-m365-login-sync.php b/includes/class-m365-login-sync.php new file mode 100644 index 0000000..5eb1022 --- /dev/null +++ b/includes/class-m365-login-sync.php @@ -0,0 +1,1578 @@ +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 ); + + 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. + $photos = 0; + $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_on && ! $this->dry ) { + $photos += $this->maybe_sync_photo( $result, $oid, $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']; + } + } + + 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 ) { + $this->delete_photo( $user->ID ); + wp_delete_user( $user->ID, $reassign ); + } + 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 */ + /* ------------------------------------------------------------------ */ + + /** + * Refreshes the profile photo when it was not checked recently. + * + * @param WP_User $user User. + * @param string $oid Object ID. + * @param int $done_so_far Photo checks done in this run. + * @return int 1 when Graph was asked, 0 otherwise. + */ + private function maybe_sync_photo( $user, $oid, $done_so_far ) { + /** + * Maximum number of profile photo checks per sync run (the rest follows in later runs). + * + * @param int $limit Limit. + */ + if ( $done_so_far >= (int) apply_filters( 'm365_login_sync_photo_limit', 200 ) ) { + return 0; + } + $stored = get_user_meta( $user->ID, self::META_PHOTO, true ); + $stored = is_array( $stored ) ? $stored : array(); + + /** + * Seconds between two photo checks of the same user. + * + * @param int $interval Interval. + */ + $interval = (int) apply_filters( 'm365_login_sync_photo_interval', 20 * HOUR_IN_SECONDS ); + if ( ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) { + return 0; + } + + $info = $this->graph->photo_info( $oid ); + if ( is_wp_error( $info ) ) { + /* 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, $info->get_error_message() ) ); + return 1; + } + + if ( null === $info ) { + if ( ! empty( $stored['file'] ) ) { + $this->delete_photo( $user->ID ); + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + } + update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); + return 1; + } + + if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $info['etag'] && file_exists( self::photo_path( $stored['file'] ) ) ) { + $stored['checked'] = time(); + update_user_meta( $user->ID, self::META_PHOTO, $stored ); + return 1; + } + + $bytes = $this->graph->photo_bytes( $info['path'] ); + 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 ) ); + return 1; + } + + $file = $this->store_photo( $user->ID, $oid, $info['etag'], $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 ) ); + return 1; + } + + 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' => $info['etag'], + 'checked' => time(), + ) + ); + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + return 1; + } + + /** + * 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. + * + * @param int $user_id User ID. + */ + private 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 ) : ?> + + + + + + +

    + settings = new M365_Login_Settings(); $this->graph = new M365_Login_Graph( $this->settings ); $this->auth = new M365_Login_Auth( $this->settings, $this->graph ); + $this->sync = new M365_Login_Sync( $this->settings, $this->graph ); $this->button = new M365_Login_Button( $this->settings ); if ( is_admin() ) { - $this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph ); + $this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph, $this->sync ); } add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) ); diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index 36e39e263882cc9d7f60b69252c116669296e58e..dab20df0a1b57c0d8aac96989fc0a58243cbb6af 100644 GIT binary patch delta 21451 zcma)@37i~No%r8`5ONVhLhg`MPG-nV=YRk?FqvU;K@x@>gg_wFOxH|TrZ2jyXEF&= z;S>*+qXZR!K#T~Y9JT^0hsTJn%7TEpD$2S+cGq1I-Tmvb3cBCldsW@lGlai<@|ka) zuipFJ@4e>dHw<~~mSgbIrdkHPKm zPIw&r9oz-qh9ls1#3Li#4dwnGDD4&IBY$b2mkaX3_2C0Y;7y$W2V95f_MQ@s0Lwa- z>n}rT@F&oMuR|I6&<3NTv2Zl!2`DO90nw(~3gx{#6ro#@Jz_>g%AD{ z6v-Tc((}JSv58@)Ds?`b59Px>@DuPF*aoN4=*M6;l=t3-B2m?7Y-v0k#_3X@1Kv?9 zA@-rV;Ba^Y90_lRtKpsD{a2yb#GfFZq)up3>I_&9uZ6pzNa%Ig3V#jXgo%Xt;Piz? zLUZ7Ex$ZY}z&|w#AG#W@fTEjc;BD}mP)58RQAHrt%&rsT#wN$AO!gcUE_#7My&qZvvi2q;0!E0Rj4Llxx|4gOE!=FMNLA?pZ z3r;-CxX%K3E9c*Y5-+wbGaa}Yp1}D%5W7_mL!_>L1T8p}ho$55AeYnkn+}}=r*eKNWKe1^l#x9Qc~pH3N_+1>k;G}d zEXKMMio_l0Kg&Um0}*Xv6=4-Fg=zR3=)j$;jr)8JUdj2-A=*$ok@E-PO>j3H)uL28 z?1m@87hn?p5`Gu1IR~}D6Izw}G<={H`LE`nb&XOx;Pvo4_zSoeHt=U6%tJZ<5Py2$ z3lNuAYcP;QFbn6w6W5y&p9v?+btn@05R{Q0f-~X6a0Yx~y>E2)W_aNp_+f5L+hAGvSv5}Zh`f17nF`&4;R6E z;VAeL6czpgehL0R*bP6oO}FE#@!L%g=R$dL0VGDKU2qz_8QujSg3{6T7n+c|17e?Q zHmUBBl7J z&gKs>Mco1C!tcWb{2fF~Y9@-8_cL%Yd=RdNuR_uNbPPi7uYe0+FFcR$tIu#S8@>hc z3^nc&iA;C|ybb<46eHWe%lLgA7Szu9G$_V&6@TQtpF{ehW?hDU;XWwsz6Qs^y4`pL zJPDo(uZ6z6@Gu7=n&U1vH^#$>oPPkyi`zq{pp1Aw6l3~46j442r@%L$*u-CeheRib##9c{2~;cKNs?cP>k($I1`Re8c{bw zJWyQ*m%#_&7I_XXfoC{IMK0XO`4dpm@{&&9^kjLbxv&;a=Ei0yBkF>~;ME~-40$sY zqq_sng9qUd_*{7ZAE1ozMJN(^HRKymKKvFO3;)LtZw&7;H^xD^F%_N!7lh}lp}6A( z;dMK_-UBCa{X?(?-VMK8N63Yuy4zh7${&EDx(RzsC~t)#LEq-!WDc%|r@@0zMD_}t z3Ws$Y(anN$IL|@MMFrvY|Ayi%&1vHu9q?MtKMAG1=^0(;t2QW-y&F!4kHbZLU%kRX zfeS-1&5y%gNQ_XUbLNH1p~ZP0JPY0lN5fa(DELeG0r(r(1Q+B@hjzl*obQLy@q6HU z_)WMFj=DlRfc{r-a2^-ZP#Sy^iuHdB77*pD@IKBr7fm4f9hB$(49COcJX5SrgKu#D zAe52bTr!e;01o5)Rmd%M1d4I*=;1owS2+%(!5%mkUIRzMAUwYZKE?SX(1zW;MwkBx z7jym&ya6WmnvQ=3PT>4`cnmxO55a$h55N!inSi$hSMae5wTpve;kP08)bmiRcI|%C zkv1sSeo4qAln!U$Rj>~Zg>S=q;k&RG$=!3MF~W&FB=0vsQQZSzGUh(2JY2_xFT>&R9k>)8`w^3^RzMMP z7d!**h4S1ZP;~kvY=qCjjqtzV>u}XaO$X+D%t$B!#r;-7B;u3_eEyS^ zS@30u=c>aP6dtH%Vi+=jPs7i`Z^Pv<{b@5f{T%f7aN+d3_!vA0#o9-btan2Tz6B3J z5nUe3`FlA1AYz3-h8fs+ud((!p$!XXb`^C`SGwTm;=iN__|3c?kK7d$fPX z=>AqHqWLM50P$xiqMv-&=yV=j!1*R9*ZbfkcmrfG>K-@-E@!y=;0Aad{1%kwH{5R| zcp>~8=a=8_8~+{7SuYpnLmS=$Mbr{6=YWw=Mw);#;9c+ldR2=Ik?RZ&{^1Ko^#ArnQ@y?mhw)(Z!)8S5;aJXhz>zQo zPlQEyExaBsr=1ax7%ymj)Of{CD3a`gqLS;N4D2901^N$$54;4Ia^bg7dOVkI;-Bht z_&i(>Q*gqU%=N3FbnrH~41Nlp1z&*@5l5Gq6~R;CaqyS$c=+$I8U7JY5dS~x%O+26 zhHJTT1(XM$gfhZ!L+SBPpnTv6Tnwkt`VzPao(>N{8R=KxLijS24i9TnksiTVOlqe5(p)Vra&Vh)$05NEF0Ak3h;Tz_| zuRzgZ_cu+tEy7`(`*1G20sas^1jWh^F#Iv_E-1G25Uht!!5Q#ZP*ix#w-890gQedx zk?9az%lR*$h12F||$hr{*XHXXSDifvp9#k#wo4Cop-3?2-5KNPR|B9wtV z3FpACf7>@{^H*Gu0P$-$3jPtw3q!tRUN|0#geE{S$~jP;UmCIniYm^BqJqnzJm*1C z#SP*0Pr;Ko{~{DgebeVajOR6|<}IFEY_#Sr(SJ{yo3x7h((>?9d&#rBlw;W)9l280 zv)rPUFXTL@!*h~Wy`9XsS-0pFY%f=^_}7;!!QO22e1m0YlUA=?w6ZzR>PXqyE+^?H z!cRnv(sVkhpJ;cij)G&;xaq*a2hBaJUi#ZGu$iP|cX)1(ynWOs(vIi&18*~;PB+b` zDOd%^x06<9A(yc>xgCXEG1uu?r=78s z(U>QSJaV)ripwoKnPhx?-bn%M zzHhf9)!L2?XoJUk?6e!ohEWxq4#(|r__8kw$$E~lx}uZTM#uXy^cstdE8fi(JrQ57 z(@OFRwqQE@KB`M%u<4wQSZf~jEu&^6DaLDu9d$Eymm{`bV=YCyXTa;>+XW|+JIbxl zLd-|edDVFJ;U>p&v$Zy2*oMN1uGYftO`G99P_h{ zp@0T`Mm2}to^-VelY^eH>Wgkywu%0=r*j=By&-saOxuKH()HY2)=pc6T-qrbD-T|p zHNLJGoOi;Si&34M$>$25)m3teo_2_$Ro}9trGd#jcQbZYq$@)+Euh9QN))+jdzK?c zCwA?0<_hM?Vqdl+xaovTR)=;j=VJdDXPTarvRRBYVYQ_lyT}^_JVL}MgR-qor`IBg zl{}}I2>$hid44{hcIi5%I7qlf%UHPFv}2ly;#j>IoRe%6`w$7yQNE&eQgwDlAzksT zT(-k$NLZ~g)!V)iebMT$vtoAAitZ^E$Y_on2(&fUHrYsb@^-PJ=&(f{ z)m!qm1Y{e@h26^$;N48dNxDS4bYEhdwIk&i$Im;3B5xx}ZI4GY$40F#E%d4pi?%90 znkbJL`^b>dMK>#v39rgJ<*$r;w{D2*l^;2&scvOtOj)OgIAtvjQ<#3e6 zggPge$=lh!V9dlfk0mt~`hvG7-r~~*REazWY0tu7EcrlROmzfa6P8g^u_Kpv8nuq` zo&mwFD3P?*r5den$H^0}wXKU>^9A2zl1|r2CyBs$F%`)pTI|(0rN?C4FE&nX;JpsY zsW={84kO4QA52)AvE7PybU6~EmK5j*sU?z={w_h*^n8VtcXD}Ra%5FvM#0rhll*f^ zMUU6gxh@2e#|Kp_*`!ZZZ8LVLbvsDDu?LdzV?KtRTT*C*$&kC9b}8*?$?207oGp*3 zg?{9?A@TdDAIsYXFGF?+9-TDB595?5A&P2MhZ7}7a#F2sF`u^ktgM}3kZzBg#N<3@ zucumb;#w(!1&1ajSRGEmb34V69IHOTS%YfDfCwrPwM&iKTXBn#WKR9Jd^H8duWZ+CWZ~R)R<5Esit#u1TCJ|2gktuC; zt?MLEo7Q;IDM)Y+jmXXRP<=>*!^m~z$9L?km$1(7>=buN8Z{?^zfAr8>{0=9TA#yW z=~#yF#G9e#=#ZOSRsPMii9@vN4dFF0v!XaP@+FR1 zDYCOw6yJ|hQ^RdD(w)^(L4sAy+Y%||Vy6~I#`ZeIF08IX4xh$F6DL4dSv6BtdE(MC4V9$)^5$ERGEzkARPVo5|>sly-OOog6i$?i`2dSj3bacb* z-XUt0L=c&uHq+p-xlP+lqLGMOw0fO%I-xe|Di9#^ ze8K5)onEyu*M;vVE!?%x$ADs|BwKUA!zjhil8N%3nK#$@G6*}A_|f}sN!$}{h%Fi8 zMh-4UxItbIa~S!yN0K_v>0z3jrC$-pGc{UR7KCN*NbM*h>qPK->&@T?vo?%y3WZ#O znMpAiKKoEGe)ft9MQiqUOf|8EnlsUyPS2Kr8@x2{)Zn(+|1z>&W)(W$1^GEEhvAN- zg!1R-Tt8$~=!LqmruiRi)Gi?=CYGnORC_MjC!;gnj$3DN@BCBeNHt)*q*hDfUZI+T zN9Qm1H|L1zOuMKGLiLB%VWN!Dy4ozIhDSujv(9hdL#a*3Qur|wi$R%Chn8%*FDi#5 z>V|4?3o<{{vni7UeMYP&QY%FI+%`VXwPEcDXvK;?2EWk*P6P`Vy)!(Tnw0AoKQd(a7UznROJ9P=7ED=MUk%T+ zpHESW>QF`xi+Fp(a;wHaxQ(#{ZL?4EjSh^cwemy=2^F=j%eC322AExqQYNa?OjIX} zF;)vJ5s;n$>UkoCPizXQi_0{+;v>2Ag-%gzDVazvTCF*;>=NY=)pG}K)>nHgOAgcJ zw-|+<_mNsWnFtqGS8CbsaeP)z=o8xIi&T(IWWu?g zEYiGJW9Y;>N6bXGBa#Z;qv9AQJ?M~{)dTUhWaLRR(Ww>IG2 z@`>F^hcMFP7MZ^6Hhm|ikWZQW)<}g!Y3pwqftUgKvIZox1|#E`D3&a;=0)KzoXb^2 zT3?x$Ep2FwDnUtl?=6+e@I`exiO9rOyYViqGFd0FnypUiXs!-$6C%+SdJ1gIv{y82 z!n`yVPHman=wft07lI$1_8)$=HC@|!UcqIhPO>oGz%<6yk%CAjlCM_k$W(`BQk`as9M5L5 z)F*Rta*U)o?Lo7(+Gp#W7p@jjHu9B>3Jj(SpL z>gZ&HFQ5L~DQ0z|c0wtADF%;!z*(Z33RfDXQpFtg#5t;Ovn`*oWhISkwJ~o z%HU$teXV*DAX!&!V~|kJhHB2HPLNT^S_QGT z)9orrn(A}BM6iF^nJeOTiIoiNdD8W%A{4YW6hF|^tZ_LOLR#t#6eeRf$DkKAgPMFt=YYKtC~(NU%E8g zs^8Jpbk4>NgddTZcqz{sm#s|!2@9)WflJ$WC895X`Vn>!KQ&M^HRV!DFJB ziH?oBjfIk!^#$>oC?=S0XlaSoM5NLuq#o>D@f<6s6~m<@cK9llu!|BAm8s=XTK&51 z8(JG87dj`VW4m3qk1&|N5T$ffG1F1Kpe)j?T7e9s`Z=++X#?k-JYSi1E?b$JiM%Gg-@S4~-N;@lH);`9A%eqa|K8W-s@g6~=rZo*R#Y2QMyNZ{o2slK8}*QX-@71nzD$u_8J9%x6au~SO&;IY*Q)|h!R!;r=7XdxlC zB0-byW%4?h$~(GlDKBXLSe+lnRu$7K$9g?JqBv`f6{*Uq*PuIQ`AX^eS5cYFt<0(i zjzBckVXH~-agN& zubG!wOn0QLLP3t(<*=$?UTv^S_yX7tG*(L3F*=_}o+zFYZJ(rR(oSlEZe?9#x%F9Tkx)eUDnA+eZ_gUS{^nTsUBUEx_#5O-_*+d2s2=^g?K}CmSoedU~QBw8p|o9?NU26 zyJa3_o4NmKS)Ixg#;_gJ-F8`+B4{QfQ?kg--P!)9^PS;9mKr7ODzCVAwID>c5LFW$hHju5^7vHf1x<5QTL)z5Rz%X?8v7 z8&})O$5g$>U3~GExcw0-Jh6JQI1J7Vu4*&2wnpv6j)g-}m$tF{(A);SGiU%ss|EWL zC-e)oE#k3x2gj=#@1WM0jdwEC!Ci}6uX&3YRCTO@!5A?($(6`TKND_7wh}}t`6V;h?2HZZwR$U2n8!DDOZHmqa5Y-uyE)l1YR z(T;dlCiv6-X;fD(0X*Em)FDtudTJDmpQd%t$mq3y`CRn71qsh0aK|(In32^uksP}F zAE)djOh|>)MORIv$T%tNN9&54M-KP_J|O-ly{a}ZW7n73%(fp(RPs=BRzd+vE{5A& zo-RRj;L?eN-v}^ml4KTjkL6&ZfvBPl8KR3?Gg_qQy}Ae5svD*FiUy+tgVtFkRVK@5>l8E4L7~JpfF4ua zGqvKv;Ar_~?8Y~0HGv`qy%t3-Q((xll~bz7&nf7LZM@;)QYQ;`RkM)pnfzf!u|kii z*{?(ir_h;8Bg$~NHIi&8c^(zHTB9o+i%knR+q9Jo>Zon~hr2}8>`_K$;$*88br=Av z1L0=%Nl!JzPMCOL^YPtLCXiWKV4*Ti2V>i&&8c-@id~&!XtSC0>ZZ}I3U2P0WG~#N zCwFX7R(eIy5E)eRQf%U;EyXB8HD~wfdQSF4w-Mm7RHd$yZQ3ULqMI2gRRdNOT+%kH zaT~TmX75zdfp$La*UW!(30Nuqg8gmDF~;NUbhgCr3<~Af+J@Kp=W1W8gjBj&u~`(V zEey9|2CD6h-$>vvp~=NwDn!;AgWtsb83`xv3z_a+kdzt zC1K9m$iTufr6a{kOS==ie!I8bzzPoBI5+V z-ZH&>@0O3$UA(UUFb$fpYBo-z&=Un<>|P3KK5En^(YR6&h!`>S$P_aDhx-q@%>PTp zM7d$xn7YMY|I^u|Ho+Z;#?-46h+VS#n3ZkwP+CZ)i&11(m6vYM4Dl;cuE~A!`?GM< zNawe3msRqBiX~3kN4PQTWCv4nP^HmsezU?RQ>s9cj5?6A`8vCy{J6l)1`q7`l;12X z^r985tD5FSX%a4p(F{SNyHtkkT#%N{^QN|>He)%1vVM!>Qap+ev#E?9$tJ8(vwTx( zES50RYK^p)CT!8&VADk_hqGm)f2tJRc#*epYrbGhR+a5oraaPt7Mp#MY_ci5)yOg~ zafPf=lucO?OoQ6YOJ-MAUJLCm`13{k&ukIPDA12?G03d7!E9=aZEbQrB8RqqZQzA) za3qnQ;J=geFC&qjpQ7M3W0oHeQFKa>k5#WyEE}VVwq&J8#!Chk-(mO;Yw+N;-BbK7 zDGkIZ^q>;z!b-)hcWs+6X8LcJWE;xMbrXh|KP9a-Ea(>OF1!*CtB`4GcJ9n)+k(!WgttN>8j@kRY zW7o66eY<9jSVMZ262zNUR8@oB=A4z!q`_(x1OH69sE zZ9i*)ex@ZVT;tXf28wD74VJW@@1KVhqmj!GXiNw-)iC1{k2YC=M1lh_ZcpzLcY946 zT2*t2X(uKh&f}t(9DXWG%peGbQ#zfETmnSAYG5)$7Ol*nS6KRiFo5wBQWpt{MkSn@ zkUC(yI0~unUuHLkMG0fAS-h~OM_r|Lx*C>Z3DtrL=gJ4#KRsmGW=0Wh9@7;vq!v@m zu_J$_l&bhX6S&~S&WYuP&d-KS3cp?rmrY`Y%Y&D8O)AeyU3lziEL1X9Z=an?s}>IE z2vMz?x+~dE7b{tQwdVz;OjEeEZ0fIY=UA30_yr6RtGQ-JS+1kq$O``Ey=2s0nO;gg zL3}3WVH!lYW_&K%Sl)^R!^R!2*B0-TNrM-bOR{xR%@$qk@%2B=G{1abW^CQ)EtR4L ze|R*z{+JP1aR1{>+`ZrjyC$E+*J3+WX0tiCJT-aQ;5}x%T|z`SOJLrWt%TEk)p?-V zhNixw29%%dNH#S!HIBS7tg-OnFd$d_DI8!}zvB;{hl`P^kVaGu%Bq1a2}vsbXi)*% zO0z4=f`qJi=w0ZvPw}9m5eb=k9?z(pn;mGZNz#7Jy*)ZlN%N9jV#?ucjb5ZqOI65O z_03fS{4!iw^XX~a`Lva?LzMM&86O*wnR`{tQEyzcFTGZGn$kdWwhqTq(3lcY*DGo( zawTA4(FE*VKHAGR^5bU!c$p02=$~RUThA9UXPJR*Wa-?~N;R9=*p$S}gvFYVsXz6q zkF;OAMx*B-u>rJUetsu)1v(NPR05uuxO6Ej%;eOBJ$+THi&PZNx2^hWufnXPr@KZ! z8EdWHxz?LXgSWW-_#Ssuxv6T3hMz-MiSnYpYa#izieD#^ck16%4~ z9*#ms(F$->plMg92#3+LQ^`7vzQhW9vbeO725o%@-1rs`PI#$ZW zVqE!c3QuJv{4;6_bXP}aJu?wE4eiM$TuH%pzdBdP(jxAkVyjKB{}Y0@Is9@LMK!H0 zQN+{X@{6U}b?4K72`eVf#Jxn6pc6C7k1ss&L+aX>Vk=AK-}c;GcQ##ZXPr$7JXZLt zt6yuX+v{c-FkC#tuUN}wY?NV%VDH|QqEqBqSu~(N)W6wdPGDY%)(>^Qjm0|@tpzE$ zqWtLI)kBU8y)l@zg+(H*UU37uh4yqYk`&1?)!4|wwF#Ws%5+UNU^SApNJ_4Zj+rf{ z!_rbwk{{df#4|Z&w@It4R%6(|Pl17k?y2367e$GWR5#>XF?c;~C;mys=cg7D(BCI3 zA`Xm@bxG}o5~ebQdXuu2TWk2e1xpW}A5(?I15q&hLkOL;MtqXEq~m-TwZlZl|N9}+ zH)CfEH@wU2f`01MA7l zZpL2;xAfg0(LIr$($O9(C_B)X**}p5X^F706PYat{iPZD zGqpAzbByxYSFWxbdvPx<#VWz_l^>j0=j(8xYCgYQR30n7toVSdkyJKi%mz-&-&QG$ z3z(fRCL7`Aj?eCn>=H>quS<|HX&$AdOQ_8RWv{yLFY!cT4s#z_m6xQz)?BzVr#~Xe zRelRn;HO{|O0u(>#euAg#c8rT+0du_h;RLybx1KustfV(tR=rO;0FL=bjBl1DR^|( z;LAai|L`#Lla=6S{yWQ@w5)GLf`0E`1vIr_hp{zGJ^u<`KX9d6A{R0Yi4v7*06S;b z;cMYmp4H08wQ>K=PDuX)D{9U$^<(*xs~@cM!^+fCF)LD)vP}_csxeX=^L=i`TQCbD z%1q=H{S2q1`ga5@4k=wVZgPFIK%DQ)2hcDP6~sV+K|w=aLih$0pNJ;nD>WYtaD$O)24{vbOdL_NP%`n9 zWM(NzwI9pEYu3i5xmK+0mbE=>sWd*NuBB;hTWSyYce+!{2j+YJ_s-mV&j0^EcWB!h z^yx;6|5V@LhXp@f{0-zUvAc@?|C8EF2sdFG7T|7-#Pi6n_?4F+3=0=xEcU^8oQVnO zz)akNL+~~1f#8RuE zKzs;qBz_h7s5l;oJCRF>v$&o6iz^J~a^S%TAqL~TSPrZV>7z34e!Uq zeykp!Lv@{YT!P_ILWH6N)rx(nl3Rzt*nnZ&Uuv$Y5GMfy1i3X~}bEsBmj}>AYI?;|AcO8$Fn27u0$Uln}?{eS< zJcmhm8K+?soupR03{?s%F&}-X-uE)r;wh}hC04W6U3iN4JEV%@J(fQh&){|p9b`7^ z?m>RDi$CE2eJH*{)uq=k9TWJJ6=&lZT#55FW{xT|8nd_?N8=i7!#qsqA|zAXg(OMb zhpGu%P<_t8L->`SfrG)Gp=S5?y4l=@UQ`!sLbbwP$wDm0Ml8V#sCuM`f|j8TkK>!T z5x1rYu@n2J(idppZTLOTz?o@6Ohx}A45ApE4jlLaeZ;vepL>gg*n*#-O8Ghptyb_9 zsvh|a)e3(=swVmjH+REutRl|C8hjqrc1+F`;$9qqJ=OkiXRwF^f5XK%D9hZ>wWuyw zk4y1MR5#RaF}Gz5&L!S}Tu7Y6arh;6LuI73f1-u8^t8K zzv#zc5{^W5q1C9mePbYQLdqs~VG6#5lwbT1h^?cASV%k*nWJb$wY@r#s)_4(E2h)I zBw4IMmD&C1S1Eg&fx2+$ShG1sAR9o`;bh#4Tk$-q=3C8&R7pO=QXH3KK4_jmC-DKK z4@E+5U}C7^R#X{!7yIJ5T=K65*EpbN8phrU!yHsAF2rbDg`2SncVX;!^Fh>(Dq}}c z-QaE9hgVS5c<%(WWCq3)e~KytKO<`v>G^&k<}j$pH*3_6DplX$YRs7^gauzhe#I-i z9Ay9hf)5cNAU&!Hr%f@Nb0?~1JBYtc44Jcd8xyc6du|Y>BO68(`x&SvszddG1E^AT z828|ZsCr=)k3)5SJx1Ubd;nYU3=S_aClEE=Tu26X<9G>r(H@9Tp}Ni)RL$-GmVvr~ zrO-SOhV8_0Xv0oarOBCLrmhk<67NDa(ZreNm(w_GAuh&e@Ow;F?<1Wnumfu_YnHj2 zp2Z$&{~uzY_T|xlZ=ssu`?wZQ<8EA5WH!xpyotE)Y_p~*s8%u~5HGuoC6~<7RymRQt!od{0dddqbam%#x%^wX}AL$P^EhCJSMKu zh(37|)}D-{9liU$oq2e%s|QLpb;Z+^zAet6TB5wym$%uAIiYR-~cwGT3O*z^Tv(X zi+C4iq3&{vWUcA0xhiYT^wx^K1AcOd!5yBmeyw z45VP{4cYh})}h*db9uVlgYTfaVIg&C#cFKC0lL{-&jgIP)2!`UT+aE2@DhHH5962R zLi`z5SMW8C*>>`O7lTc9v*uS&omjfUe8wBdqQpD61Jf$aUg^LN;wz|nVvfmREnO*zIY68!jEtiUP5)Dp=-?*=i)$OAMQkn&6vT(x?>04h$k=+Kfw=BHHfPH zhp3(tUt&QVgRnX?g{e4}cp0kH@4y`V9G7G4dh^*|i^0UfG>XbZD9ZW)ozfUMO(w-o zl5fN=l*{7=8z03#XEC-7x~3UTiNmxkr^{+9Ep=5py&kL0ty@doy3MPXrCK>rW%GDe zx!h%eGZi*xxn7noe@wb4FARAgyxQY+-DP#S%I!|xS}w;V4@j`P^-{0PU1Po3t2=E@ zZ?bjzWHH(9vzO^^t5?6KCm@@h(+%u6jW9!)LMWP18#V^T(hMIOmq zA)B(s%g(H+vg{UD51($Wa_c_3zDoKVqm2vMK282-deW0kYJCO>w~m5ZvT z$QxJM;17taLsycg6^@d(%ogc(^{$9ow{{~%SlD7s)P=` z+-t3NmIcntR#iTB$n9nJ<@&L`PDlCYkWC)>#-`O_GwnV-ZGp?}DXp+o8~(<(G~<^oF`7);I!4xPJt+Ux^k4bOwkgIi z`I9AR>aoAbKDCjuXnTW^(8kXy4gsG4?&~43V?;rWj*e!!={U zQ`3TsbI)aHviOA`WxoS+xudsx2H>$a79yt>1oyNz29W^2a#FE(m& z&r72W>&rP7dA|L$tbMi02s@mo8B31DX-U7Qlx6=>VCMcuR@1Fh>`poU@u+TO+3qZt po7#%ydq?-njeq?q?Dx8-O?23*
    -

    +

    diff --git a/includes/class-m365-login-graph.php b/includes/class-m365-login-graph.php index 027c485..f4f8bfb 100644 --- a/includes/class-m365-login-graph.php +++ b/includes/class-m365-login-graph.php @@ -277,52 +277,111 @@ class M365_Login_Graph { } /** - * Metadata of a user's profile photo (prefers the 240×240 rendition). + * Runs up to 20 GET requests in one Graph JSON batch. + * + * @param string[] $paths Request key => path relative to the v1.0 base. + * @return array|WP_Error Request key => array( 'status' => int, 'body' => mixed ). + */ + public function batch_get( $paths ) { + $requests = array(); + foreach ( array_values( $paths ) as $i => $path ) { + $requests[] = array( + 'id' => (string) $i, + 'method' => 'GET', + 'url' => $path, + ); + } + $keys = array_keys( $paths ); + if ( empty( $requests ) ) { + return array(); + } + if ( count( $requests ) > 20 ) { + return new WP_Error( 'graph_batch_size', 'A Graph batch holds at most 20 requests.' ); + } + + $result = $this->request( 'POST', '/$batch', array( 'requests' => $requests ) ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $out = array(); + foreach ( isset( $result['responses'] ) && is_array( $result['responses'] ) ? $result['responses'] : array() as $response ) { + $i = isset( $response['id'] ) ? (int) $response['id'] : -1; + if ( isset( $keys[ $i ] ) ) { + $out[ $keys[ $i ] ] = array( + 'status' => isset( $response['status'] ) ? (int) $response['status'] : 0, + 'body' => isset( $response['body'] ) ? $response['body'] : null, + ); + } + } + return $out; + } + + /** + * Profile photo versions of several users (one batch request per 20 users). + * + * @param string[] $oids User object IDs. + * @return array oid => etag string, null (user has no photo) or WP_Error (could not be checked). + */ + public function photo_versions( $oids ) { + $out = array(); + foreach ( array_chunk( array_values( array_filter( $oids, array( 'M365_Login_Settings', 'is_guid' ) ) ), 20 ) as $chunk ) { + $paths = array(); + foreach ( $chunk as $oid ) { + $paths[ $oid ] = '/users/' . rawurlencode( strtolower( $oid ) ) . '/photo'; + } + $responses = $this->batch_get( $paths ); + foreach ( $chunk as $oid ) { + if ( is_wp_error( $responses ) ) { + $out[ $oid ] = $responses; + continue; + } + $response = isset( $responses[ $oid ] ) ? $responses[ $oid ] : array( + 'status' => 0, + 'body' => null, + ); + if ( 404 === $response['status'] ) { + $out[ $oid ] = null; + } elseif ( 200 === $response['status'] && is_array( $response['body'] ) ) { + $etag = isset( $response['body']['@odata.mediaEtag'] ) ? (string) $response['body']['@odata.mediaEtag'] : ''; + $out[ $oid ] = '' !== $etag ? $etag : md5( (string) wp_json_encode( $response['body'] ) ); + } else { + $code = isset( $response['body']['error']['code'] ) ? (string) $response['body']['error']['code'] : 'HTTP ' . $response['status']; + $out[ $oid ] = new WP_Error( 'graph_photo', $code, array( 'status' => $response['status'] ) ); + } + } + } + return $out; + } + + /** + * Downloads a user's photo (240×240 rendition, else the original). * * @param string $oid User object ID. - * @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo. + * @return string|null|WP_Error Binary image data, null when the user has no photo. */ - public function photo_info( $oid ) { + public function photo_bytes( $oid ) { if ( ! M365_Login_Settings::is_guid( $oid ) ) { return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); } $base = '/users/' . rawurlencode( strtolower( $oid ) ); - foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) { - $meta = $this->request( 'GET', $path ); - if ( is_wp_error( $meta ) ) { - if ( self::is_not_found( $meta ) ) { - continue; - } - return $meta; + foreach ( array( $base . '/photos/240x240/$value', $base . '/photo/$value' ) as $path ) { + $response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ) ); + if ( is_wp_error( $response ) ) { + return $response; + } + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + if ( 200 === $code ) { + return $body; + } + if ( 404 !== $code ) { + return $this->error_from( $code, json_decode( $body, true ) ); } - $etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : ''; - return array( - 'path' => $path, - 'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ), - ); } return null; } - /** - * Downloads photo bytes. - * - * @param string $path Photo path returned by photo_info(). - * @return string|WP_Error Binary image data. - */ - public function photo_bytes( $path ) { - $response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) ); - if ( is_wp_error( $response ) ) { - return $response; - } - $code = (int) wp_remote_retrieve_response_code( $response ); - $body = wp_remote_retrieve_body( $response ); - if ( 200 !== $code ) { - return $this->error_from( $code, json_decode( $body, true ) ); - } - return $body; - } - /** * Searches groups by display name. * diff --git a/includes/class-m365-login-sync.php b/includes/class-m365-login-sync.php index 5eb1022..347b23a 100644 --- a/includes/class-m365-login-sync.php +++ b/includes/class-m365-login-sync.php @@ -75,6 +75,7 @@ class M365_Login_Sync { 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' ) ); @@ -394,7 +395,7 @@ class M365_Login_Sync { $linked = $this->linked_users(); $seen = array(); $pending = array(); // Deactivations/deletions, applied after the safety check. - $photos = 0; + $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 ) { @@ -404,11 +405,18 @@ class M365_Login_Sync { $result = $this->sync_person( $person, $linked, $memberships ); if ( is_array( $result ) ) { $pending[] = $result; - } elseif ( $result instanceof WP_User && $photo_on && ! $this->dry ) { - $photos += $this->maybe_sync_photo( $result, $oid, $photos ); + } 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 ] ) ) { @@ -776,6 +784,22 @@ class M365_Login_Sync { } } + // 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 ); @@ -996,8 +1020,7 @@ class M365_Login_Sync { $this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); $this->count( 'deleted' ); if ( ! $this->dry ) { - $this->delete_photo( $user->ID ); - wp_delete_user( $user->ID, $reassign ); + wp_delete_user( $user->ID, $reassign ); // The delete_user hook removes the photo. } return; } @@ -1133,89 +1156,182 @@ class M365_Login_Sync { /* ------------------------------------------------------------------ */ /** - * Refreshes the profile photo when it was not checked recently. + * Brings the stored photos in line with Microsoft 365: downloads new and changed + * photos and deletes photos that were removed in Microsoft 365. * - * @param WP_User $user User. - * @param string $oid Object ID. - * @param int $done_so_far Photo checks done in this run. - * @return int 1 when Graph was asked, 0 otherwise. + * 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 maybe_sync_photo( $user, $oid, $done_so_far ) { + private function sync_photos( $users ) { /** - * Maximum number of profile photo checks per sync run (the rest follows in later runs). - * - * @param int $limit Limit. - */ - if ( $done_so_far >= (int) apply_filters( 'm365_login_sync_photo_limit', 200 ) ) { - return 0; - } - $stored = get_user_meta( $user->ID, self::META_PHOTO, true ); - $stored = is_array( $stored ) ? $stored : array(); - - /** - * Seconds between two photo checks of the same user. + * 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', 20 * HOUR_IN_SECONDS ); - if ( ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) { - return 0; - } + $interval = (int) apply_filters( 'm365_login_sync_photo_interval', 0 ); - $info = $this->graph->photo_info( $oid ); - if ( is_wp_error( $info ) ) { - /* 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, $info->get_error_message() ) ); - return 1; - } + /** + * 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 ); - if ( null === $info ) { - if ( ! empty( $stored['file'] ) ) { - $this->delete_photo( $user->ID ); - /* translators: %s: e-mail address */ - $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); - $this->count( 'photos' ); + $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() ) ); - return 1; } + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + } - if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $info['etag'] && file_exists( self::photo_path( $stored['file'] ) ) ) { - $stored['checked'] = time(); - update_user_meta( $user->ID, self::META_PHOTO, $stored ); - return 1; - } - - $bytes = $this->graph->photo_bytes( $info['path'] ); - 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 ) ); - return 1; - } - - $file = $this->store_photo( $user->ID, $oid, $info['etag'], $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 ) ); - return 1; - } - - if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) { - wp_delete_file( self::photo_path( $stored['file'] ) ); - } - update_user_meta( - $user->ID, - self::META_PHOTO, + /** + * Deletes every stored photo (the photo sync was switched off). + */ + private function remove_all_photos() { + $users = get_users( array( - 'file' => $file, - 'etag' => $info['etag'], - 'checked' => time(), + 'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_compare' => 'EXISTS', + 'fields' => array( 'ID' ), + 'number' => -1, ) ); - /* translators: %s: e-mail address */ - $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); - $this->count( 'photos' ); - return 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(); } /** @@ -1273,11 +1389,11 @@ class M365_Login_Sync { } /** - * Deletes a user's stored photo. + * Deletes a user's stored photo (also hooked to user deletion). * * @param int $user_id User ID. */ - private function delete_photo( $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'] ) ); diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index dab20df0a1b57c0d8aac96989fc0a58243cbb6af..982d3109dad5f74e15f577027391086ac44f50fd 100644 GIT binary patch delta 7164 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 diff --git a/languages/m365-login-de_DE.po b/languages/m365-login-de_DE.po index 0386eb1..4efe142 100644 --- a/languages/m365-login-de_DE.po +++ b/languages/m365-login-de_DE.po @@ -124,7 +124,7 @@ msgstr "Du hast ungespeicherte Änderungen. Der Sync verwendet die gespeicherten msgid "Move up" msgstr "Nach oben" -#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1495 +#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1611 msgid "You are not allowed to do this." msgstr "Dafür fehlt die Berechtigung." @@ -428,8 +428,8 @@ msgid "Selected Microsoft 365 attributes are copied into the WordPress profile o msgstr "Ausgewählte Microsoft-365-Attribute werden bei jedem Sync ins WordPress-Profil übernommen (Microsoft 365 hat Vorrang). Namen landen in den normalen Profilfeldern, alles andere in Benutzer-Metadaten mit dem Präfix „m365_“ – nutzbar für Themes und andere Plugins – und wird auf der Profilseite angezeigt." #: includes/class-m365-login-admin.php:643 -msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user." -msgstr "Profilbilder werden in wp-content/uploads/m365-login-avatars/ gespeichert und ersetzen den Gravatar. Sie werden etwa einmal täglich pro Benutzer geprüft." +msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay)." +msgstr "Profilbilder werden in wp-content/uploads/m365-login-avatars/ gespeichert und ersetzen den Gravatar. Sie werden bei jedem Lauf abgeglichen: Geänderte Bilder werden neu geladen, in Microsoft 365 gelöschte Bilder auch in WordPress gelöscht. Felder und Bilder, die du hier abwählst, werden beim nächsten Lauf aus den Profilen entfernt (Vor-, Nach- und Anzeigename bleiben)." #: includes/class-m365-login-admin.php:647 msgid "Disabled and deleted Microsoft 365 accounts" @@ -1119,7 +1119,7 @@ msgstr "Zu viele Versuche. Bitte 15 Minuten warten." msgid "Too many sign-in attempts from your connection. Please wait a few minutes and try again." msgstr "Zu viele Anmeldeversuche von dieser Verbindung. Bitte ein paar Minuten warten und erneut versuchen." -#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1404 +#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1520 msgid "This account has been deactivated." msgstr "Dieses Konto wurde deaktiviert." @@ -1175,15 +1175,15 @@ msgstr "Das Zertifikat gehört nicht zu diesem privaten Schlüssel." msgid "The certificate has already expired." msgstr "Das Zertifikat ist bereits abgelaufen." -#: includes/class-m365-login-graph.php:375 +#: includes/class-m365-login-graph.php:434 msgid "Group" msgstr "Gruppe" -#: includes/class-m365-login-graph.php:377 +#: includes/class-m365-login-graph.php:436 msgid "Security group" msgstr "Sicherheitsgruppe" -#: includes/class-m365-login-graph.php:379 +#: includes/class-m365-login-graph.php:438 msgid "Microsoft 365 group" msgstr "Microsoft 365-Gruppe" @@ -1235,293 +1235,312 @@ msgstr "Die eigene Login-Seite muss eine URL dieser Website sein." msgid "User sync: \"Delete\" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead." msgstr "Benutzer-Sync: „Löschen“ braucht einen Benutzer, der die Beiträge gelöschter Konten übernimmt. Bis einer ausgewählt ist, werden Konten stattdessen deaktiviert." -#: includes/class-m365-login-sync.php:109 +#: includes/class-m365-login-sync.php:110 msgid "Display name" msgstr "Anzeigename" -#: includes/class-m365-login-sync.php:113 +#: includes/class-m365-login-sync.php:114 msgid "First name" msgstr "Vorname" -#: includes/class-m365-login-sync.php:117 +#: includes/class-m365-login-sync.php:118 msgid "Last name" msgstr "Nachname" -#: includes/class-m365-login-sync.php:121 +#: includes/class-m365-login-sync.php:122 msgid "Profile photo (used as avatar)" msgstr "Profilbild (als Avatar)" -#: includes/class-m365-login-sync.php:125 +#: includes/class-m365-login-sync.php:126 msgid "Job title" msgstr "Position" -#: includes/class-m365-login-sync.php:129 +#: includes/class-m365-login-sync.php:130 msgid "Department" msgstr "Abteilung" -#: includes/class-m365-login-sync.php:133 +#: includes/class-m365-login-sync.php:134 msgid "Company" msgstr "Firma" -#: includes/class-m365-login-sync.php:137 +#: includes/class-m365-login-sync.php:138 msgid "Office" msgstr "Büro" -#: includes/class-m365-login-sync.php:141 +#: includes/class-m365-login-sync.php:142 msgid "Employee ID" msgstr "Personalnummer" -#: includes/class-m365-login-sync.php:145 +#: includes/class-m365-login-sync.php:146 msgid "Business phone" msgstr "Telefon (geschäftlich)" -#: includes/class-m365-login-sync.php:149 +#: includes/class-m365-login-sync.php:150 msgid "Mobile phone" msgstr "Mobiltelefon" -#: includes/class-m365-login-sync.php:153 +#: includes/class-m365-login-sync.php:154 msgid "Street address" msgstr "Straße" -#: includes/class-m365-login-sync.php:157 +#: includes/class-m365-login-sync.php:158 msgid "Postal code" msgstr "Postleitzahl" -#: includes/class-m365-login-sync.php:161 +#: includes/class-m365-login-sync.php:162 msgid "City" msgstr "Ort" -#: includes/class-m365-login-sync.php:165 +#: includes/class-m365-login-sync.php:166 msgid "State / province" msgstr "Bundesland / Region" -#: includes/class-m365-login-sync.php:169 +#: includes/class-m365-login-sync.php:170 msgid "Country" msgstr "Land" -#: includes/class-m365-login-sync.php:173 +#: includes/class-m365-login-sync.php:174 msgid "Language (sets the admin language if installed)" msgstr "Sprache (setzt die Backend-Sprache, falls installiert)" -#: includes/class-m365-login-sync.php:308 +#: includes/class-m365-login-sync.php:309 msgid "Another sync is still running. Please try again in a few minutes." msgstr "Ein anderer Sync läuft noch. Bitte versuche es in ein paar Minuten erneut." -#: includes/class-m365-login-sync.php:365 +#: includes/class-m365-login-sync.php:366 msgid "The connection to Microsoft Entra ID is not configured yet." msgstr "Die Verbindung zu Microsoft Entra ID ist noch nicht eingerichtet." -#: includes/class-m365-login-sync.php:369 +#: includes/class-m365-login-sync.php:370 msgid "The user sync needs a pinned tenant ID (GUID) on the Connection tab." msgstr "Der Benutzer-Sync braucht eine feste Tenant-ID (GUID) im Tab „Verbindung“." -#: includes/class-m365-login-sync.php:373 +#: includes/class-m365-login-sync.php:374 msgid "The default role does not exist. Please check the sync settings." msgstr "Die Standardrolle existiert nicht. Bitte prüfe die Sync-Einstellungen." #. translators: %d: number of users -#: includes/class-m365-login-sync.php:385 +#: includes/class-m365-login-sync.php:386 msgid "%d user read from Microsoft 365." msgid_plural "%d users read from Microsoft 365." msgstr[0] "%d Benutzer aus Microsoft 365 gelesen." msgstr[1] "%d Benutzer aus Microsoft 365 gelesen." #. translators: 1: number of accounts, 2: limit -#: includes/class-m365-login-sync.php:435 +#: includes/class-m365-login-sync.php:443 msgid "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)." msgstr "Sicherheitsstopp: %1$d Konten würden deaktiviert oder gelöscht, mehr als das Limit von %2$d pro Lauf. Es wurde kein Konto deaktiviert oder gelöscht. Prüfe die Sync-Gruppen und den Tenant und starte den Sync dann erneut (das Limit lässt sich mit dem Filter m365_login_sync_deprovision_limit ändern)." #. translators: %s: user principal name -#: includes/class-m365-login-sync.php:559 +#: includes/class-m365-login-sync.php:567 msgid "%s: no usable e-mail address, skipped." msgstr "%s: keine verwendbare E-Mail-Adresse, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:566 +#: includes/class-m365-login-sync.php:574 msgid "%s: e-mail domain is not on the allow-list, skipped." msgstr "%s: E-Mail-Domain steht nicht auf der Liste erlaubter Domains, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:578 +#: includes/class-m365-login-sync.php:586 msgid "%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped." msgstr "%s: Das WordPress-Konto mit dieser E-Mail-Adresse ist mit einem anderen Microsoft-Konto verknüpft, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:582 +#: includes/class-m365-login-sync.php:590 msgid "%s: existing account linked." msgstr "%s: bestehendes Konto verknüpft." -#: includes/class-m365-login-sync.php:596 includes/class-m365-login-sync.php:931 includes/class-m365-login-sync.php:1536 +#: includes/class-m365-login-sync.php:604 includes/class-m365-login-sync.php:955 includes/class-m365-login-sync.php:1652 msgid "disabled in Microsoft 365" msgstr "in Microsoft 365 deaktiviert" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:607 +#: includes/class-m365-login-sync.php:615 msgid "%s: added to this site." msgstr "%s: zu dieser Website hinzugefügt." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:616 +#: includes/class-m365-login-sync.php:624 msgid "%s: reactivated (active in Microsoft 365 again)." msgstr "%s: reaktiviert (in Microsoft 365 wieder aktiv)." #. translators: 1: e-mail address, 2: list of changed fields -#: includes/class-m365-login-sync.php:627 +#: includes/class-m365-login-sync.php:635 msgid "%1$s: updated (%2$s)." msgstr "%1$s: aktualisiert (%2$s)." #. translators: 1: e-mail address, 2: role names -#: includes/class-m365-login-sync.php:653 +#: includes/class-m365-login-sync.php:661 msgid "%1$s: account created (%2$s)." msgstr "%1$s: Konto angelegt (%2$s)." #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:680 +#: includes/class-m365-login-sync.php:688 msgid "%1$s: account could not be created: %2$s" msgstr "%1$s: Konto konnte nicht angelegt werden: %2$s" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:738 +#: includes/class-m365-login-sync.php:746 msgid "%s: e-mail address is used by another WordPress account and was not changed." msgstr "%s: Die E-Mail-Adresse gehört bereits einem anderen WordPress-Konto und wurde nicht geändert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:741 +#: includes/class-m365-login-sync.php:749 msgid "e-mail" msgstr "E-Mail" +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:799 +msgid "%s removed" +msgstr "%s entfernt" + #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:785 +#: includes/class-m365-login-sync.php:809 msgid "%1$s: profile could not be updated: %2$s" msgstr "%1$s: Profil konnte nicht aktualisiert werden: %2$s" #. translators: %s: role names -#: includes/class-m365-login-sync.php:891 +#: includes/class-m365-login-sync.php:915 msgid "roles: %s" msgstr "Rollen: %s" -#: includes/class-m365-login-sync.php:926 includes/class-m365-login-sync.php:1537 +#: includes/class-m365-login-sync.php:950 includes/class-m365-login-sync.php:1653 msgid "deleted in Microsoft 365" msgstr "in Microsoft 365 gelöscht" -#: includes/class-m365-login-sync.php:934 includes/class-m365-login-sync.php:1538 +#: includes/class-m365-login-sync.php:958 includes/class-m365-login-sync.php:1654 msgid "no longer a member of the sync groups" msgstr "kein Mitglied der Sync-Gruppen mehr" #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:954 +#: includes/class-m365-login-sync.php:978 msgid "%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed." msgstr "%1$s: %2$s, das Konto ist aber geschützt (Administrator oder dein eigenes Konto) und wurde nicht geändert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:987 +#: includes/class-m365-login-sync.php:1011 msgid "%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted." msgstr "%s: Es ist kein gültiger Benutzer für die Übernahme der Inhalte ausgewählt, deshalb wird das Konto deaktiviert statt gelöscht." #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:996 +#: includes/class-m365-login-sync.php:1020 msgid "%1$s: account deleted (%2$s)." msgstr "%1$s: Konto gelöscht (%2$s)." #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:1006 +#: includes/class-m365-login-sync.php:1029 msgid "%1$s: account deactivated (%2$s)." msgstr "%1$s: Konto deaktiviert (%2$s)." -#: includes/class-m365-login-sync.php:1085 +#: includes/class-m365-login-sync.php:1108 msgid "Microsoft Graph refused the request. Grant the application permissions \"User.Read.All\" and \"GroupMember.Read.All\" with admin consent in Entra ID." msgstr "Microsoft Graph hat die Anfrage abgelehnt. Erteile in Entra ID die Anwendungsberechtigungen „User.Read.All“ und „GroupMember.Read.All“ mit Administratorzustimmung." #. translators: %s: error message -#: includes/class-m365-login-sync.php:1088 +#: includes/class-m365-login-sync.php:1111 msgid "Microsoft Graph error: %s" msgstr "Microsoft-Graph-Fehler: %s" -#: includes/class-m365-login-sync.php:1106 +#: includes/class-m365-login-sync.php:1129 msgid "Log truncated." msgstr "Protokoll gekürzt." #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:1168 +#: includes/class-m365-login-sync.php:1205 msgid "%1$s: profile photo could not be read: %2$s" msgstr "%1$s: Profilbild konnte nicht gelesen werden: %2$s" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1176 -msgid "%s: profile photo removed." -msgstr "%s: Profilbild entfernt." +#: includes/class-m365-login-sync.php:1229 includes/class-m365-login-sync.php:1272 +msgid "%s: profile photo updated." +msgstr "%s: Profilbild aktualisiert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1192 +#: includes/class-m365-login-sync.php:1248 msgid "%s: profile photo could not be downloaded." msgstr "%s: Profilbild konnte nicht heruntergeladen werden." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1199 +#: includes/class-m365-login-sync.php:1255 msgid "%s: profile photo is not a valid image or could not be saved." msgstr "%s: Profilbild ist kein gültiges Bild oder konnte nicht gespeichert werden." -#. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1216 -msgid "%s: profile photo updated." -msgstr "%s: Profilbild aktualisiert." +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1278 +msgid "%d changed profile photo will be downloaded in the next run (download limit per run reached)." +msgid_plural "%d changed profile photos will be downloaded in the next run (download limit per run reached)." +msgstr[0] "%d geändertes Profilbild wird im nächsten Lauf heruntergeladen (Download-Limit pro Lauf erreicht)." +msgstr[1] "%d geänderte Profilbilder werden im nächsten Lauf heruntergeladen (Download-Limit pro Lauf erreicht)." -#: includes/class-m365-login-sync.php:1433 includes/class-m365-login-sync.php:1566 +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1293 +msgid "%s: profile photo removed." +msgstr "%s: Profilbild entfernt." + +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1321 +msgid "Profile photo sync is off: %d stored photo removed." +msgid_plural "Profile photo sync is off: %d stored photos removed." +msgstr[0] "Profilbild-Sync ist aus: %d gespeichertes Profilbild entfernt." +msgstr[1] "Profilbild-Sync ist aus: %d gespeicherte Profilbilder entfernt." + +#: includes/class-m365-login-sync.php:1549 includes/class-m365-login-sync.php:1682 msgid "Microsoft 365" msgstr "Microsoft 365" -#: includes/class-m365-login-sync.php:1451 +#: includes/class-m365-login-sync.php:1567 msgid "Deactivated" msgstr "Deaktiviert" -#: includes/class-m365-login-sync.php:1454 +#: includes/class-m365-login-sync.php:1570 msgid "Imported" msgstr "Importiert" -#: includes/class-m365-login-sync.php:1456 +#: includes/class-m365-login-sync.php:1572 msgid "Linked" msgstr "Verknüpft" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Reactivate" msgstr "Reaktivieren" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Deactivate" msgstr "Deaktivieren" -#: includes/class-m365-login-sync.php:1517 +#: includes/class-m365-login-sync.php:1633 msgid "The account has been deactivated and signed out everywhere." msgstr "Das Konto wurde deaktiviert und überall abgemeldet." -#: includes/class-m365-login-sync.php:1518 +#: includes/class-m365-login-sync.php:1634 msgid "The account has been reactivated." msgstr "Das Konto wurde reaktiviert." #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1542 +#: includes/class-m365-login-sync.php:1658 msgid "Deactivated since %1$s (%2$s)" msgstr "Deaktiviert seit %1$s (%2$s)" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1544 +#: includes/class-m365-login-sync.php:1660 msgid "manually" msgstr "manuell" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1546 +#: includes/class-m365-login-sync.php:1662 msgid "Status" msgstr "Status" -#: includes/class-m365-login-sync.php:1549 +#: includes/class-m365-login-sync.php:1665 msgid "Object ID" msgstr "Objekt-ID" -#: includes/class-m365-login-sync.php:1553 +#: includes/class-m365-login-sync.php:1669 msgid "Last sync" msgstr "Letzter Sync" -#: includes/class-m365-login-sync.php:1575 +#: includes/class-m365-login-sync.php:1691 msgid "These values are managed by the Microsoft 365 user sync and overwritten on the next run." msgstr "Diese Werte verwaltet der Microsoft-365-Benutzer-Sync; sie werden beim nächsten Lauf überschrieben." diff --git a/languages/m365-login-de_DE_formal.mo b/languages/m365-login-de_DE_formal.mo index f404ef39a11445684b3d6a9950cfac5d52103a86..d7269bd9cbaea685d80e8588010427da38e9cc66 100644 GIT binary patch delta 7197 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 VW@$#HI88^2>53{!+Ssb;zW}2<8Jz$C delta 6157 zcmYM%2~?HU8piQ`E(i*!2o4A+2SG$J6P!g%a7I&$&`5<44~U`?PKe486;aE?A*V{s zp)^V>OsyPBvoyP!UA=0Sm1bEh(_9VS>el@~?QgBiV)5JW{-(X({qFA^zS!XNQ?-xx zN{H`MhM(y!V>}2;o@F#bS)dkFf>*j?vgW%*M&6<5`%13$O|9#x#5v zxy1N4H)g0YUK2&5GaW^!0X&Csco+-tI<~|6!;MM7Qsmd{b3BV3iN8mk-y*`860F2Q zco!REDmQoINbH2OumR6EH8gzacoTc#VblZvgHagEG&RG1s2S(;r#CLbL_CHX@Xwfv zku8l0z--h^3o*lGOa;y-KF>{ce54zBzR9Jb6c^!K^x_%(12yswd3q9liGJ9)l`$TS zzy?@>>aRwv-Z~7%ZBD!w8xg;aYw=^uz`_`wMRv>~8uAiOz@JeA%8q4S@iFwp$5E-R z#wF;}+L-$I1ZLt!^vByc2Jd1$%p^UUaSrPEMAZFC;>f>lFpCadQ0bg-6_*o#hXYAn zX&Yw-R8}4OKStf~BD(P!YUaLe?GgoJ5OD|85+oz5X);jP6{CuBPFwP?DqTs3E;!`) zfpg*oRA#QCM*b(NCj8nNGaTbk51xtZZ~>0MaBlhpPC{LG7nM=d-qunm`Vn^V(%>4? z3#kuNfOT;(2H*4QvN$7aT`r>>Q@z=cxNc zcQIxj4#Y)x9D^~Pv{h^WkEU^jj_1&zAf?UR5jiI1Y*7Z3HY z16Y9#i8mp2Yjz`9H|NofzMQOqw??{568iIeQ$!=v#VZ&YgZUttibq#3`#vZ`UHB{Z zM&A@WpuQMRJQkUhDMig}H*%^ug1X-ys7!R?Viju^D&rpX9;PvshEm(%K3=QX1&i=| z^k7yWyM2z}T;flW)i7D)c^)pu91Kh|W*kn!MtB;>;}>`m`}bwpuwg%Ap2nU1$bTOi z{rVd-3M+94eue|EEq|I~F)FU&&rCdxYyq!$&5Y=2jL^Q75(tP zs0aEEWw|g0HNXlljR`b1Vg~x$Z$BhPp;C7d>tM<-`+&Zv8I8cjI0-|kojdp^vEK-L zUY!RR3~>l*0K-tloryu1hjr0gOrrsf**F@PU~6vp6{?y87?n;8#}*iaDza3phYw;r zjzJBq5xzVOiB_wkz0Ogq9@)P)JiJHm{?2wZ{BU=3=ZgGSm{ z>L{c>O%7^+o6(IskvcOcQ3I$?VFh4wR2+-#u_GpE|Bt27mX4*Ul+|K1-un9lit1rw z_%$i~;hkdEVl1A*4tN_`B@@NM>-x!4UPi5bBn6@4$(Vq%a0t&g+i0}HTgc8Z zA*1zXVn^UAyn!mRIb-a;cTqv(h$B$NRL&n=_bDJ@akH01V^6Fw~3+&<`sdmpHCK72R5Fjhpcwc-%RD0yV=k zsEk~8ypDSCEeytAz0QHU1@=G)>OeR)#snwsgW8S{IQ_X!|4eK`e-)?e~NbTuvN^*KsdurWPLEYQUvfj*HM2ede+}SQlrJxm~CtjOHX=-x;-Z zDX1DqFDL)HQGwGj4eJwEV;F97+>aX2N2sd(1UuqysOu9*qcYbGl{tN^>%0uqgQuZt zU@nfsEvWa3&wLttXoSqSpVvpRhPXwAz0pZjD$k*|&v&Q`n=h~fh)3<7EY!efp+8n4 z?;f)pb=@5d#b~yM-YZ=&6^DCiY@o3cRU`?E>=%e640Q2N1Zqa>D~)M`+wgNdh25}b zF@=WL&=C`K*z2=#0C6F1!dGwtdWSEysXc%L={SmYF<_bfQW=T~#Dh^O zo`GF)G3vZSs5N~L+vC4*D4NF^6x@#inD~UvP%qT>OGh%|HDxq(!jq^0Y`}Q@61mm{ zu(xV(EjGrqC+!SJVGtP#Tw!kN=bPA7c1^or zXHMvcy>K?R!+jWpUt&M}0ac8hR#T`9AO+KjPp@GMWAmr%7nY|ngt+@!n~@3e}{TO1Z}seZ;M*f zu9$$?PX97&M!XK0jM;+?a0t^a!$)uto<}X=BhT9m79hXovFFLZ_HWn=>|N}NxwspZ z>foJr3Bpk`?Tsz*C7h3^@KGGP%MRdWEF~WNqTS9%QA>9cBk&UL!=JIIi~D%VYfBp6 zykt}Fx7&VujYL0Ad=xdK0u08P7=TsS2v^}k+>W|o{2sdt@+kHC^p~PCxfsK78){;Q zQ5p4~a89_5UFfL)svU7(WPh3r{16Lp0(O4Q_CJdn;H%gJ4`6q^gUV<^E&mnaVD!ho zPy_PcYqwt$Y@+=?ibg9AOvV9NjXLoHHp1(u5&w#MfQ!2(VoyxMBJ7NtQ8PV<_u`MJ z0Y{VXWL$=E_#Sq^n>becKXShvQ5k9`2T|Ma7t{rP4=@Xyh`n$xj>8|Z6F&5Y{j#|P zw-8^(f8yLX?XF3G%NF%7$m2}dL0e<%ahN)Oqv659hwML_doYUFec0ASA6!Q~5>@>_ zU>=4au{BYS%E$@~#dk0S&tn+gz;5Vz+upASb|)T=-bfltXei}7kb*X~$a0x6@7ML`5M?bvk_$_MJ+(s4S?-+wV?|bdX zW}Elz3!**h#ywCM^hbXjfy&UssG`hAy--RWD>0aO6>15#q0ZZnT8fiS|EJiP_%+r`5W`qdKl$nm*b+!IL-1Gv1v$Zt9Hb?x|DqJnmfg_}tm< zVo!;C#xze!?I%O3qE}~5X}vmQN_=hAl(ZhJFHa6!ow0LtJJ0l4xo%J4l*ze8?&-(2 l7ZerdO>h^NOm(Murp%aL#tjNQ#U;lM=1;GEeOE%v{{Z>ins5LB diff --git a/languages/m365-login-de_DE_formal.po b/languages/m365-login-de_DE_formal.po index eee0dbc..2a828fa 100644 --- a/languages/m365-login-de_DE_formal.po +++ b/languages/m365-login-de_DE_formal.po @@ -124,7 +124,7 @@ msgstr "Sie haben ungespeicherte Änderungen. Der Sync verwendet die gespeichert msgid "Move up" msgstr "Nach oben" -#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1495 +#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1611 msgid "You are not allowed to do this." msgstr "Dafür fehlt die Berechtigung." @@ -428,8 +428,8 @@ msgid "Selected Microsoft 365 attributes are copied into the WordPress profile o msgstr "Ausgewählte Microsoft-365-Attribute werden bei jedem Sync ins WordPress-Profil übernommen (Microsoft 365 hat Vorrang). Namen landen in den normalen Profilfeldern, alles andere in Benutzer-Metadaten mit dem Präfix „m365_“ – nutzbar für Themes und andere Plugins – und wird auf der Profilseite angezeigt." #: includes/class-m365-login-admin.php:643 -msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user." -msgstr "Profilbilder werden in wp-content/uploads/m365-login-avatars/ gespeichert und ersetzen den Gravatar. Sie werden etwa einmal täglich pro Benutzer geprüft." +msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay)." +msgstr "Profilbilder werden in wp-content/uploads/m365-login-avatars/ gespeichert und ersetzen den Gravatar. Sie werden bei jedem Lauf abgeglichen: Geänderte Bilder werden neu geladen, in Microsoft 365 gelöschte Bilder auch in WordPress gelöscht. Felder und Bilder, die Sie hier abwählen, werden beim nächsten Lauf aus den Profilen entfernt (Vor-, Nach- und Anzeigename bleiben)." #: includes/class-m365-login-admin.php:647 msgid "Disabled and deleted Microsoft 365 accounts" @@ -1119,7 +1119,7 @@ msgstr "Zu viele Versuche. Bitte 15 Minuten warten." msgid "Too many sign-in attempts from your connection. Please wait a few minutes and try again." msgstr "Zu viele Anmeldeversuche von dieser Verbindung. Bitte ein paar Minuten warten und erneut versuchen." -#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1404 +#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1520 msgid "This account has been deactivated." msgstr "Dieses Konto wurde deaktiviert." @@ -1175,15 +1175,15 @@ msgstr "Das Zertifikat gehört nicht zu diesem privaten Schlüssel." msgid "The certificate has already expired." msgstr "Das Zertifikat ist bereits abgelaufen." -#: includes/class-m365-login-graph.php:375 +#: includes/class-m365-login-graph.php:434 msgid "Group" msgstr "Gruppe" -#: includes/class-m365-login-graph.php:377 +#: includes/class-m365-login-graph.php:436 msgid "Security group" msgstr "Sicherheitsgruppe" -#: includes/class-m365-login-graph.php:379 +#: includes/class-m365-login-graph.php:438 msgid "Microsoft 365 group" msgstr "Microsoft 365-Gruppe" @@ -1235,293 +1235,312 @@ msgstr "Die eigene Login-Seite muss eine URL dieser Website sein." msgid "User sync: \"Delete\" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead." msgstr "Benutzer-Sync: „Löschen“ braucht einen Benutzer, der die Beiträge gelöschter Konten übernimmt. Bis einer ausgewählt ist, werden Konten stattdessen deaktiviert." -#: includes/class-m365-login-sync.php:109 +#: includes/class-m365-login-sync.php:110 msgid "Display name" msgstr "Anzeigename" -#: includes/class-m365-login-sync.php:113 +#: includes/class-m365-login-sync.php:114 msgid "First name" msgstr "Vorname" -#: includes/class-m365-login-sync.php:117 +#: includes/class-m365-login-sync.php:118 msgid "Last name" msgstr "Nachname" -#: includes/class-m365-login-sync.php:121 +#: includes/class-m365-login-sync.php:122 msgid "Profile photo (used as avatar)" msgstr "Profilbild (als Avatar)" -#: includes/class-m365-login-sync.php:125 +#: includes/class-m365-login-sync.php:126 msgid "Job title" msgstr "Position" -#: includes/class-m365-login-sync.php:129 +#: includes/class-m365-login-sync.php:130 msgid "Department" msgstr "Abteilung" -#: includes/class-m365-login-sync.php:133 +#: includes/class-m365-login-sync.php:134 msgid "Company" msgstr "Firma" -#: includes/class-m365-login-sync.php:137 +#: includes/class-m365-login-sync.php:138 msgid "Office" msgstr "Büro" -#: includes/class-m365-login-sync.php:141 +#: includes/class-m365-login-sync.php:142 msgid "Employee ID" msgstr "Personalnummer" -#: includes/class-m365-login-sync.php:145 +#: includes/class-m365-login-sync.php:146 msgid "Business phone" msgstr "Telefon (geschäftlich)" -#: includes/class-m365-login-sync.php:149 +#: includes/class-m365-login-sync.php:150 msgid "Mobile phone" msgstr "Mobiltelefon" -#: includes/class-m365-login-sync.php:153 +#: includes/class-m365-login-sync.php:154 msgid "Street address" msgstr "Straße" -#: includes/class-m365-login-sync.php:157 +#: includes/class-m365-login-sync.php:158 msgid "Postal code" msgstr "Postleitzahl" -#: includes/class-m365-login-sync.php:161 +#: includes/class-m365-login-sync.php:162 msgid "City" msgstr "Ort" -#: includes/class-m365-login-sync.php:165 +#: includes/class-m365-login-sync.php:166 msgid "State / province" msgstr "Bundesland / Region" -#: includes/class-m365-login-sync.php:169 +#: includes/class-m365-login-sync.php:170 msgid "Country" msgstr "Land" -#: includes/class-m365-login-sync.php:173 +#: includes/class-m365-login-sync.php:174 msgid "Language (sets the admin language if installed)" msgstr "Sprache (setzt die Backend-Sprache, falls installiert)" -#: includes/class-m365-login-sync.php:308 +#: includes/class-m365-login-sync.php:309 msgid "Another sync is still running. Please try again in a few minutes." msgstr "Ein anderer Sync läuft noch. Bitte versuchen Sie es in ein paar Minuten erneut." -#: includes/class-m365-login-sync.php:365 +#: includes/class-m365-login-sync.php:366 msgid "The connection to Microsoft Entra ID is not configured yet." msgstr "Die Verbindung zu Microsoft Entra ID ist noch nicht eingerichtet." -#: includes/class-m365-login-sync.php:369 +#: includes/class-m365-login-sync.php:370 msgid "The user sync needs a pinned tenant ID (GUID) on the Connection tab." msgstr "Der Benutzer-Sync braucht eine feste Tenant-ID (GUID) im Tab „Verbindung“." -#: includes/class-m365-login-sync.php:373 +#: includes/class-m365-login-sync.php:374 msgid "The default role does not exist. Please check the sync settings." msgstr "Die Standardrolle existiert nicht. Bitte prüfen Sie die Sync-Einstellungen." #. translators: %d: number of users -#: includes/class-m365-login-sync.php:385 +#: includes/class-m365-login-sync.php:386 msgid "%d user read from Microsoft 365." msgid_plural "%d users read from Microsoft 365." msgstr[0] "%d Benutzer aus Microsoft 365 gelesen." msgstr[1] "%d Benutzer aus Microsoft 365 gelesen." #. translators: 1: number of accounts, 2: limit -#: includes/class-m365-login-sync.php:435 +#: includes/class-m365-login-sync.php:443 msgid "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)." msgstr "Sicherheitsstopp: %1$d Konten würden deaktiviert oder gelöscht, mehr als das Limit von %2$d pro Lauf. Es wurde kein Konto deaktiviert oder gelöscht. Prüfen Sie die Sync-Gruppen und den Tenant und starten Sie den Sync dann erneut (das Limit lässt sich mit dem Filter m365_login_sync_deprovision_limit ändern)." #. translators: %s: user principal name -#: includes/class-m365-login-sync.php:559 +#: includes/class-m365-login-sync.php:567 msgid "%s: no usable e-mail address, skipped." msgstr "%s: keine verwendbare E-Mail-Adresse, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:566 +#: includes/class-m365-login-sync.php:574 msgid "%s: e-mail domain is not on the allow-list, skipped." msgstr "%s: E-Mail-Domain steht nicht auf der Liste erlaubter Domains, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:578 +#: includes/class-m365-login-sync.php:586 msgid "%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped." msgstr "%s: Das WordPress-Konto mit dieser E-Mail-Adresse ist mit einem anderen Microsoft-Konto verknüpft, übersprungen." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:582 +#: includes/class-m365-login-sync.php:590 msgid "%s: existing account linked." msgstr "%s: bestehendes Konto verknüpft." -#: includes/class-m365-login-sync.php:596 includes/class-m365-login-sync.php:931 includes/class-m365-login-sync.php:1536 +#: includes/class-m365-login-sync.php:604 includes/class-m365-login-sync.php:955 includes/class-m365-login-sync.php:1652 msgid "disabled in Microsoft 365" msgstr "in Microsoft 365 deaktiviert" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:607 +#: includes/class-m365-login-sync.php:615 msgid "%s: added to this site." msgstr "%s: zu dieser Website hinzugefügt." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:616 +#: includes/class-m365-login-sync.php:624 msgid "%s: reactivated (active in Microsoft 365 again)." msgstr "%s: reaktiviert (in Microsoft 365 wieder aktiv)." #. translators: 1: e-mail address, 2: list of changed fields -#: includes/class-m365-login-sync.php:627 +#: includes/class-m365-login-sync.php:635 msgid "%1$s: updated (%2$s)." msgstr "%1$s: aktualisiert (%2$s)." #. translators: 1: e-mail address, 2: role names -#: includes/class-m365-login-sync.php:653 +#: includes/class-m365-login-sync.php:661 msgid "%1$s: account created (%2$s)." msgstr "%1$s: Konto angelegt (%2$s)." #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:680 +#: includes/class-m365-login-sync.php:688 msgid "%1$s: account could not be created: %2$s" msgstr "%1$s: Konto konnte nicht angelegt werden: %2$s" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:738 +#: includes/class-m365-login-sync.php:746 msgid "%s: e-mail address is used by another WordPress account and was not changed." msgstr "%s: Die E-Mail-Adresse gehört bereits einem anderen WordPress-Konto und wurde nicht geändert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:741 +#: includes/class-m365-login-sync.php:749 msgid "e-mail" msgstr "E-Mail" +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:799 +msgid "%s removed" +msgstr "%s entfernt" + #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:785 +#: includes/class-m365-login-sync.php:809 msgid "%1$s: profile could not be updated: %2$s" msgstr "%1$s: Profil konnte nicht aktualisiert werden: %2$s" #. translators: %s: role names -#: includes/class-m365-login-sync.php:891 +#: includes/class-m365-login-sync.php:915 msgid "roles: %s" msgstr "Rollen: %s" -#: includes/class-m365-login-sync.php:926 includes/class-m365-login-sync.php:1537 +#: includes/class-m365-login-sync.php:950 includes/class-m365-login-sync.php:1653 msgid "deleted in Microsoft 365" msgstr "in Microsoft 365 gelöscht" -#: includes/class-m365-login-sync.php:934 includes/class-m365-login-sync.php:1538 +#: includes/class-m365-login-sync.php:958 includes/class-m365-login-sync.php:1654 msgid "no longer a member of the sync groups" msgstr "kein Mitglied der Sync-Gruppen mehr" #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:954 +#: includes/class-m365-login-sync.php:978 msgid "%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed." msgstr "%1$s: %2$s, das Konto ist aber geschützt (Administrator oder Ihr eigenes Konto) und wurde nicht geändert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:987 +#: includes/class-m365-login-sync.php:1011 msgid "%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted." msgstr "%s: Es ist kein gültiger Benutzer für die Übernahme der Inhalte ausgewählt, deshalb wird das Konto deaktiviert statt gelöscht." #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:996 +#: includes/class-m365-login-sync.php:1020 msgid "%1$s: account deleted (%2$s)." msgstr "%1$s: Konto gelöscht (%2$s)." #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:1006 +#: includes/class-m365-login-sync.php:1029 msgid "%1$s: account deactivated (%2$s)." msgstr "%1$s: Konto deaktiviert (%2$s)." -#: includes/class-m365-login-sync.php:1085 +#: includes/class-m365-login-sync.php:1108 msgid "Microsoft Graph refused the request. Grant the application permissions \"User.Read.All\" and \"GroupMember.Read.All\" with admin consent in Entra ID." msgstr "Microsoft Graph hat die Anfrage abgelehnt. Erteilen Sie in Entra ID die Anwendungsberechtigungen „User.Read.All“ und „GroupMember.Read.All“ mit Administratorzustimmung." #. translators: %s: error message -#: includes/class-m365-login-sync.php:1088 +#: includes/class-m365-login-sync.php:1111 msgid "Microsoft Graph error: %s" msgstr "Microsoft-Graph-Fehler: %s" -#: includes/class-m365-login-sync.php:1106 +#: includes/class-m365-login-sync.php:1129 msgid "Log truncated." msgstr "Protokoll gekürzt." #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:1168 +#: includes/class-m365-login-sync.php:1205 msgid "%1$s: profile photo could not be read: %2$s" msgstr "%1$s: Profilbild konnte nicht gelesen werden: %2$s" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1176 -msgid "%s: profile photo removed." -msgstr "%s: Profilbild entfernt." +#: includes/class-m365-login-sync.php:1229 includes/class-m365-login-sync.php:1272 +msgid "%s: profile photo updated." +msgstr "%s: Profilbild aktualisiert." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1192 +#: includes/class-m365-login-sync.php:1248 msgid "%s: profile photo could not be downloaded." msgstr "%s: Profilbild konnte nicht heruntergeladen werden." #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1199 +#: includes/class-m365-login-sync.php:1255 msgid "%s: profile photo is not a valid image or could not be saved." msgstr "%s: Profilbild ist kein gültiges Bild oder konnte nicht gespeichert werden." -#. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1216 -msgid "%s: profile photo updated." -msgstr "%s: Profilbild aktualisiert." +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1278 +msgid "%d changed profile photo will be downloaded in the next run (download limit per run reached)." +msgid_plural "%d changed profile photos will be downloaded in the next run (download limit per run reached)." +msgstr[0] "%d geändertes Profilbild wird im nächsten Lauf heruntergeladen (Download-Limit pro Lauf erreicht)." +msgstr[1] "%d geänderte Profilbilder werden im nächsten Lauf heruntergeladen (Download-Limit pro Lauf erreicht)." -#: includes/class-m365-login-sync.php:1433 includes/class-m365-login-sync.php:1566 +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1293 +msgid "%s: profile photo removed." +msgstr "%s: Profilbild entfernt." + +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1321 +msgid "Profile photo sync is off: %d stored photo removed." +msgid_plural "Profile photo sync is off: %d stored photos removed." +msgstr[0] "Profilbild-Sync ist aus: %d gespeichertes Profilbild entfernt." +msgstr[1] "Profilbild-Sync ist aus: %d gespeicherte Profilbilder entfernt." + +#: includes/class-m365-login-sync.php:1549 includes/class-m365-login-sync.php:1682 msgid "Microsoft 365" msgstr "Microsoft 365" -#: includes/class-m365-login-sync.php:1451 +#: includes/class-m365-login-sync.php:1567 msgid "Deactivated" msgstr "Deaktiviert" -#: includes/class-m365-login-sync.php:1454 +#: includes/class-m365-login-sync.php:1570 msgid "Imported" msgstr "Importiert" -#: includes/class-m365-login-sync.php:1456 +#: includes/class-m365-login-sync.php:1572 msgid "Linked" msgstr "Verknüpft" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Reactivate" msgstr "Reaktivieren" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Deactivate" msgstr "Deaktivieren" -#: includes/class-m365-login-sync.php:1517 +#: includes/class-m365-login-sync.php:1633 msgid "The account has been deactivated and signed out everywhere." msgstr "Das Konto wurde deaktiviert und überall abgemeldet." -#: includes/class-m365-login-sync.php:1518 +#: includes/class-m365-login-sync.php:1634 msgid "The account has been reactivated." msgstr "Das Konto wurde reaktiviert." #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1542 +#: includes/class-m365-login-sync.php:1658 msgid "Deactivated since %1$s (%2$s)" msgstr "Deaktiviert seit %1$s (%2$s)" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1544 +#: includes/class-m365-login-sync.php:1660 msgid "manually" msgstr "manuell" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1546 +#: includes/class-m365-login-sync.php:1662 msgid "Status" msgstr "Status" -#: includes/class-m365-login-sync.php:1549 +#: includes/class-m365-login-sync.php:1665 msgid "Object ID" msgstr "Objekt-ID" -#: includes/class-m365-login-sync.php:1553 +#: includes/class-m365-login-sync.php:1669 msgid "Last sync" msgstr "Letzter Sync" -#: includes/class-m365-login-sync.php:1575 +#: includes/class-m365-login-sync.php:1691 msgid "These values are managed by the Microsoft 365 user sync and overwritten on the next run." msgstr "Diese Werte verwaltet der Microsoft-365-Benutzer-Sync; sie werden beim nächsten Lauf überschrieben." diff --git a/languages/m365-login.pot b/languages/m365-login.pot index e861616..299d0c8 100644 --- a/languages/m365-login.pot +++ b/languages/m365-login.pot @@ -122,7 +122,7 @@ msgstr "" msgid "Move up" msgstr "" -#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1495 +#: includes/class-m365-login-admin.php:261 includes/class-m365-login-admin.php:300 includes/class-m365-login-admin.php:326 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:514 includes/class-m365-login-sync.php:1611 msgid "You are not allowed to do this." msgstr "" @@ -426,7 +426,7 @@ msgid "Selected Microsoft 365 attributes are copied into the WordPress profile o msgstr "" #: includes/class-m365-login-admin.php:643 -msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user." +msgid "Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are compared on every run: changed photos are downloaded again, photos deleted in Microsoft 365 are deleted in WordPress too. Fields and photos you deselect here are removed from the profiles on the next run (first and last name and display name stay)." msgstr "" #: includes/class-m365-login-admin.php:647 @@ -1117,7 +1117,7 @@ msgstr "" msgid "Too many sign-in attempts from your connection. Please wait a few minutes and try again." msgstr "" -#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1404 +#: includes/class-m365-login-auth.php:864 includes/class-m365-login-sync.php:1520 msgid "This account has been deactivated." msgstr "" @@ -1173,15 +1173,15 @@ msgstr "" msgid "The certificate has already expired." msgstr "" -#: includes/class-m365-login-graph.php:375 +#: includes/class-m365-login-graph.php:434 msgid "Group" msgstr "" -#: includes/class-m365-login-graph.php:377 +#: includes/class-m365-login-graph.php:436 msgid "Security group" msgstr "" -#: includes/class-m365-login-graph.php:379 +#: includes/class-m365-login-graph.php:438 msgid "Microsoft 365 group" msgstr "" @@ -1233,293 +1233,312 @@ msgstr "" msgid "User sync: \"Delete\" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead." msgstr "" -#: includes/class-m365-login-sync.php:109 +#: includes/class-m365-login-sync.php:110 msgid "Display name" msgstr "" -#: includes/class-m365-login-sync.php:113 +#: includes/class-m365-login-sync.php:114 msgid "First name" msgstr "" -#: includes/class-m365-login-sync.php:117 +#: includes/class-m365-login-sync.php:118 msgid "Last name" msgstr "" -#: includes/class-m365-login-sync.php:121 +#: includes/class-m365-login-sync.php:122 msgid "Profile photo (used as avatar)" msgstr "" -#: includes/class-m365-login-sync.php:125 +#: includes/class-m365-login-sync.php:126 msgid "Job title" msgstr "" -#: includes/class-m365-login-sync.php:129 +#: includes/class-m365-login-sync.php:130 msgid "Department" msgstr "" -#: includes/class-m365-login-sync.php:133 +#: includes/class-m365-login-sync.php:134 msgid "Company" msgstr "" -#: includes/class-m365-login-sync.php:137 +#: includes/class-m365-login-sync.php:138 msgid "Office" msgstr "" -#: includes/class-m365-login-sync.php:141 +#: includes/class-m365-login-sync.php:142 msgid "Employee ID" msgstr "" -#: includes/class-m365-login-sync.php:145 +#: includes/class-m365-login-sync.php:146 msgid "Business phone" msgstr "" -#: includes/class-m365-login-sync.php:149 +#: includes/class-m365-login-sync.php:150 msgid "Mobile phone" msgstr "" -#: includes/class-m365-login-sync.php:153 +#: includes/class-m365-login-sync.php:154 msgid "Street address" msgstr "" -#: includes/class-m365-login-sync.php:157 +#: includes/class-m365-login-sync.php:158 msgid "Postal code" msgstr "" -#: includes/class-m365-login-sync.php:161 +#: includes/class-m365-login-sync.php:162 msgid "City" msgstr "" -#: includes/class-m365-login-sync.php:165 +#: includes/class-m365-login-sync.php:166 msgid "State / province" msgstr "" -#: includes/class-m365-login-sync.php:169 +#: includes/class-m365-login-sync.php:170 msgid "Country" msgstr "" -#: includes/class-m365-login-sync.php:173 +#: includes/class-m365-login-sync.php:174 msgid "Language (sets the admin language if installed)" msgstr "" -#: includes/class-m365-login-sync.php:308 +#: includes/class-m365-login-sync.php:309 msgid "Another sync is still running. Please try again in a few minutes." msgstr "" -#: includes/class-m365-login-sync.php:365 +#: includes/class-m365-login-sync.php:366 msgid "The connection to Microsoft Entra ID is not configured yet." msgstr "" -#: includes/class-m365-login-sync.php:369 +#: includes/class-m365-login-sync.php:370 msgid "The user sync needs a pinned tenant ID (GUID) on the Connection tab." msgstr "" -#: includes/class-m365-login-sync.php:373 +#: includes/class-m365-login-sync.php:374 msgid "The default role does not exist. Please check the sync settings." msgstr "" #. translators: %d: number of users -#: includes/class-m365-login-sync.php:385 +#: includes/class-m365-login-sync.php:386 msgid "%d user read from Microsoft 365." msgid_plural "%d users read from Microsoft 365." msgstr[0] "" msgstr[1] "" #. translators: 1: number of accounts, 2: limit -#: includes/class-m365-login-sync.php:435 +#: includes/class-m365-login-sync.php:443 msgid "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)." msgstr "" #. translators: %s: user principal name -#: includes/class-m365-login-sync.php:559 +#: includes/class-m365-login-sync.php:567 msgid "%s: no usable e-mail address, skipped." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:566 +#: includes/class-m365-login-sync.php:574 msgid "%s: e-mail domain is not on the allow-list, skipped." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:578 +#: includes/class-m365-login-sync.php:586 msgid "%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:582 +#: includes/class-m365-login-sync.php:590 msgid "%s: existing account linked." msgstr "" -#: includes/class-m365-login-sync.php:596 includes/class-m365-login-sync.php:931 includes/class-m365-login-sync.php:1536 +#: includes/class-m365-login-sync.php:604 includes/class-m365-login-sync.php:955 includes/class-m365-login-sync.php:1652 msgid "disabled in Microsoft 365" msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:607 +#: includes/class-m365-login-sync.php:615 msgid "%s: added to this site." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:616 +#: includes/class-m365-login-sync.php:624 msgid "%s: reactivated (active in Microsoft 365 again)." msgstr "" #. translators: 1: e-mail address, 2: list of changed fields -#: includes/class-m365-login-sync.php:627 +#: includes/class-m365-login-sync.php:635 msgid "%1$s: updated (%2$s)." msgstr "" #. translators: 1: e-mail address, 2: role names -#: includes/class-m365-login-sync.php:653 +#: includes/class-m365-login-sync.php:661 msgid "%1$s: account created (%2$s)." msgstr "" #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:680 +#: includes/class-m365-login-sync.php:688 msgid "%1$s: account could not be created: %2$s" msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:738 +#: includes/class-m365-login-sync.php:746 msgid "%s: e-mail address is used by another WordPress account and was not changed." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:741 +#: includes/class-m365-login-sync.php:749 msgid "e-mail" msgstr "" +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:799 +msgid "%s removed" +msgstr "" + #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:785 +#: includes/class-m365-login-sync.php:809 msgid "%1$s: profile could not be updated: %2$s" msgstr "" #. translators: %s: role names -#: includes/class-m365-login-sync.php:891 +#: includes/class-m365-login-sync.php:915 msgid "roles: %s" msgstr "" -#: includes/class-m365-login-sync.php:926 includes/class-m365-login-sync.php:1537 +#: includes/class-m365-login-sync.php:950 includes/class-m365-login-sync.php:1653 msgid "deleted in Microsoft 365" msgstr "" -#: includes/class-m365-login-sync.php:934 includes/class-m365-login-sync.php:1538 +#: includes/class-m365-login-sync.php:958 includes/class-m365-login-sync.php:1654 msgid "no longer a member of the sync groups" msgstr "" #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:954 +#: includes/class-m365-login-sync.php:978 msgid "%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:987 +#: includes/class-m365-login-sync.php:1011 msgid "%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted." msgstr "" #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:996 +#: includes/class-m365-login-sync.php:1020 msgid "%1$s: account deleted (%2$s)." msgstr "" #. translators: 1: e-mail address, 2: reason -#: includes/class-m365-login-sync.php:1006 +#: includes/class-m365-login-sync.php:1029 msgid "%1$s: account deactivated (%2$s)." msgstr "" -#: includes/class-m365-login-sync.php:1085 +#: includes/class-m365-login-sync.php:1108 msgid "Microsoft Graph refused the request. Grant the application permissions \"User.Read.All\" and \"GroupMember.Read.All\" with admin consent in Entra ID." msgstr "" #. translators: %s: error message -#: includes/class-m365-login-sync.php:1088 +#: includes/class-m365-login-sync.php:1111 msgid "Microsoft Graph error: %s" msgstr "" -#: includes/class-m365-login-sync.php:1106 +#: includes/class-m365-login-sync.php:1129 msgid "Log truncated." msgstr "" #. translators: 1: e-mail address, 2: error message -#: includes/class-m365-login-sync.php:1168 +#: includes/class-m365-login-sync.php:1205 msgid "%1$s: profile photo could not be read: %2$s" msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1176 -msgid "%s: profile photo removed." +#: includes/class-m365-login-sync.php:1229 includes/class-m365-login-sync.php:1272 +msgid "%s: profile photo updated." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1192 +#: includes/class-m365-login-sync.php:1248 msgid "%s: profile photo could not be downloaded." msgstr "" #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1199 +#: includes/class-m365-login-sync.php:1255 msgid "%s: profile photo is not a valid image or could not be saved." msgstr "" +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1278 +msgid "%d changed profile photo will be downloaded in the next run (download limit per run reached)." +msgid_plural "%d changed profile photos will be downloaded in the next run (download limit per run reached)." +msgstr[0] "" +msgstr[1] "" + #. translators: %s: e-mail address -#: includes/class-m365-login-sync.php:1216 -msgid "%s: profile photo updated." +#: includes/class-m365-login-sync.php:1293 +msgid "%s: profile photo removed." msgstr "" -#: includes/class-m365-login-sync.php:1433 includes/class-m365-login-sync.php:1566 +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1321 +msgid "Profile photo sync is off: %d stored photo removed." +msgid_plural "Profile photo sync is off: %d stored photos removed." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-m365-login-sync.php:1549 includes/class-m365-login-sync.php:1682 msgid "Microsoft 365" msgstr "" -#: includes/class-m365-login-sync.php:1451 +#: includes/class-m365-login-sync.php:1567 msgid "Deactivated" msgstr "" -#: includes/class-m365-login-sync.php:1454 +#: includes/class-m365-login-sync.php:1570 msgid "Imported" msgstr "" -#: includes/class-m365-login-sync.php:1456 +#: includes/class-m365-login-sync.php:1572 msgid "Linked" msgstr "" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Reactivate" msgstr "" -#: includes/class-m365-login-sync.php:1484 +#: includes/class-m365-login-sync.php:1600 msgid "Deactivate" msgstr "" -#: includes/class-m365-login-sync.php:1517 +#: includes/class-m365-login-sync.php:1633 msgid "The account has been deactivated and signed out everywhere." msgstr "" -#: includes/class-m365-login-sync.php:1518 +#: includes/class-m365-login-sync.php:1634 msgid "The account has been reactivated." msgstr "" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1542 +#: includes/class-m365-login-sync.php:1658 msgid "Deactivated since %1$s (%2$s)" msgstr "" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1544 +#: includes/class-m365-login-sync.php:1660 msgid "manually" msgstr "" #. translators: 1: date, 2: reason -#: includes/class-m365-login-sync.php:1546 +#: includes/class-m365-login-sync.php:1662 msgid "Status" msgstr "" -#: includes/class-m365-login-sync.php:1549 +#: includes/class-m365-login-sync.php:1665 msgid "Object ID" msgstr "" -#: includes/class-m365-login-sync.php:1553 +#: includes/class-m365-login-sync.php:1669 msgid "Last sync" msgstr "" -#: includes/class-m365-login-sync.php:1575 +#: includes/class-m365-login-sync.php:1691 msgid "These values are managed by the Microsoft 365 user sync and overwritten on the next run." msgstr "" diff --git a/readme.txt b/readme.txt index 5f6b820..be2a63c 100644 --- a/readme.txt +++ b/readme.txt @@ -69,7 +69,7 @@ When the optional **group restriction** or the optional **user sync** is used, t * `/groups` – only when an administrator searches for groups in the settings screen. The typed search text is sent. * `/users/{id}/checkMemberGroups` – during sign-in when the ID token carries no usable `groups` claim. The user's Microsoft object ID and the configured group IDs are sent; Microsoft returns which of those groups the user belongs to. * `/users`, `/groups/{id}/transitiveMembers`, `/users/{id}` – only while the user sync runs (manually, on the configured schedule or via WP-CLI). The configured group IDs and the object IDs of linked accounts are sent; Microsoft returns the users with their account status and the profile attributes selected in the settings. -* `/users/{id}/photos/240x240`, `/users/{id}/photo` – only while the user sync runs and "Profile photo" is selected. Returns the user's profile photo. +* `/$batch` with `/users/{id}/photo`, and `/users/{id}/photos/240x240`, `/users/{id}/photo` – only while the user sync runs and "Profile photo" is selected. Returns the version and, when it changed, the image of the user's profile photo. For sign-in the plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID and uses them solely to find the matching WordPress account. The user sync stores the object ID, the account status and the attributes selected by the administrator (for example name, job title, department, phone numbers, profile photo) in the WordPress user profile; profile photos are saved in `wp-content/uploads/m365-login-avatars/` and are shown publicly wherever WordPress displays avatars. @@ -149,6 +149,7 @@ The settings, cached data, the sync report and schedule, stored profile photos a = 1.1.0 = * New: user sync – import Microsoft 365 users (whole tenant or selected groups) with a default role and group → role mapping, selectable profile attributes and profile photos as avatars. +* New: profile fields and photos follow Microsoft 365 on every run – changed photos are replaced, deleted photos and cleared or deselected fields are removed. * New: deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted; automatic reactivation; dry run, safety stop and protected administrators. * New: "Microsoft 365" column, deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen. * New: `wp m365-login sync [--dry-run]` WP-CLI command and scheduled sync via WP-Cron. From 5c9b19399ab852bfe9d1cb9fc149bd054634eecc Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:42:29 +0000 Subject: [PATCH 4/9] Restore the debug log helper of the auth component 701e85a removed M365_Login_Auth::log() while it is still called on every failure path of the callback (token exchange, token verification, object ID mismatch, group checks). Those sign-ins ended in a PHP fatal error instead of the error message on the login page. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-m365-login-auth.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 810e99a..9368cd7 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -796,6 +796,17 @@ class M365_Login_Auth { ); } + /** + * Writes a diagnostic line to the debug log (only with WP_DEBUG and WP_DEBUG_LOG; never tokens or secrets). + * + * @param string $message Message. + */ + private function log( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + error_log( '[M365 Login] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + } + /** * Aborts the flow and shows a generic error on the login screen. * From 791f43a80bb4fbc684cfeeafee2a1ee7d0e4cd93 Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:43:10 +0000 Subject: [PATCH 5/9] Add excluded Entra groups for the Microsoft sign-in New "Excluded Entra groups" card on the Security tab. Members of these groups (nested memberships count) can never sign in with Microsoft, even if they are in an allowed group. A hit in the ID token's groups claim refuses immediately. Otherwise the plugin always asks Microsoft Graph (checkMemberGroups), because a groups claim can be filtered in the app registration and cannot prove non-membership. Graph errors refuse the sign-in (fail closed). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + README.md | 15 ++- docs/security-audit.md | 9 ++ includes/class-m365-login-admin.php | 10 ++ includes/class-m365-login-auth.php | 77 ++++++++++++- includes/class-m365-login-settings.php | 11 ++ languages/m365-login-de_DE.mo | Bin 52380 -> 53745 bytes languages/m365-login-de_DE.po | 146 ++++++++++++++----------- languages/m365-login-de_DE_formal.mo | Bin 52448 -> 53812 bytes languages/m365-login-de_DE_formal.po | 146 ++++++++++++++----------- languages/m365-login.pot | 146 ++++++++++++++----------- readme.txt | 5 +- 12 files changed, 372 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24864d8..c6863e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,13 @@ All notable changes to this project are documented in this file. The format foll - Graph client: paging, retry on throttling (429/503/504), user, group member and photo endpoints. - Filters and actions for the sync (`m365_login_sync_*`, `m365_login_user_disabled`, `m365_login_user_enabled`). +- Excluded Entra groups (Security tab): members can never sign in with Microsoft, even when they are in an allowed group. Checked via the `groups` claim and always via Microsoft Graph `checkMemberGroups` (a filtered claim cannot prove non-membership); fails closed. + ### Changed - The group picker is reusable (security groups, sync groups, role mapping). ### Fixed +- Failed Microsoft sign-ins (token exchange, token verification, object ID mismatch, group checks) ended in a PHP fatal error because the auth component's log helper had been removed in 1.0.0 development. - "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser). ## [1.0.0] – 2026-09-22 diff --git a/README.md b/README.md index ee0f185..9020155 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ | 📧 **Zuordnung über die E-Mail-Adresse** | Der Login legt **keine Benutzer an**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | | 🔄 **Benutzer-Sync (optional)** | Importiert Microsoft-365-Benutzer als WordPress-Konten – mit Standardrolle, zusätzlichen Rollen per Gruppen-Zuordnung, wählbaren Profilfeldern und Profilbild. In Microsoft 365 deaktivierte oder gelöschte Konten werden in WordPress deaktiviert oder gelöscht. | | 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. | -| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. | +| 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen und/oder Mitglieder bestimmter Gruppen ausschließen. Gruppen werden direkt im Backend gesucht und ausgewählt. | | 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. | | 🔏 **Secret oder Zertifikat** | Wahlweise Client Secret oder zertifikatsbasierte Authentifizierung (RFC 7523). Zertifikat mit einem Klick im Backend erzeugen, nur der öffentliche Teil geht zu Microsoft. | | 🛡️ **Sicher by default** | OpenID Connect + PKCE, Signaturprüfung, Tenant-Pinning, Konto-Bindung, verschlüsseltes Secret, [Security-Audit](docs/security-audit.md). | @@ -243,6 +243,16 @@ Prüfreihenfolge beim Login: Leere Liste = keine Beschränkung. +**Ausgeschlossene Entra-Gruppen** (gleicher Tab, darunter): Mitglieder dieser Gruppen können sich **nie** per Microsoft +anmelden – auch wenn sie in einer erlaubten Gruppe sind (Ausschluss hat Vorrang, verschachtelte Mitgliedschaften zählen). + +1. Steht eine ausgeschlossene Gruppe im `groups`-Claim → sofort abgelehnt. +2. Sonst wird **immer** Microsoft Graph gefragt (`checkMemberGroups`, Berechtigung `User.Read.All`), denn ein `groups`-Claim + kann in der App-Registrierung gefiltert sein und beweist nicht, dass jemand *kein* Mitglied ist. +3. Schlägt die Graph-Prüfung fehl → Anmeldung abgelehnt (fail closed). + +Die Passwort-Anmeldung betrifft das nicht; wer auch die sperren will, kombiniert es mit dem Nur-Button-Modus. + ### Nur-Button-Modus & Fallback Im Tab *Sicherheit* → **Button-only mode**: @@ -482,7 +492,8 @@ add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 1 | *No WordPress account exists for your Microsoft e-mail address.* | E-Mail in WordPress stimmt nicht mit Microsoft überein. | | *This WordPress account is linked to a different Microsoft account.* | Objekt-ID weicht ab. Wenn gewollt (neues Microsoft-Konto): User-Meta `_m365_login_oid` beim Benutzer löschen. | | *… not a member of a group that is allowed …* | Benutzer ist in keiner der ausgewählten Gruppen. | -| *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. | +| *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. Bei ausgeschlossenen Gruppen ist Graph immer nötig. | +| *… member of a group that is not allowed to sign in here.* | Benutzer ist Mitglied einer ausgeschlossenen Gruppe (auch verschachtelt). | | *This account has been deactivated.* | Das Konto wurde vom Benutzer-Sync oder von Hand deaktiviert. *Benutzer → Zeilenaktion „Reaktivieren“* – ist die Person in Microsoft 365 noch deaktiviert, deaktiviert der nächste Sync sie wieder. | | Meldung im Sync-Protokoll | Ursache & Lösung | diff --git a/docs/security-audit.md b/docs/security-audit.md index 53b4344..50eb2c3 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -137,6 +137,15 @@ Der Sync legt Konten an, ändert Rollen und deaktiviert bzw. löscht Konten. Gep - **Graph-Aufrufe:** nur `https://graph.microsoft.com/v1.0/`; Paging-Links werden auf diesen Präfix geprüft, IDs sind GUIDs. - **Ausgabe:** Protokoll und Profilfelder werden escaped ausgegeben; Benutzer-Zeilenaktionen mit Nonce und `edit_user`. +### N-8 · Ausgeschlossene Gruppen und fehlende Log-Methode (1.1.0) — **neu / behoben** + +- **Ausgeschlossene Entra-Gruppen:** Ausschluss vor Erlaubnis; Treffer im `groups`-Claim lehnt sofort ab, ohne Treffer wird + immer Graph `checkMemberGroups` (transitiv) gefragt, weil ein gefilterter Claim Nicht-Mitgliedschaft nicht beweist; + Graph-Fehler → Ablehnung (fail closed). +- **Behoben:** `M365_Login_Auth::log()` fehlte seit der Umstellung auf eigene Login-Seiten; alle Fehlerpfade des Callbacks + endeten in einem PHP-Fatal-Error (kein Sicherheitsleck – die Anmeldung scheiterte –, aber keine Fehlermeldung und ein + 500er). Durch Tests in einer echten Installation gefunden. + Hinweis für den Betrieb: Personenbezogene Daten (Telefon, Adresse, Foto) nur synchronisieren, wenn sie auf der Website gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen). diff --git a/includes/class-m365-login-admin.php b/includes/class-m365-login-admin.php index 6c6ef67..cbfff4d 100644 --- a/includes/class-m365-login-admin.php +++ b/includes/class-m365-login-admin.php @@ -1081,6 +1081,16 @@ class M365_Login_Admin {
    +
    +

    +

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

    +
    +
    +

    diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 9368cd7..56824b9 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -642,6 +642,75 @@ class M365_Login_Auth { return $body; } + /** + * Applies the Entra group rules: members of an excluded group are refused, + * everybody else needs membership in one of the allowed groups (if any are set). + * + * @param array $claims Verified claims. + * @param string $oid User object ID. + * @return true|string True, or an error code for fail(). + */ + private function check_groups( $claims, $oid ) { + $denied = $this->check_denied_groups( $claims, $oid ); + if ( true !== $denied ) { + return $denied; + } + return $this->check_allowed_groups( $claims, $oid ); + } + + /** + * Group IDs from the "groups" claim, or null when the token has no complete list (claim missing or overage). + * + * @param array $claims Verified claims. + * @return string[]|null + */ + private function token_groups( $claims ) { + $overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] ); + if ( $overage || ! isset( $claims['groups'] ) || ! is_array( $claims['groups'] ) ) { + return null; + } + return array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) ); + } + + /** + * Refuses members of an excluded group (fails closed). + * + * A "groups" claim can be filtered in the app registration (e.g. only groups assigned to the + * application), so it can prove membership but never non-membership: without a match in the + * token the plugin always asks Microsoft Graph. + * + * @param array $claims Verified claims. + * @param string $oid User object ID. + * @return true|string True, or an error code for fail(). + */ + private function check_denied_groups( $claims, $oid ) { + $denied = array_keys( $this->settings->denied_groups() ); + if ( empty( $denied ) ) { + return true; + } + + $token_groups = $this->token_groups( $claims ); + if ( null !== $token_groups && array_intersect( $denied, $token_groups ) ) { + $this->log( 'User is a member of an excluded group (token claim).' ); + return 'in_denied_group'; + } + + if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { + return 'invalid_token'; + } + + $matches = $this->graph->check_member_groups( $oid, $denied ); + if ( is_wp_error( $matches ) ) { + $this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() ); + return 'group_check_failed'; + } + if ( ! empty( $matches ) ) { + $this->log( 'User is a member of an excluded group (Graph).' ); + return 'in_denied_group'; + } + return true; + } + /** * Verifies membership in one of the allowed Entra groups. * @@ -652,15 +721,14 @@ class M365_Login_Auth { * @param string $oid User object ID. * @return true|string True, or an error code for fail(). */ - private function check_groups( $claims, $oid ) { + private function check_allowed_groups( $claims, $oid ) { $allowed = array_keys( $this->settings->allowed_groups() ); if ( empty( $allowed ) ) { return true; } - $overage = ! empty( $claims['_claim_names'] ) || ! empty( $claims['hasgroups'] ); - if ( ! $overage && isset( $claims['groups'] ) && is_array( $claims['groups'] ) ) { - $token_groups = array_map( 'strtolower', array_filter( $claims['groups'], 'is_string' ) ); + $token_groups = $this->token_groups( $claims ); + if ( null !== $token_groups ) { if ( array_intersect( $allowed, $token_groups ) ) { return true; } @@ -869,6 +937,7 @@ class M365_Login_Auth { 'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ), 'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ), 'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ), + 'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ), 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ), 'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ), diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index 5852f00..566450b 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -46,6 +46,7 @@ class M365_Login_Settings { 'bind_oid' => 1, 'allowed_domains' => '', 'allowed_groups' => array(), // id => display name. + 'denied_groups' => array(), // id => display name; members may never sign in. 'remember_me' => 0, // Button-only mode. 'button_only' => 0, @@ -333,6 +334,15 @@ class M365_Login_Settings { return self::guid_map( $this->get( 'allowed_groups', array() ) ); } + /** + * Excluded Entra group IDs (lowercase GUIDs) mapped to display names. + * + * @return array + */ + public function denied_groups() { + return self::guid_map( $this->get( 'denied_groups', array() ) ); + } + /** * Groups that limit the user sync (lowercase GUID => name); empty = whole tenant. * @@ -552,6 +562,7 @@ class M365_Login_Settings { // Allowed groups: GUID => name. $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() ); + $out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() ); // Button-only mode + fallback key. $out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index 982d3109dad5f74e15f577027391086ac44f50fd..efb30b89dc3de3aedfac521ce3612c89cb5669ba 100644 GIT binary patch delta 7074 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 delta 6139 zcmYM&3%HI|z5wtgV%u)zo?D`Ja*15Zc8yFC5{9Wv7q>wu$wb9`Ou5ZSQlz`mZB&}< zE|SuiqQo>A%v7SOoO33p)8%;PoF?Zqzklm{p7D76)_;BPyWX|_>%ZP_Z(pygeE&U_ z3kT~~S(W^mT`8qz+*>1}fBjk3D5bi>+c=hM*@nk8PN_ND@e~d!##0%eFJ(J!WCI@L z79M4PK6g?|r70ECQ4M3J?>U5xnxxc~6BrY^hh=<`llT$a@<&Zm>dhI{l{OT8o1Mg; zF|Kc5keQsvf&7Z~S#I_%JwIKf(M89#tj%?-%#G~97a0%ynkTbai@f4q)RnGaAI{}j ze1$RLFW8Uu-DIU{G-IJ-IkHkpH*$VK$L^N-f{WxaF1(bH@(G;F*?gBr88d&QRZ5-t zKC7~3>y#$49%BJFGWwS=cJp?g!262v(>zhUiG|e~J2ggf?5QcqT6&@Adz>u(f-#}d zr=?_<(iB$VEsWGI;SzqwTD;ZrM{y;q@jv)ee#Pn>dU{^yPfnNrxL}-)81QPw0M{~Z zn8#}TfVc4zUQo%NtFRwda7IeU>)*-9+#lJJA2L>cjIl)}_OB+(7+cVV${-DHEB|rh zWF1kZ*E6biDdUD0if&`6_+3VZK447j@3f8S|2d2;+#C%jHw&n${7hX_wIMr1e!wa~8TlqGta%s1`RVx@1 zdw{k1DkEdN*pGi^jB}Fo%wu2P8t4C2jT3bAKPROX9LaaVSKN_}qo0wX z0%PI#4l3lLd0a;e9orduceofI zbw)Zwjs3Zo&sUPYtMk_Fnwnqfg+m&#cSEM-clynY6s}=&ZekbiXJ?j9&*!-}cZo0I zP@blMVrAE}H49XKX&vM9%Gc)8Gn{c+uIDW-OHXRV1KQ8XKmT9FNcA&p#_jCHPdStI zuX8lHnBGaLna;R=23v9|yK_Bj@>9mdzG5F9V+Z!UJ|<-Ur)ad&@oUD+H*g^Lup^t! z$`csO!Q#1$0pDVrhP|9AwMY1{_=+3ycR`w+udBg&`Wx{!w&P*G%*LLd9-5O=yNOXG zM`*0{4I6RNjk&+Tc<>^gz?CfJx?;S6uZXvDEN@oPXYwP)X{dQ~{%+{QnE0D4M3EfO zsLXHp2>;F}dC%PZ2JAmC|7DiC3{P*A?Bun9x3+%tMUY`JQoq zr(fpG^VI22G1&s+B%NaNRH=|fuEy^#Nv)NC4G4J6@R%QFe`3H$EtWnAR zj1}Fx#1{`A<00ccpGLl_yE z%lLc+V*>ZF4L_whrkX4AmaJiY@qjz>3NK|%8LG81kK5$V{COM3@1Lo+MiVzoUqicVRT_p~#+iu+`279yf3rr%2l5J@;$!0d?7_JTU>RTH)g16p_E|O%H(8tC@tydkcm*SK^B*>8 zwtpo5Kfu3ny13W6Tb)A(QI~bJ>zuDd_3>{!;H-Q zjq!e{@kCC28=fxi#wf+^umGgA3G zx+lHEILFsp=4{@n2_dz4jmG=Dbl z;i+sY?#kzQF*}*@albVnyXcH97@=a%<7CGDPcs%&!6@2oEajhgA`kKwe#IWfDa@+K z$Kl!M^YM6tk+vD(Vg=*9G5^Kf|2M`2|BvOY z^3o4q(}mPWBT_n>?n$dyje|Gk35{ew@mMzCI=14=Jf8*~$tne(xgwJDl4&_-~ z#LnErt~|nKasFFw&M%tYj2Ta7Pwr%NkUubh{QCo?9ro#iaql8drGT+C*C zossg7_#}_;H@xf3{NO&@^A_%B4gCjMm7nuee$BmXxWoFIY8=^-tG3!(xtJQVjs7ym z@fpUL@im;rwd}!KZ|52q$Vm0=ypp@Pi0yagFP{p=M7FRx-(nN)*(txVqAzsB3`^h1 zp2$*hV@5HyW^1-%6%J!f{+Km5mT~=b#`SX;8Jf>1%H@pf*A{(-u@#%kHDU|iVO)5K zu@zqz`>Qxh^~H@Dnd-nOo(#LB;qE-~7Tf>Us+x}6b;=w
    @@ -506,11 +539,30 @@ class M365_Login_Admin { elements for all editable roles (escaped, unlike wp_dropdown_roles()). + * + * @param string $selected Selected role slug. + */ + private function role_options( $selected ) { + if ( ! function_exists( 'get_editable_roles' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + foreach ( array_reverse( get_editable_roles() ) as $role => $details ) { + printf( + '', + esc_attr( $role ), + selected( $selected, $role, false ), + esc_html( translate_user_role( $details['name'] ) ) + ); + } + } + /** * Sends the public certificate as a .cer download (never the private key). */ public function download_certificate() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 ); } check_admin_referer( self::POST_CERT ); @@ -599,13 +651,14 @@ class M365_Login_Admin {

    +

    group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?>
    @@ -703,7 +756,7 @@ class M365_Login_Admin { * Renders the settings screen. */ public function render() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) ); } @@ -806,6 +859,9 @@ class M365_Login_Admin {

    + +

    +
    > @@ -1085,6 +1141,9 @@ class M365_Login_Admin {

    + settings->is_multi_tenant() ) : ?> +

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

    @@ -1093,7 +1152,7 @@ class M365_Login_Admin {

    -

    +