diff --git a/CHANGELOG.md b/CHANGELOG.md index 739ce8f..c4f1263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ 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. +- Profile photos follow Microsoft 365 on every run: versions are compared via Graph `$batch` (20 users per request), changed photos are downloaded again (old file deleted, new URL), photos deleted in Microsoft 365 are deleted in WordPress. Errors never delete a photo. +- Deselected profile fields (`m365_*`) and photos are removed from the profiles on the next run; fields cleared in Microsoft 365 are cleared in WordPress; a user's photo is deleted together with the user. +- 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`). + +- 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). + +- "Link Microsoft account" on the profile screen (the signed-in user binds their own Microsoft account) and an administrator-assigned Microsoft account (UPN) per user – for administrators whose user principal name differs from their e-mail address. Sign-in finds bound accounts by object ID first, then by assigned UPN, then by e-mail. +- Privacy exporter and eraser for the data the plugin copies. + +### Changed +- No frame around the login box (form and Microsoft block) on wp-login.php. + +### Fixed +- Button-only mode: the "Lost your password?" link is removed server-side, the lost-password and reset screens redirect to the login page and password resets are refused (unless the fallback link is active); the login stylesheet is also loaded while the connection is broken, so the password form stays hidden. +- Accounts whose WordPress e-mail is the user principal name (while the Microsoft mail differs) were not found at sign-in and got a duplicate account from the sync; sign-in and sync now try the mail address and the UPN. + +### Security +Third audit (details: docs/security-audit.md, section 7): XML-RPC `system.multicall` bypass of button-only mode closed; privileged-account rules extended (bind_oid off, multisite-wide capabilities, code/HTML capabilities, deactivated administrators); cookie protection on WordPress 6.0/6.1; atomic and refreshed run lock with crash report; per-account tenant for deprovisioning; demotion safety stop; safety stop based on accounts linked before the run; photos re-encoded and removed on deactivation; one-time hardening of accounts deactivated by 1.0. + +Fixes from a full second security audit (details: docs/security-audit.md, section 6): +- Multisite: settings, connection test, certificates and user sync require `manage_network_options` (a site admin could otherwise sign in as the super admin via an own tenant). +- Administrator accounts are only linked (sync and first sign-in) through a matching user principal name of a member account, never through the freely settable `mail` attribute; their e-mail address is never changed by the sync; e-mail change notifications stay on for other accounts. +- Button-only mode: exemption by credential (application passwords, WP-CLI) instead of request context – closes bypasses through `xmlrpc.php` and REST login handlers of other plugins; API requests never receive login cookies; same message for right and wrong passwords; mode no longer switches off when the connection breaks; fallback cookie expires on the server; the correct fallback key works despite IP lockouts; right-most proxy header entry; start limit raised to 300. +- Multi-tenant mode refuses guest/external identities; one Microsoft object ID can only be bound to one WordPress account. +- Deactivation also sets a random password, revokes application passwords and removes the role (restored on reactivation); linked people disabled in Microsoft 365 are deactivated even if their e-mail vanished or changed domain; duplicate bindings are all deprovisioned. +- Sync safety: abort on an empty directory answer, no deprovisioning in the first run after a tenant change, atomic run lock, strict photo path validation. +- Certificates: key+certificate bundles are refused in the certificate field and only a clean re-exported certificate is stored. +- Robust sanitising of array input, encoded `redirect_to` in the button URL, separate nonces per AJAX action, escaped role lists, no Graph retries/sleeps during sign-in, warnings for public Microsoft 365 groups, group rules in multi-tenant mode and salts missing from wp-config.php, cached Graph token removed on uninstall. + +### 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 ### Added diff --git a/README.md b/README.md index a071a9c..fe76c76 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ - [Gruppen-Beschränkung](#gruppen-beschränkung) - [Nur-Button-Modus & Fallback](#nur-button-modus--fallback) - [Eigene Login-Seite](#eigene-login-seite) + - [Benutzer-Sync](#benutzer-sync) + - [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen) - [Sicherheitskonzept](#sicherheitskonzept) - [Shortcode & Hooks](#shortcode--hooks) - [Fehlerbehebung](#fehlerbehebung) @@ -46,9 +48,10 @@ | | | | --- | --- | | 🔑 **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. | +| 👥 **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). | @@ -170,7 +173,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. --- @@ -209,7 +224,7 @@ Alles wird live in der Vorschau angezeigt, bevor du speicherst. | Option | Standard | Beschreibung | | --- | --- | --- | | Konto an Microsoft-Objekt-ID binden | an | Beim ersten Login wird die `oid` gespeichert; danach muss sie übereinstimmen. Schützt vor Übernahme, wenn eine E-Mail-Adresse in Microsoft neu vergeben wird. | -| UPN-Fallback | an | Fehlt der `email`-Claim, wird der User Principal Name verwendet, sofern er eine gültige E-Mail-Adresse ist. | +| UPN-Fallback | an | Neben dem `email`-Claim wird auch der User Principal Name gesucht – wichtig, wenn WordPress-Konten den UPN statt der Mailadresse tragen. Der Abgleich läuft in der Reihenfolge: gebundene Objekt-ID → zugewiesener UPN → Mailadresse → UPN. | | Angemeldet bleiben | aus | 14-Tage-Session statt Browser-Session. | | Erlaubte E-Mail-Domains | leer | Kommagetrennte Liste, z. B. `contoso.com, contoso.de`. | @@ -229,14 +244,32 @@ 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**: -- Blendet Benutzername/Passwort-Felder und den „Passwort vergessen?“-Link aus. +- Blendet Benutzername/Passwort-Felder aus. Der „Passwort vergessen?“-Link wird serverseitig entfernt, die + Passwort-vergessen-Seite leitet zur Anmeldung um, und Passwort-Resets (auch „Passwort zurücksetzen“ in der + Benutzerliste) sind gesperrt – mit aktivem Fallback-Link funktioniert beides wie gewohnt. - **Sperrt Passwort-Logins serverseitig**, nicht nur per CSS – auf `wp-login.php` und in jedem eigenen Login-Formular (`authenticate`-Filter). -- Application Passwords, REST API, XML-RPC, WP-CLI und Cron sind nicht betroffen. Einzelne Ausnahmen per Filter `m365_login_block_password_login`. -- Wird erst aktiv, wenn die Verbindung vollständig konfiguriert ist. +- Unterschieden wird nach **Zugangsdaten, nicht nach Anfrage-Typ**: Das normale Passwort wird überall abgelehnt – auch über + XML-RPC und in Login-Handlern anderer Plugins, die in `xmlrpc.php` oder einer REST-Anfrage laufen. Application Passwords + (REST, XML-RPC) und WP-CLI funktionieren weiter; API-Anfragen bekommen aber nie ein Login-Cookie. +- Falsches und richtiges Passwort erhalten dieselbe Meldung (kein Passwort-Orakel). +- Einzelne Ausnahmen per Filter `m365_login_block_password_login`. +- Bleibt aktiv, auch wenn die Verbindung zu Microsoft kaputtgeht (abgelaufenes Zertifikat, rotierte Salts) – dann gibt es + einen roten Hinweis im Backend, und nur der Fallback-Link oder die Konstante helfen. Passwort-Logins schalten sich nie + stillschweigend wieder ein. **Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an: @@ -272,6 +305,104 @@ 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 + +> **Privilegierte Konten** (Administratoren, Redakteure mit `unfiltered_html`, alle mit Rechten an Benutzern, Plugins +> oder Themes – auf irgendeiner Site des Netzwerks – sowie deaktivierte Konten, die solche Rollen zurückbekämen) werden +> nie über das frei setzbare `mail`-Attribut verknüpft. Siehe [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen). +> Ihre E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über +> eine Änderung. + +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 (Mailadresse **oder** UPN) 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. Achtung: Avatare sind öffentlich sichtbar, wo WordPress sie anzeigt. + +Microsoft 365 hat immer Vorrang, bei jedem Lauf: + +| In Microsoft 365 … | … in WordPress | +| --- | --- | +| Feld geändert | Feld wird überschrieben. | +| Feld geleert | Feld wird geleert (`m365_*`-Meta gelöscht; ein leerer Anzeigename wird nicht übernommen). | +| Profilbild geändert | Neues Bild wird geladen, das alte gelöscht; die Avatar-URL ändert sich, damit Browser nicht das alte Bild zeigen. | +| Profilbild gelöscht | Bild wird gelöscht, der Avatar fällt auf Gravatar zurück. | + +Die Bildversionen werden per Graph-`$batch` geprüft (20 Benutzer pro Anfrage); heruntergeladen werden nur geänderte Bilder, +höchstens 500 pro Lauf, der Rest folgt im nächsten (Filter `m365_login_sync_photo_limit`, Prüfintervall per +`m365_login_sync_photo_interval`). Bei einem Fehler von Microsoft bleibt das vorhandene Bild erhalten – gelöscht wird nur, +wenn Microsoft ausdrücklich „kein Foto“ meldet. **Abgewählte** Felder und Bilder werden beim nächsten Lauf aus den Profilen +entfernt (Vor-, Nach- und Anzeigename bleiben stehen); wird ein Benutzer in WordPress gelöscht, wird auch sein Bild gelöscht. + +**Deaktivierte Konten** bekommen ein Zufallspasswort, verlieren ihre Rolle auf der Site (sie wird gemerkt und bei der +Reaktivierung zurückgegeben) und alle Application Passwords – so bleiben sie auch gesperrt, wenn das Plugin einmal +deaktiviert wird. Sie 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 +``` + +### Administrator-Konten verknüpfen + +Das `mail`-Attribut in Entra ID kann jeder Benutzer- oder Exchange-Administrator des Tenants frei setzen – wer es auf die +Adresse eines WordPress-Admins setzt, dürfte sonst dessen Konto übernehmen. Privilegierte Konten werden deshalb nur auf +einem dieser Wege mit einem Microsoft-Konto verknüpft (per Anmeldung oder Sync): + +| Weg | Wann sinnvoll | +| --- | --- | +| **Selbst verknüpfen:** *Profil → Microsoft 365 → „Mit Microsoft-Konto verknüpfen“*. Die Person ist in WordPress angemeldet (beweist das WordPress-Konto) und meldet sich einmal bei Microsoft an (beweist das Microsoft-Konto). | Immer – auch wenn UPN und Mailadresse völlig verschieden sind. Im Nur-Button-Modus vorher über den Fallback-Link mit Passwort anmelden. | +| **Zuweisen:** Ein Administrator trägt beim Bearbeiten des Benutzers unter *Microsoft 365* den **UPN** ein (*Zugewiesenes Microsoft-Konto*). Anmeldung und Sync verknüpfen genau dieses Konto. | Mehrere Admins einrichten, ohne dass jeder selbst klicken muss. | +| **Automatisch:** UPN eines Mitglieds (kein Gast) = WordPress-E-Mail. | Wenn UPN und Mailadresse bei euch gleich sind. | + +Nach der Verknüpfung findet die Anmeldung das Konto über die unveränderliche Objekt-ID – E-Mail-Adresse oder UPN dürfen sich +danach ändern. Eine bestehende Verknüpfung kann nur ein Administrator aufheben (*Verknüpfung mit dem Microsoft-Konto +aufheben* im Profil); eine Person kann ihr Konto nicht selbst auf ein anderes Microsoft-Konto umhängen. + --- ## Sicherheitskonzept @@ -284,11 +415,13 @@ 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`). | -| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. | +| Flooding der State-Tabelle | Max. 300 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER` – am besten ein einwertiger Header wie `HTTP_CF_CONNECTING_IP` oder `HTTP_X_REAL_IP` (bei `X-Forwarded-For` zählt der rechte, vom Proxy geschriebene Eintrag). | | Offene Redirects | `redirect_to` läuft durch `wp_validate_redirect`, alle Redirects über `wp_safe_redirect`. | | Fehler-Reflektion | Fehlermeldungen sind Codes → feste, übersetzte Texte; Details nur ins Log (`WP_DEBUG_LOG`). | | Rate Limiting Fallback-Key | 10 Fehlversuche pro IP / 15 Min. | @@ -346,6 +479,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, m365_login_is_privileged_user +// Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled +``` + --- ## Fehlerbehebung @@ -360,7 +525,17 @@ add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m3 | *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 | +| --- | --- | +| *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 +551,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 +588,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.
@@ -422,12 +598,19 @@ Ja, Tenant auf consumers oder common stellen. Microsof
Multisite? -Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Super-Admin) sein. +Ja. Einstellungen gelten pro Site, dürfen aber **nur von Super-Admins** geändert werden: Sie entscheiden, welche +Microsoft-Identität sich als welcher (netzwerkweite) WordPress-Benutzer anmelden darf. Ein Site-Admin könnte sonst einen +eigenen Tenant eintragen und sich als Super-Admin anmelden. Der Benutzer muss Mitglied der Site (oder Super-Admin) sein.
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 bleiben ohne Rolle, mit Zufallspasswort und ohne Application Passwords – der Deaktivierungs-Vermerk selbst wird entfernt. +
+ +
+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/css/login.css b/assets/css/login.css index 4743224..9c2f374 100644 --- a/assets/css/login.css +++ b/assets/css/login.css @@ -12,13 +12,14 @@ background: #fff; padding: 0 24px 26px; margin: -1px 0 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); - border: 1px solid #c3c4c7; - border-top: 0; + border: 0; + box-shadow: none; } +/* No frame around the login box: neither around the form nor around the Microsoft block. */ +body.m365-login-page #loginform, body.m365-login-attached #loginform { - border-bottom: 0; + border: 0; box-shadow: none; } @@ -26,8 +27,8 @@ body.m365-login-attached #loginform { background: #fff; padding: 24px; margin: 20px 0 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); - border: 1px solid #c3c4c7; + border: 0; + box-shadow: none; } .m365-login--above .m365-login__divider { @@ -123,16 +124,18 @@ body.m365-login-attached #loginform { /* Button-only mode: the password form is hidden until the fallback key is used. */ body.m365-button-only #loginform, -body.m365-button-only #nav { - display: none; +body.m365-button-only #nav, +body.m365-button-only a[href*="action=lostpassword"], +body.m365-button-only .wp-login-lost-password { + display: none !important; } body.m365-button-only .m365-login--below, body.m365-button-only .m365-login--above { margin-top: 20px; padding: 26px 24px; - border: 1px solid #c3c4c7; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + border: 0; + box-shadow: none; } /* Custom login pages: wp_login_form() injection, shortcode and template function. */ diff --git a/assets/js/admin.js b/assets/js/admin.js index a8cbb45..28b83f6 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -3,6 +3,7 @@ 'use strict'; var cfg = window.m365LoginAdmin || {}; + cfg.nonces = cfg.nonces || {}; var i18n = cfg.i18n || {}; $( function () { @@ -166,7 +167,7 @@ $btn.prop( 'disabled', true ); $out.removeClass( 'is-success is-error' ).prop( 'hidden', false ).text( i18n.generating || '…' ); - $.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonce, op: 'generate' } ).done( function ( res ) { + $.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonces.cert, op: 'generate' } ).done( function ( res ) { if ( res && res.success ) { $out.addClass( 'is-success' ).text( res.data.message ); window.setTimeout( function () { window.location.reload(); }, 800 ); @@ -221,101 +222,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 ) : ?> + +
    • + + + + + + + + + + + + + +
    • + +
    + + + +
    +
  • + 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 ); - $pem = $this->settings->certificate_pem(); + // Only ever the re-exported public certificate, whatever an older version stored. + $pem = M365_Login_Certificate::clean_pem( $this->settings->certificate_pem() ); if ( '' === $pem ) { wp_die( esc_html__( 'No certificate is stored.', 'm365-login' ), 404 ); } @@ -356,11 +583,181 @@ class M365_Login_Admin { exit; } + /** + * Renders the "User sync" tab. + * + * @param array $s Settings. + */ + private function render_sync_panel( $s ) { + $field = function ( $key ) { + return esc_attr( M365_LOGIN_OPTION . '[' . $key . ']' ); + }; + $configured = $this->settings->is_configured(); + $next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK ); + $actions = array( + 'none' => __( 'Do nothing', 'm365-login' ), + 'disable' => __( 'Deactivate the WordPress account', 'm365-login' ), + 'delete' => __( 'Delete the WordPress account', 'm365-login' ), + ); + $selects = array( + 'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ), + 'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ), + 'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ), + ); + ?> +
    +
    +

    +

    + + + +
    +
    + + + + +

    + +
    +
    + + + +

    +

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

    + +
    + + +

    +
    + +

    +

    +

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

    +

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

    +
    + +
    +

    +

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

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

    +
    + +

    +
    + +
    +

    +

    +
    + + +
    +

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

    + +

    +
    > @@ -695,7 +1095,7 @@ class M365_Login_Admin {

    -

    +

    +
    +

    +

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

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

    +
    +
    +

    -

    +

    + render_sync_panel( $s ); ?> +
    @@ -846,7 +1237,7 @@ class M365_Login_Admin {
  • -

    +

    diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 757d9b3..2defd7c 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -20,6 +20,9 @@ class M365_Login_Auth { const STATE_TTL = 600; // 10 minutes. const META_OID = '_m365_login_oid'; const META_LAST_LOGIN = '_m365_login_last_login'; + const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to. + const META_UPN = '_m365_login_upn'; // Microsoft account (UPN) assigned by an administrator. + const LINK_NONCE = 'm365_login_link'; const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS; const HTTP_TIMEOUT = 15; @@ -37,6 +40,13 @@ class M365_Login_Auth { */ private $graph; + /** + * User authenticated by an application password in the current authenticate pass (0 = none). + * + * @var int + */ + private $app_password_user = 0; + /** * Constructor. * @@ -55,6 +65,17 @@ class M365_Login_Auth { add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 ); // Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error. add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 ); + // Track application-password sign-ins per authenticate pass (XML-RPC multicall runs several passes per request). + add_filter( 'authenticate', array( $this, 'reset_app_password_user' ), 0 ); + add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) ); + // Button-only mode: no "Lost your password?" link, no reset screen, no reset e-mails. + add_filter( 'lost_password_html_link', array( $this, 'hide_lost_password_link' ), 99 ); + add_filter( 'allow_password_reset', array( $this, 'block_password_reset' ), 99 ); + foreach ( array( 'lostpassword', 'retrievepassword', 'rp', 'resetpass' ) as $reset_action ) { + add_action( 'login_form_' . $reset_action, array( $this, 'block_reset_screen' ) ); + } + // No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled. + add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 ); // Custom login page: send people back there after logging out. add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 ); @@ -74,16 +95,25 @@ class M365_Login_Auth { return true; // Nothing is hidden, the form is always available. } $cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : ''; - return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie ); + $parts = explode( '|', $cookie ); + if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) { + return false; + } + $issued = (int) $parts[0]; + if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) { + return false; // Expired on the server side, whatever the browser keeps. + } + return hash_equals( $this->fallback_cookie_value( $issued ), $cookie ); } /** - * Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie). + * Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie). * + * @param int $issued Issue timestamp. * @return string */ - private function fallback_cookie_value() { - return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) ); + private function fallback_cookie_value( $issued ) { + return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) ); } /** @@ -99,20 +129,21 @@ class M365_Login_Auth { return; } - // Slow down brute force attempts on the key. - $ip_key = 'm365_login_fb_' . md5( $this->client_ip() ); - $attempts = (int) get_transient( $ip_key ); - if ( $attempts >= 10 ) { - $this->fail( 'fallback_locked' ); - } - + // The correct key always works (a shared office IP must not lock the administrator out); + // wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise. + $ip_key = 'm365_login_fb_' . md5( $this->client_ip() ); if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) { + $attempts = (int) get_transient( $ip_key ); + if ( $attempts >= 10 ) { + $this->fail( 'fallback_locked' ); + } set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS ); $this->fail( 'fallback_invalid' ); } delete_transient( $ip_key ); - $this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL ); + $issued = time(); + $this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL ); nocache_headers(); wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) ); exit; @@ -149,16 +180,14 @@ class M365_Login_Auth { if ( '' === (string) $username && '' === (string) $password ) { return $user; // Initial form render or cookie auth, no password attempt. } - // Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working. - if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) - || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) - || ( defined( 'WP_CLI' ) && WP_CLI ) - || wp_doing_cron() ) { + // Exempt by credential, not by request context: application passwords (XML-RPC, REST) and + // WP-CLI keep working. A normal password is refused everywhere – also in forms of other + // plugins that happen to run inside xmlrpc.php or a REST request. + // The application password must have authenticated exactly this user in this pass – did_action() + // is request-global and would let a later multicall boxcar through with a normal password. + if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && $this->app_password_user && $this->app_password_user === $user->ID ) ) { return $user; } - if ( ! $user instanceof WP_User ) { - return $user; // Already failed for another reason; keep core's message. - } /** * Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin). @@ -166,13 +195,102 @@ class M365_Login_Auth { * @param bool $block Whether to block. Default true. * @param WP_User $user Authenticated user. */ - if ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) { + if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) { return $user; } + // Same answer for right and wrong passwords: the form must not become a password oracle. return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) ); } + /** + * Whether password resets are switched off (button-only mode without an active fallback). + * + * @return bool + */ + private function passwords_disabled() { + return $this->settings->button_only() && ! $this->fallback_active(); + } + + /** + * Removes the "Lost your password?" link from wp-login.php (server side, independent of CSS). + * + * @param string $html Link markup. + * @return string + */ + public function hide_lost_password_link( $html ) { + return $this->passwords_disabled() ? '' : $html; + } + + /** + * Refuses password resets (form, e-mails, "Send password reset" in the users list). + * + * @param bool|WP_Error $allow Whether the reset is allowed. + * @return bool|WP_Error + */ + public function block_password_reset( $allow ) { + if ( ! $this->passwords_disabled() ) { + return $allow; + } + return new WP_Error( 'm365_login_no_password_reset', __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', 'm365-login' ) ); + } + + /** + * Sends the lost-password and reset screens back to the login page. + */ + public function block_reset_screen() { + if ( ! $this->passwords_disabled() ) { + return; + } + nocache_headers(); + wp_safe_redirect( add_query_arg( 'm365_error', 'password_reset_disabled', $this->settings->login_page_url() ) ); + exit; + } + + /** + * Starts a new authenticate pass (runs first on the authenticate filter). + * + * @param null|WP_User|WP_Error $user Result so far (unchanged). + * @return null|WP_User|WP_Error + */ + public function reset_app_password_user( $user ) { + $this->app_password_user = 0; + return $user; + } + + /** + * Remembers which user an application password authenticated in the current pass. + * + * @param WP_User $user Authenticated user. + */ + public function remember_app_password_user( $user ) { + $this->app_password_user = $user instanceof WP_User ? (int) $user->ID : 0; + } + + /** + * While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies. + * + * Core can set cookies there, e.g. when an application password is used to change the account + * password via REST, or when another plugin's login handler runs inside xmlrpc.php. + * + * @param bool $send Whether to send the cookies. + * @param int $expire Expiry (unused). + * @param int $expiration Expiration (unused). + * @param int|null $user_id User ID (0 when cookies are cleared; not passed before WordPress 6.2). + * @return bool + */ + public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = null ) { + // Before WordPress 6.2 the filter gets no user ID: block in API contexts anyway (also blocks + // clearing cookies there, which is harmless). + if ( ! $send || 0 === $user_id || ! $this->settings->button_only() || $this->fallback_active() ) { + return $send; + } + if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { + return false; + } + return $send; + } + /** * Best-effort client IP for rate limiting. * @@ -190,7 +308,10 @@ class M365_Login_Auth { */ $header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' ); if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) { - $candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] ); + // Proxies append to the list: the right-most entry was written by the trusted proxy, + // entries further left are supplied by the client. + $hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ); + $candidate = trim( (string) end( $hops ) ); if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) { $ip = $candidate; } @@ -247,6 +368,22 @@ class M365_Login_Auth { return $this->authority() . '/discovery/v2.0/keys'; } + /** + * URL that links the signed-in user's WordPress account to a Microsoft account. + * + * @return string + */ + public function link_url() { + return add_query_arg( + array( + 'action' => self::ACTION_START, + 'm365_link' => '1', + '_wpnonce' => wp_create_nonce( self::LINK_NONCE ), + ), + wp_login_url() + ); + } + /** * URL that starts the Microsoft login. * @@ -254,9 +391,10 @@ class M365_Login_Auth { * @return string */ public function start_url( $redirect_to = '' ) { - $args = array( 'action' => self::ACTION_START ); + $args = array( 'action' => self::ACTION_START ); + $redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : ''; if ( '' !== $redirect_to ) { - $args['redirect_to'] = $redirect_to; + $args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values. } return add_query_arg( $args, wp_login_url() ); } @@ -276,7 +414,7 @@ class M365_Login_Auth { // Cap the number of pending login attempts one client can create (state records are stored server-side). $throttle_key = 'm365_login_start_' . md5( $this->client_ip() ); $starts = (int) get_transient( $throttle_key ); - if ( $starts >= 30 ) { + if ( $starts >= 300 ) { $this->fail( 'too_many_attempts' ); } set_transient( $throttle_key, $starts + 1, self::STATE_TTL ); @@ -284,6 +422,18 @@ class M365_Login_Auth { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use. $redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : ''; + // "Link my Microsoft account" from the profile: the signed-in user proves ownership of the + // WordPress account, the Microsoft sign-in proves ownership of the Microsoft account. + $link_user = 0; + if ( isset( $_GET['m365_link'] ) ) { + $link_nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + if ( ! is_user_logged_in() || ! wp_verify_nonce( $link_nonce, self::LINK_NONCE ) ) { + $this->fail( 'invalid_state' ); + } + $link_user = get_current_user_id(); + $redirect_to = admin_url( 'profile.php' ); + } + $state = M365_Login_JWT::b64url_encode( random_bytes( 32 ) ); $nonce = M365_Login_JWT::b64url_encode( random_bytes( 32 ) ); $code_verifier = M365_Login_JWT::b64url_encode( random_bytes( 64 ) ); @@ -299,6 +449,7 @@ class M365_Login_Auth { 'verifier' => $code_verifier, 'cookie' => hash( 'sha256', $cookie_token ), 'redirect_to' => $redirect_to, + 'link_user' => $link_user, 'created' => time(), ), self::STATE_TTL @@ -421,16 +572,42 @@ class M365_Login_Auth { $this->fail( 'invalid_token' ); } - $email = $this->email_from_claims( $claims ); - if ( '' === $email ) { - $this->fail( 'no_email' ); + // In multi-tenant mode a guest or federated identity could present any user name. + if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) { + $this->fail( 'external_identity' ); } - if ( ! $this->domain_allowed( $email ) ) { + $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; + + if ( ! empty( $attempt['link_user'] ) ) { + $this->link_account( (int) $attempt['link_user'], $claims, $oid ); + } + + // E-mail and user principal name may differ: every usable address is tried (on the domain allow-list). + $candidates = $this->email_candidates( $claims ); + $email = $candidates ? $candidates[0] : ''; + $allowed = array_values( array_filter( $candidates, array( $this, 'domain_allowed' ) ) ); + if ( $candidates && ! $allowed ) { $this->fail( 'domain_not_allowed' ); } - $user = get_user_by( 'email', $email ); + // 1. An account already bound to this Microsoft identity, 2. an account the administrator + // assigned this user principal name to, 3. the e-mail address, then the user principal name. + $user = $this->find_bound_user( $oid ); + if ( ! $user ) { + $user = $this->find_assigned_user( $claims ); + } + if ( ! $user ) { + if ( ! $allowed ) { + $this->fail( 'no_email' ); + } + foreach ( $allowed as $candidate ) { + $user = get_user_by( 'email', $candidate ); + if ( $user instanceof WP_User ) { + break; + } + } + } if ( ! $user instanceof WP_User ) { /** This action is documented in wp-includes/user.php */ do_action( 'wp_login_failed', $email, new WP_Error( 'm365_login_no_user', 'No WordPress user with this e-mail address.' ) ); @@ -441,7 +618,9 @@ class M365_Login_Auth { $this->fail( 'no_user' ); } - $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; + if ( M365_Login_Sync::disabled_info( $user->ID ) ) { + $this->fail( 'account_disabled' ); + } // Entra group restriction. $group_check = $this->check_groups( $claims, $oid ); @@ -450,17 +629,45 @@ class M365_Login_Auth { } // Bind the account to the immutable Microsoft object ID after first login. + $stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) ); if ( $this->settings->get( 'bind_oid' ) ) { if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) { $this->fail( 'invalid_token' ); } - $stored = (string) get_user_meta( $user->ID, self::META_OID, true ); if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) { $this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) ); $this->fail( 'oid_mismatch' ); } - if ( '' === $stored ) { - update_user_meta( $user->ID, self::META_OID, $oid ); + } + + // Privileged accounts are only safe through a verified binding (bind_oid on and the stored + // object ID matches – checked above). Otherwise only the user principal name of a member + // account may claim them: its domain is verified in the tenant, the e-mail attribute is not. + $bound = $this->settings->get( 'bind_oid' ) && '' !== $stored; + if ( ! $bound && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) { + $this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) ); + $this->fail( 'privileged_unlinked' ); + } + + if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) { + // One Microsoft identity, one WordPress account. + $taken = get_users( + array( + 'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'exclude' => array( $user->ID ), + 'fields' => 'ID', + 'number' => 1, + 'blog_id' => 0, + ) + ); + if ( ! empty( $taken ) ) { + $this->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) ); + $this->fail( 'oid_mismatch' ); + } + update_user_meta( $user->ID, self::META_OID, $oid ); + if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) { + update_user_meta( $user->ID, self::META_TID, strtolower( (string) $claims['tid'] ) ); } } @@ -638,6 +845,248 @@ class M365_Login_Auth { return $body; } + /** + * Whether the token comes from a guest or another external identity provider. + * + * Entra only adds the "idp" claim when the identity provider differs from the issuer. + * + * @param array $claims Verified claims. + * @return bool + */ + private function is_external_identity( $claims ) { + $idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : ''; + $iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : ''; + return '' !== $idp && $idp !== $iss; + } + + /** + * Whether a token may claim a privileged account that is not bound yet. + * + * @param array $claims Verified claims. + * @param WP_User $user Matched account. + * @return bool + */ + private function may_claim_privileged( $claims, $user ) { + $upn = $this->claimed_upn( $claims ); + if ( '' === $upn || $this->settings->is_multi_tenant() ) { + return false; + } + $assigned = strtolower( (string) get_user_meta( $user->ID, self::META_UPN, true ) ); + return hash_equals( strtolower( $user->user_email ), $upn ) || ( '' !== $assigned && hash_equals( $assigned, $upn ) ); + } + + /** + * User principal name of a member account from the token ('' for guests/external identities). + * + * @param array $claims Verified claims. + * @return string + */ + private function claimed_upn( $claims ) { + if ( $this->is_external_identity( $claims ) || empty( $claims['preferred_username'] ) || ! is_string( $claims['preferred_username'] ) ) { + return ''; + } + return strtolower( trim( $claims['preferred_username'] ) ); + } + + /** + * Account bound to a Microsoft object ID (network-wide), if any. + * + * @param string $oid Object ID. + * @return WP_User|null + */ + private function find_bound_user( $oid ) { + if ( ! $this->settings->get( 'bind_oid' ) || ! M365_Login_Settings::is_guid( $oid ) ) { + return null; + } + $ids = get_users( + array( + 'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'fields' => 'ID', + 'number' => 2, + 'blog_id' => 0, + ) + ); + if ( 1 !== count( $ids ) ) { + return null; // None, or ambiguous (bound twice by an older version): fall back to the other rules. + } + $user = get_userdata( (int) $ids[0] ); + return $user ? $user : null; + } + + /** + * Account whose administrator-assigned Microsoft account matches the token's user principal name. + * + * @param array $claims Verified claims. + * @return WP_User|null + */ + private function find_assigned_user( $claims ) { + $upn = $this->claimed_upn( $claims ); + if ( '' === $upn || $this->settings->is_multi_tenant() ) { + return null; + } + $ids = get_users( + array( + 'meta_key' => self::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'fields' => 'ID', + 'number' => 2, + 'blog_id' => 0, + ) + ); + if ( 1 !== count( $ids ) ) { + return null; + } + $user = get_userdata( (int) $ids[0] ); + return $user ? $user : null; + } + + /** + * Binds the Microsoft identity to the signed-in WordPress user who started "Link my Microsoft account". + * + * @param int $user_id User who started the link. + * @param array $claims Verified claims. + * @param string $oid Object ID. + */ + private function link_account( $user_id, $claims, $oid ) { + // The browser must still be signed in as the user who started the link. + if ( ! $user_id || get_current_user_id() !== $user_id ) { + $this->fail_link( 'link_session' ); + } + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + $this->fail_link( 'invalid_token' ); + } + if ( M365_Login_Sync::disabled_info( $user_id ) ) { + $this->fail_link( 'account_disabled' ); + } + $stored = strtolower( (string) get_user_meta( $user_id, self::META_OID, true ) ); + if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) { + $this->fail_link( 'link_other' ); // Unlinking is an administrator decision. + } + $taken = get_users( + array( + 'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'exclude' => array( $user_id ), + 'fields' => 'ID', + 'number' => 1, + 'blog_id' => 0, + ) + ); + if ( ! empty( $taken ) ) { + $this->fail_link( 'oid_mismatch' ); + } + + update_user_meta( $user_id, self::META_OID, $oid ); + if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) { + update_user_meta( $user_id, self::META_TID, strtolower( (string) $claims['tid'] ) ); + } + $this->log( sprintf( 'User #%d linked a Microsoft account from the profile.', $user_id ) ); + + /** + * Fires after a user linked a Microsoft account from the profile screen. + * + * @param int $user_id User ID. + * @param array $claims Verified claims. + */ + do_action( 'm365_login_account_linked', $user_id, $claims ); + + wp_safe_redirect( add_query_arg( 'm365_linked', '1', admin_url( 'profile.php' ) ) ); + exit; + } + + /** + * Ends a failed profile link with a message on the profile screen. + * + * @param string $code Error code. + */ + private function fail_link( $code ) { + $this->clear_state_cookie(); + nocache_headers(); + wp_safe_redirect( add_query_arg( 'm365_link_error', rawurlencode( $code ), admin_url( 'profile.php' ) ) ); + exit; + } + + /** + * Translated message for a login/link error code. + * + * @param string $code Code. + * @return string + */ + public function error_message( $code ) { + $messages = $this->error_messages(); + return isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error']; + } + + /** + * 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, false ); + 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. * @@ -648,15 +1097,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; } @@ -669,7 +1117,7 @@ class M365_Login_Auth { return 'invalid_token'; } - $matches = $this->graph->check_member_groups( $oid, $allowed ); + $matches = $this->graph->check_member_groups( $oid, $allowed, false ); if ( is_wp_error( $matches ) ) { $this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() ); return 'group_check_failed'; @@ -682,12 +1130,12 @@ class M365_Login_Auth { } /** - * Extracts the e-mail address used for matching. + * Addresses used for matching, in order (e-mail claim, then user principal name). * * @param array $claims Verified claims. - * @return string Lowercase e-mail or empty string. + * @return string[] Lowercase addresses. */ - private function email_from_claims( $claims ) { + private function email_candidates( $claims ) { $candidates = array(); $email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : ''; $upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : ''; @@ -711,19 +1159,23 @@ class M365_Login_Auth { } } + $out = array(); foreach ( $candidates as $candidate ) { $candidate = strtolower( trim( $candidate ) ); if ( is_email( $candidate ) ) { /** - * Filters the e-mail address used to look up the WordPress user. + * Filters an e-mail address used to look up the WordPress user. * * @param string $email E-mail from the token. * @param array $claims Verified claims. */ - return (string) apply_filters( 'm365_login_match_email', $candidate, $claims ); + $candidate = strtolower( (string) apply_filters( 'm365_login_match_email', $candidate, $claims ) ); + if ( is_email( $candidate ) && ! in_array( $candidate, $out, true ) ) { + $out[] = $candidate; + } } } - return ''; + return $out; } /** @@ -792,6 +1244,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. * @@ -816,6 +1279,13 @@ class M365_Login_Auth { // phpcs:enable WordPress.Security.NonceVerification.Recommended $out = array(); + if ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) { + $out[] = array( + 'type' => 'error', + 'code' => 'unavailable', + 'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ), + ); + } if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) { $out[] = array( 'type' => 'message', @@ -841,22 +1311,29 @@ class M365_Login_Auth { */ private function error_messages() { return array( - 'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ), - 'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ), - 'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ), - 'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ), - 'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ), - 'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ), - 'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ), - 'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ), - 'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ), - 'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ), - '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' ), - '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' ), + 'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ), + 'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ), + 'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ), + 'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ), + 'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ), + 'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ), + 'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ), + 'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ), + 'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ), + 'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ), + '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' ), + 'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ), + 'privileged_unlinked' => __( 'For security reasons this administrator account is not linked automatically. Sign in once with your password and click "Link Microsoft account" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile.', 'm365-login' ), + 'link_session' => __( 'The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again.', 'm365-login' ), + 'link_other' => __( 'Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile.', 'm365-login' ), + 'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ), + 'password_reset_disabled' => __( 'Passwords are not used on this site. Please sign in with the Microsoft button.', 'm365-login' ), ); } diff --git a/includes/class-m365-login-button.php b/includes/class-m365-login-button.php index ecae6fb..db1d8f5 100644 --- a/includes/class-m365-login-button.php +++ b/includes/class-m365-login-button.php @@ -167,14 +167,7 @@ class M365_Login_Button { * @return bool */ private function should_render() { - if ( ! $this->settings->is_configured() ) { - return false; - } - // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check. - $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login'; - $interim = ! empty( $_REQUEST['interim-login'] ); - // phpcs:enable WordPress.Security.NonceVerification.Recommended - if ( $interim || ! in_array( $action, array( '', 'login' ), true ) ) { + if ( ! $this->settings->is_configured() || ! $this->is_login_action() ) { return false; } /** @@ -185,13 +178,31 @@ class M365_Login_Button { return (bool) apply_filters( 'm365_login_show_button', true ); } + /** + * Whether the current wp-login.php request shows the sign-in form (not interim login or another action). + * + * @return bool + */ + private function is_login_action() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check. + $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login'; + $interim = ! empty( $_REQUEST['interim-login'] ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + return ! $interim && in_array( $action, array( '', 'login' ), true ); + } + /** * Whether the password form is hidden for this request. * + * Also while the connection is broken: password sign-in is refused anyway, the fields would only mislead. + * * @return bool */ private function form_hidden() { - return $this->should_render() && $this->settings->button_only() && ! M365_Login::instance()->auth->fallback_active(); + if ( ! $this->settings->button_only() || M365_Login::instance()->auth->fallback_active() || ! $this->is_login_action() ) { + return false; + } + return $this->should_render() || ! $this->settings->is_configured(); } /** @@ -201,6 +212,9 @@ class M365_Login_Button { * @return string[] */ public function body_class( $classes ) { + if ( $this->should_render() ) { + $classes[] = 'm365-login-page'; + } if ( $this->form_hidden() ) { $classes[] = 'm365-button-only'; } @@ -211,7 +225,8 @@ class M365_Login_Button { * Enqueues login styles and the small positioning script. */ public function enqueue() { - if ( ! $this->should_render() ) { + // Also when only the form is hidden (e.g. broken connection): the stylesheet hides the password form and links. + if ( ! $this->should_render() && ! $this->form_hidden() ) { return; } wp_enqueue_style( 'm365-login', M365_LOGIN_URL . 'assets/css/login.css', array(), M365_LOGIN_VERSION ); diff --git a/includes/class-m365-login-certificate.php b/includes/class-m365-login-certificate.php index fd0653b..d9dce77 100644 --- a/includes/class-m365-login-certificate.php +++ b/includes/class-m365-login-certificate.php @@ -97,6 +97,9 @@ final class M365_Login_Certificate { return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) ); } + if ( false !== strpos( $cert_pem, 'PRIVATE KEY' ) ) { + return new WP_Error( 'cert_has_key', __( 'The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there.', 'm365-login' ) ); + } $cert = openssl_x509_read( $cert_pem ); if ( false === $cert ) { return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) ); @@ -110,12 +113,36 @@ final class M365_Login_Certificate { return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) ); } + // Store exactly one clean certificate (drops chains, bundles and surrounding text). + $clean = ''; + if ( ! openssl_x509_export( $cert, $clean ) || '' === $clean ) { + return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) ); + } + return array( 'private_key' => $key_pem, - 'certificate' => $cert_pem, + 'certificate' => self::normalise_pem( $clean ), ); } + /** + * Exactly one certificate re-exported from a PEM text, or '' if none can be read. + * + * @param string $pem PEM text (may contain other blocks). + * @return string + */ + public static function clean_pem( $pem ) { + if ( ! preg_match( '/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/s', (string) $pem, $m ) ) { + return ''; + } + $cert = openssl_x509_read( $m[0] ); + $clean = ''; + if ( false === $cert || ! openssl_x509_export( $cert, $clean ) ) { + return ''; + } + return self::normalise_pem( $clean ); + } + /** * Normalises line endings and trims a PEM block. * diff --git a/includes/class-m365-login-crypto.php b/includes/class-m365-login-crypto.php index e29a2dc..8765657 100644 --- a/includes/class-m365-login-crypto.php +++ b/includes/class-m365-login-crypto.php @@ -11,7 +11,9 @@ defined( 'ABSPATH' ) || exit; * AES-256-GCM helper keyed from the WordPress salts. * * The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the - * stored client secret is useless without access to wp-config.php. + * stored client secret is useless without access to wp-config.php – provided the + * salts are defined there. Without them wp_salt() keeps generated salts in the + * database next to the ciphertext; the settings screen warns about that. */ final class M365_Login_Crypto { diff --git a/includes/class-m365-login-graph.php b/includes/class-m365-login-graph.php index ba1f6e5..f5b7a75 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,68 @@ 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. + * @param bool $retry Retry on 429/503/504. + * @param int $max_bytes Maximum response size (0 = unlimited). + * @return array|WP_Error Response array from wp_remote_request(). + */ + private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true, $max_bytes = 0 ) { + $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 ); + } + if ( $max_bytes > 0 ) { + $args['limit_response_size'] = $max_bytes; // Stop downloading oversized bodies early. + } + + $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 ( $retry && $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 +167,11 @@ 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. + * @param bool $retry Retry on 429/503/504. + * @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 ); + private function request( $method, $path, $json = null, $headers = array(), $retry = true ) { + $response = $this->raw_request( $method, $path, $json, $headers, $retry ); if ( is_wp_error( $response ) ) { return $response; } @@ -137,18 +179,215 @@ 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 ) ) ); + } + + /** + * 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 string|null|WP_Error Binary image data, null when the user has no photo. + */ + 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/$value', $base . '/photo/$value' ) as $path ) { + $response = $this->raw_request( 'GET', $path, null, array( 'Accept' => 'image/*' ), true, 2 * MB_IN_BYTES + 1 ); + 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 ) ); + } + } + return null; + } + /** * Searches groups by display name. * @@ -157,10 +396,10 @@ class M365_Login_Graph { */ public function search_groups( $query ) { $query = trim( (string) $query ); - $select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName'; + $select = '$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility&$top=25&$orderby=displayName'; if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) { - $path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled'; + $path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility'; $item = $this->request( 'GET', $path ); if ( is_wp_error( $item ) ) { return $item; @@ -204,6 +443,10 @@ class M365_Login_Graph { } elseif ( ! empty( $item['mailEnabled'] ) ) { $type = __( 'Microsoft 365 group', 'm365-login' ); } + $unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true ); + if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) { + $type = __( 'Public Microsoft 365 group – anyone in the organisation can join', 'm365-login' ); + } return array( 'id' => strtolower( (string) $item['id'] ), 'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'], @@ -217,9 +460,10 @@ class M365_Login_Graph { * * @param string $user_oid User object ID. * @param string[] $group_ids Group object IDs (any count; chunked by 20). + * @param bool $retry Retry on throttling (off in the interactive sign-in). * @return string[]|WP_Error Matching group IDs. */ - public function check_member_groups( $user_oid, $group_ids ) { + public function check_member_groups( $user_oid, $group_ids, $retry = true ) { if ( ! M365_Login_Settings::is_guid( $user_oid ) ) { return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); } @@ -229,7 +473,9 @@ class M365_Login_Graph { $result = $this->request( 'POST', '/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups', - array( 'groupIds' => $chunk ) + array( 'groupIds' => $chunk ), + array(), + $retry ); if ( is_wp_error( $result ) ) { return $result; diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index 8fcbfaf..a0640cb 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. * @@ -27,36 +34,51 @@ 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. + 'denied_groups' => array(), // id => display name; members may never sign in. + '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. ); } @@ -73,6 +95,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. * @@ -199,11 +228,26 @@ 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; } + /** + * Replaces the stored certificate by a clean re-export (drops key bundles or chains saved by older versions). + */ + public function normalise_stored_certificate() { + $pem = $this->certificate_pem(); + if ( '' === $pem ) { + return; + } + $clean = M365_Login_Certificate::clean_pem( $pem ); + if ( '' !== $clean && $clean !== $pem ) { + $all = $this->all(); + $all['cert_certificate'] = $clean; + $this->write( $all ); + } + } + /** * Removes the stored certificate and key. */ @@ -211,8 +255,22 @@ class M365_Login_Settings { $all = $this->all(); $all['cert_private_key'] = ''; $all['cert_certificate'] = ''; - update_option( M365_LOGIN_OPTION, $all ); - $this->cache = null; + $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, false ); // Holds encrypted secrets: never autoloaded. + $this->raw_write = false; + $this->cache = null; } /** @@ -289,7 +347,56 @@ 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() ) ); + } + + /** + * 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. + * + * @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 ) { @@ -311,7 +418,10 @@ class M365_Login_Settings { if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) { return false; } - return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key(); + // Deliberately not tied to is_configured(): an expired certificate or rotated salts must not + // silently re-enable password sign-in. The fallback link and the constant stay available. + return '' !== (string) $this->get( 'tenant_id' ) && '' !== (string) $this->get( 'client_id' ) + && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key(); } /** @@ -379,6 +489,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(); @@ -402,7 +516,7 @@ class M365_Login_Settings { $out['client_id'] = strtolower( $client_id ); // Client secret: only replaced when a new value was entered. - $secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : ''; + $secret_input = self::scalar( $input, 'client_secret' ); $secret_input = trim( $secret_input ); if ( ! empty( $input['client_secret_clear'] ) ) { $out['client_secret'] = ''; @@ -425,8 +539,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 = trim( self::scalar( $input, 'cert_key_pem' ) ); + $pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) ); if ( ! empty( $input['cert_remove'] ) ) { $out['cert_private_key'] = ''; $out['cert_certificate'] = ''; @@ -461,26 +575,13 @@ 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() ); + $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; @@ -491,7 +592,7 @@ class M365_Login_Settings { $out['fallback_key'] = $key; // Custom login page (must be on this site). - $custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : ''; + $custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) ); if ( '' !== $custom ) { if ( 0 === strpos( $custom, '/' ) ) { $custom = home_url( $custom ); @@ -508,13 +609,13 @@ class M365_Login_Settings { $text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : ''; $out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 ); - $icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : ''; + $icon = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) ); $out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : ''; $out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1; foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) { - $color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : ''; + $color = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) ); $out[ $color_key ] = $color ? $color : $defaults[ $color_key ]; } @@ -527,11 +628,128 @@ 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'] ) && is_scalar( $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 ) { + if ( ! is_scalar( $attribute ) ) { + continue; + } + $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; + } + + /** + * Unslashed string value of a posted field ('' for missing or non-scalar values such as arrays). + * + * @param array $input Raw input. + * @param string $key Field. + * @return string + */ + private static function scalar( $input, $key ) { + return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) wp_unslash( $input[ $key ] ) : ''; + } + + /** + * 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 = is_scalar( $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..402fb07 --- /dev/null +++ b/includes/class-m365-login-sync.php @@ -0,0 +1,2381 @@ + user IDs). + * + * @var array + */ + private $duplicates = array(); + + /** + * Role changes that remove administrative rights, applied after the safety check. + * + * @var array[] + */ + private $demotions = array(); + + /** + * Token of the run lock held by this process ('' when none). + * + * @var string + */ + private $lock_token = ''; + + /** + * Constructor. + * + * @param M365_Login_Settings $settings Settings. + * @param M365_Login_Graph $graph Graph client. + */ + public function __construct( M365_Login_Settings $settings, M365_Login_Graph $graph ) { + $this->settings = $settings; + $this->graph = $graph; + + add_action( self::CRON_HOOK, array( $this, 'run_scheduled' ) ); + add_action( 'init', array( $this, 'ensure_schedule' ) ); + add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); + add_action( 'add_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); + + // Deactivated accounts: no password, application password, cookie or Microsoft sign-in. + add_filter( 'authenticate', array( $this, 'block_disabled_login' ), 100, 1 ); + add_filter( 'determine_current_user', array( $this, 'drop_disabled_session' ), 100 ); + + add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 ); + add_action( 'delete_user', array( $this, 'delete_photo' ) ); + add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); + add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); + + 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( 'personal_options_update', array( $this, 'save_profile' ) ); + add_action( 'edit_user_profile_update', array( $this, 'save_profile' ) ); + 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(), + ); + + $lock = $this->acquire_lock(); + if ( '' === $lock ) { + $this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) ); + return $this->finish( 'locked', false ); + } + $this->lock_token = $lock; + $this->demotions = array(); + + // A fatal error (memory, time limit) skips "finally": record the failure and free the lock anyway. + register_shutdown_function( array( $this, 'shutdown' ) ); + + 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 password changed" mails for the random passwords of deactivated accounts. + // E-mail change notifications stay on: the previous address is told about the change. + add_filter( 'send_password_change_email', '__return_false', 99 ); + + try { + $status = $this->sync(); + } finally { + remove_filter( 'send_password_change_email', '__return_false', 99 ); + $this->release_lock( $lock ); + $this->lock_token = ''; + } + + return $this->finish( $status, true ); + } + + /** + * Takes the run lock atomically (add_option fails when the row exists). + * + * @return string Lock token, or '' when another run holds the lock. + */ + private function acquire_lock() { + global $wpdb; + $token = wp_generate_password( 20, false ); + $value = $token . '|' . time(); + + // A single INSERT is atomic; add_option() would check first and then insert. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. + $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, $value ) ); + if ( 1 === (int) $inserted ) { + $this->flush_lock_cache(); + return $token; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. + $held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) ); + $parts = explode( '|', $held ); + if ( isset( $parts[1] ) && time() - (int) $parts[1] < self::LOCK_TTL ) { + return ''; + } + // Stale lock of a crashed run: take it over only if nobody else did in the meantime. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. + $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $value, self::LOCK, $held ) ); + $this->flush_lock_cache(); + return 1 === (int) $updated ? $token : ''; + } + + /** + * Renews the timestamp of the run lock (long runs must not look stale). + */ + private function refresh_lock() { + global $wpdb; + if ( '' === $this->lock_token ) { + return; + } + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value LIKE %s", $this->lock_token . '|' . time(), self::LOCK, $wpdb->esc_like( $this->lock_token . '|' ) . '%' ) ); + $this->flush_lock_cache(); + } + + /** + * Shutdown handler: a run that did not finish (fatal error) is reported as failed and its lock released. + */ + public function shutdown() { + if ( '' === $this->lock_token ) { + return; + } + $error = error_get_last(); + $this->log( 'error', __( 'The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use "wp m365-login sync".', 'm365-login' ) ); + if ( $error && ! empty( $error['message'] ) ) { + $this->log( 'error', wp_strip_all_tags( (string) $error['message'] ) ); + } + $this->report['status'] = 'failed'; + $this->report['finished'] = time(); + update_option( self::REPORT_OPTION, $this->report, false ); + $this->release_lock( $this->lock_token ); + $this->lock_token = ''; + } + + /** + * Drops cached copies of the lock row. + */ + private function flush_lock_cache() { + wp_cache_delete( self::LOCK, 'options' ); + wp_cache_delete( 'notoptions', 'options' ); + } + + /** + * Releases the run lock if this run still holds it. + * + * @param string $token Lock token. + */ + private function release_lock( $token ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock row, must bypass the cache. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value LIKE %s", self::LOCK, $wpdb->esc_like( $token . '|' ) . '%' ) ); + $this->flush_lock_cache(); + } + + /** + * 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(); + if ( empty( $people ) && ! empty( $linked ) ) { + $this->log( 'error', __( 'Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups.', 'm365-login' ) ); + return 'aborted'; + } + + $linked_before = count( $linked ); // Basis of the safety stop: accounts created in this run must not dilute it. + $seen = array(); + $pending = array(); // Deactivations/deletions, applied after the safety check. + $photo_of = array(); // oid => user ID whose photo is kept in sync. + $photo_on = array_key_exists( 'photo', $this->selected_attributes() ); + $done = 0; + + foreach ( $people as $person ) { + $oid = strtolower( (string) $person['id'] ); + $seen[ $oid ] = true; + + $result = $this->sync_person( $person, $linked, $memberships ); + if ( is_array( $result ) ) { + $pending = array_merge( $pending, $result ); + } elseif ( $result instanceof WP_User ) { + $photo_of[ $oid ] = $result->ID; + } + + // Large directories: keep memory flat and the run lock fresh. + ++$done; + if ( 0 === $done % 250 ) { + if ( function_exists( 'wp_cache_flush_runtime' ) ) { + wp_cache_flush_runtime(); + } + $this->refresh_lock(); + } + } + + // Role removals that would take administrative rights away are applied only after a safety check. + if ( ! $this->apply_demotions() ) { + return 'aborted'; + } + + // 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 – only those that belong to + // this tenant: an object ID from another tenant is "not found" here, not deleted. + $tenant = strtolower( $this->settings->tenant() ); + $foreign = 0; + foreach ( $linked as $oid => $user_id ) { + if ( isset( $seen[ $oid ] ) ) { + continue; + } + $user_tenant = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_TID, true ) ); + if ( '' !== $user_tenant && $user_tenant !== $tenant ) { + ++$foreign; + continue; + } + // Linked before tenants were recorded: a 404 cannot tell "deleted" from "other tenant". + $action = $this->classify_missing( $oid, $user_id, '' === $user_tenant ); + if ( 'unknown' === $action ) { + ++$foreign; + continue; + } + if ( is_wp_error( $action ) ) { + $this->log( 'error', $this->graph_error_text( $action ) ); + return 'failed'; + } + if ( null !== $action ) { + $pending = array_merge( $pending, $action ); + } + } + + if ( $foreign ) { + /* translators: %d: number of accounts */ + $this->log( 'warning', sprintf( _n( '%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed.', '%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed.', $foreign, 'm365-login' ), $foreign ) ); + } + + // 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( $linked_before * 0.2 ) ), $linked_before ); + 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' => 'ID', + 'number' => -1, + ) + ); + $out = array(); + $this->duplicates = array(); + foreach ( $users as $user_id ) { + $user_id = (int) $user_id; + $oid = strtolower( (string) get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) ); + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + continue; + } + if ( isset( $out[ $oid ] ) ) { + $this->duplicates[ $oid ][] = $user_id; + } else { + $out[ $oid ] = $user_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; + + // A linked person disabled in Microsoft 365 is handled before anything else, so a + // removed or changed e-mail address during offboarding cannot keep the account alive. + if ( $user && ! $enabled ) { + return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ), $oid ); + } + + 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: the account an administrator assigned this user principal name to, + // otherwise an existing account with the same e-mail address. + if ( ! $user ) { + $by_mail = $this->assigned_user( $person ); + if ( ! $by_mail ) { + $by_mail = get_user_by( 'email', $email ); + } + // The WordPress account may use the user principal name instead of the mail address. + $upn = self::member_upn( $person ); + if ( ! $by_mail && '' !== $upn && $upn !== $email && is_email( $upn ) && $this->domain_allowed( $upn ) ) { + $by_mail = get_user_by( 'email', $upn ); + } + 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; + } + if ( ! $this->may_link( $by_mail, $person, $email ) ) { + /* translators: %s: e-mail address */ + $this->skip( sprintf( __( '%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. 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 ); + update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); + } + $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' ), $oid ); + } + + 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 ) && ! self::disabled_info( $user->ID ) ) { + $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() ); + update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); + } + + 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, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); + 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 ( self::is_privileged( $user ) ) { + /* translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 */ + $this->log( 'warning', sprintf( __( '%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended.', 'm365-login' ), $user->user_email, $email ) ); + } elseif ( $owner && $owner->ID !== $user->ID ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: e-mail address is used by another WordPress account and was not changed.', 'm365-login' ), $email ) ); + } else { + $fields['user_email'] = $email; + $changes[] = __( 'e-mail', 'm365-login' ); + } + } + + $labels = self::attributes(); + foreach ( $this->selected_attributes() as $key => $target ) { + if ( 'avatar' === $target ) { + continue; + } + $value = $this->attribute_value( $person, $key, $target ); + if ( null === $value ) { + continue; + } + + if ( in_array( $target, array( 'display_name', 'first_name', 'last_name', 'locale' ), true ) ) { + if ( '' === $value && 'display_name' === $target ) { + continue; + } + if ( (string) $user->$target !== $value ) { + $fields[ $target ] = $value; + $changes[] = $labels[ $key ]['label']; + } + continue; + } + + $meta_key = sanitize_key( $target ); + if ( (string) get_user_meta( $user->ID, $meta_key, true ) !== $value ) { + if ( ! $this->dry ) { + if ( '' === $value ) { + delete_user_meta( $user->ID, $meta_key ); + } else { + update_user_meta( $user->ID, $meta_key, $value ); + } + } + $changes[] = $labels[ $key ]['label']; + } + } + + // Fields that are no longer selected are removed from the profile (only the plugin's own m365_* keys). + $selected = $this->selected_attributes(); + foreach ( $labels as $key => $attribute ) { + $target = isset( $attribute['target'] ) ? (string) $attribute['target'] : ''; + if ( isset( $selected[ $key ] ) || 0 !== strpos( $target, 'm365_' ) ) { + continue; + } + if ( '' !== (string) get_user_meta( $user->ID, $target, true ) ) { + if ( ! $this->dry ) { + delete_user_meta( $user->ID, $target ); + } + /* translators: %s: profile field */ + $changes[] = sprintf( __( '%s removed', 'm365-login' ), $attribute['label'] ); + } + } + + if ( $fields && ! $this->dry ) { + $fields['ID'] = $user->ID; + $result = wp_update_user( $fields ); + if ( is_wp_error( $result ) ) { + $this->count( 'errors' ); + /* translators: 1: e-mail address, 2: error message */ + $this->log( 'error', sprintf( __( '%1$s: profile could not be updated: %2$s', 'm365-login' ), $email, $result->get_error_message() ) ); + return array(); + } + clean_user_cache( $user->ID ); + } + + return $changes; + } + + /** + * Normalised value of one attribute, or null to leave the field alone. + * + * @param array $person Graph user. + * @param string $key Graph property. + * @param string $target Target field. + * @return string|null + */ + private function attribute_value( $person, $key, $target ) { + if ( ! array_key_exists( $key, $person ) ) { + return null; + } + $raw = $person[ $key ]; + if ( is_array( $raw ) ) { + $raw = isset( $raw[0] ) && is_scalar( $raw[0] ) ? $raw[0] : ''; + } + $value = null === $raw ? '' : sanitize_text_field( (string) $raw ); + + if ( 'locale' === $target ) { + $locale = str_replace( '-', '_', $value ); + if ( '' === $locale ) { + return null; + } + if ( 'en_US' !== $locale && ! in_array( $locale, get_available_languages(), true ) ) { + return null; // Language pack not installed: keep the site default. + } + return $locale; + } + return mb_substr( $value, 0, 250 ); + } + + /** + * Roles a person should have, in order (the first one is the primary role). + * + * @param string $oid Object ID. + * @param array $memberships Role group memberships. + * @return string[] + */ + private function desired_roles( $oid, $memberships ) { + $default = (string) $this->settings->get( 'sync_default_role' ); + $mapped = array(); + foreach ( $this->settings->sync_role_map() as $group_id => $row ) { + if ( isset( $memberships[ $group_id ][ $oid ] ) && get_role( $row['role'] ) ) { + $mapped[] = $row['role']; + } + } + $mapped = array_values( array_unique( $mapped ) ); + + if ( 'replace' === $this->settings->get( 'sync_role_mode' ) ) { + $roles = $mapped ? array( $mapped[0] ) : array( $default ); + } else { + $roles = array_values( array_unique( array_merge( array( $default ), $mapped ) ) ); + } + + /** + * Filters the WordPress roles the sync assigns to a Microsoft 365 user. + * + * @param string[] $roles Role slugs, the first is the primary role. + * @param string $oid Microsoft object ID. + */ + $roles = array_values( array_filter( (array) apply_filters( 'm365_login_sync_roles', $roles, $oid ), 'get_role' ) ); + return $roles ? $roles : array( $default ); + } + + /** + * Whether the sync may change the roles of this account. + * + * @param WP_User $user User. + * @return bool + */ + private function manages_roles( $user ) { + if ( $this->is_protected( $user ) ) { + return false; + } + return (bool) get_user_meta( $user->ID, self::META_SYNCED, true ) || (bool) $this->settings->get( 'sync_manage_existing' ); + } + + /** + * Applies the desired roles. + * + * @param WP_User $user User. + * @param string[] $roles Desired roles. + * @return string[] Changes for the log. + */ + private function update_roles( $user, $roles ) { + $current = array_values( $user->roles ); + $same = count( $current ) === count( $roles ) && ! array_diff( $current, $roles ) && reset( $current ) === $roles[0]; + if ( $same ) { + return array(); + } + if ( self::roles_privileged( $current ) && ! self::roles_privileged( $roles ) ) { + // Losing administrative rights: collected and applied after the safety check in apply_demotions(). + $this->demotions[] = array( + 'user' => $user, + 'roles' => $roles, + ); + } elseif ( ! $this->dry ) { + self::set_roles( $user, $roles ); + } + /* translators: %s: role names */ + return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) ); + } + + /** + * Replaces the roles of a user (first = primary role). + * + * @param WP_User $user User. + * @param string[] $roles Roles. + */ + private static function set_roles( $user, $roles ) { + $user->set_role( $roles[0] ); + foreach ( array_slice( $roles, 1 ) as $role ) { + $user->add_role( $role ); + } + } + + /** + * Whether any of the roles grants a privileged capability. + * + * @param string[] $roles Role slugs. + * @return bool + */ + private static function roles_privileged( $roles ) { + foreach ( (array) $roles as $slug ) { + $role = is_string( $slug ) ? get_role( $slug ) : null; + if ( $role && array_intersect( self::privileged_caps(), array_keys( array_filter( $role->capabilities ) ) ) ) { + return true; + } + } + return false; + } + + /** + * Applies collected removals of administrative roles unless they would take away too many + * administrators at once (e.g. a mapped group was emptied by mistake). + * + * @return bool False when the run was stopped. + */ + private function apply_demotions() { + if ( empty( $this->demotions ) ) { + return true; + } + $privileged_roles = array(); + foreach ( wp_roles()->roles as $slug => $definition ) { + if ( self::roles_privileged( array( $slug ) ) ) { + $privileged_roles[] = $slug; + } + } + $admins = count( + get_users( + array( + 'role__in' => $privileged_roles, + 'fields' => 'ID', + 'number' => -1, + ) + ) + ); + + /** + * Maximum number of accounts that may lose administrative rights in one sync run. + * + * @param int $limit Limit (default: 20 % of the privileged accounts, at least 1; never all of them). + * @param int $admins Number of accounts with privileged roles on this site. + */ + $limit = (int) apply_filters( 'm365_login_sync_demotion_limit', max( 1, (int) floor( $admins * 0.2 ) ), $admins ); + if ( count( $this->demotions ) > $limit || count( $this->demotions ) >= $admins ) { + $this->log( + 'error', + sprintf( + /* translators: 1: number of accounts, 2: limit */ + __( 'Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit).', 'm365-login' ), + count( $this->demotions ), + $limit + ) + ); + return false; + } + if ( ! $this->dry ) { + foreach ( $this->demotions as $demotion ) { + self::set_roles( $demotion['user'], $demotion['roles'] ); + } + } + return true; + } + + /** + * 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. + * @param bool $legacy Linked without a recorded tenant. + * @return array[]|null|string|WP_Error Pending actions, null for none, 'unknown' when a legacy link is not found. + */ + private function classify_missing( $oid, $user_id, $legacy = false ) { + $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 ) ) { + if ( $legacy ) { + return 'unknown'; + } + return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ), $oid ); + } + return $person; + } + if ( $legacy && ! $this->dry ) { + update_user_meta( $user->ID, M365_Login_Auth::META_TID, strtolower( $this->settings->tenant() ) ); + } + 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' ), $oid ); + } + 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' ), $oid ); + } + 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. + * @param string $oid Object ID (further accounts bound to it get the same action). + * @return array[]|null Pending actions. + */ + private function action( $what, $user, $reason, $label, $oid = '' ) { + if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) { + return null; + } + $users = array( $user ); + if ( '' !== $oid && ! empty( $this->duplicates[ $oid ] ) ) { + foreach ( $this->duplicates[ $oid ] as $user_id ) { + $other = get_userdata( $user_id ); + if ( $other && $other->ID !== $user->ID ) { + $users[] = $other; + } + } + } + $out = array(); + foreach ( $users as $target ) { + if ( $this->is_protected( $target ) ) { + /* 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' ), $target->user_email, $label ) ); + continue; + } + $out[] = array( + 'what' => $what, + 'user' => $target, + 'reason' => $reason, + 'label' => $label, + ); + } + return $out ? $out : null; + } + + /** + * Whether an existing account may be linked to a directory user by e-mail address. + * + * Privileged accounts are only linked through the user principal name of a member + * account: its domain must be verified in the tenant, whereas the "mail" attribute can + * be set to any address by a user or Exchange administrator of the tenant. + * + * @param WP_User $user Existing account. + * @param array $person Graph user. + * @param string $email Address the account was found by. + * @return bool + */ + private function may_link( $user, $person, $email ) { + if ( ! self::is_privileged( $user ) ) { + return true; + } + $upn = self::member_upn( $person ); + if ( '' === $upn ) { + return false; + } + $assigned = strtolower( (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ) ); + return ( '' !== $assigned && $assigned === $upn ) || strtolower( $user->user_email ) === $upn; + } + + /** + * User principal name of a member (not a guest), lowercase, or ''. + * + * @param array $person Graph user. + * @return string + */ + private static function member_upn( $person ) { + $upn = isset( $person['userPrincipalName'] ) ? strtolower( trim( (string) $person['userPrincipalName'] ) ) : ''; + $guest = isset( $person['userType'] ) && 'Guest' === $person['userType']; + return $guest || false !== strpos( $upn, '#ext#' ) ? '' : $upn; + } + + /** + * Account an administrator assigned this person's user principal name to. + * + * @param array $person Graph user. + * @return WP_User|null + */ + private function assigned_user( $person ) { + $upn = self::member_upn( $person ); + if ( '' === $upn ) { + return null; + } + $ids = get_users( + array( + 'meta_key' => M365_Login_Auth::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'fields' => 'ID', + 'number' => 2, + 'blog_id' => 0, + ) + ); + if ( 1 !== count( $ids ) ) { + return null; + } + $user = get_userdata( (int) $ids[0] ); + return $user ? $user : null; + } + + /** + * Capabilities that make an account privileged (administrative or able to run code/HTML). + * + * @return string[] + */ + private static function privileged_caps() { + return array( 'manage_options', 'promote_users', 'edit_users', 'create_users', 'delete_users', 'unfiltered_html', 'activate_plugins', 'install_plugins', 'edit_plugins', 'edit_themes', 'switch_themes', 'update_core' ); + } + + /** + * Accounts with administrative capabilities (they get extra protection against linking by e-mail). + * + * @param WP_User $user User. + * @return bool + */ + public static function is_privileged( $user ) { + $privileged = is_super_admin( $user->ID ); + + // A deactivated account has no role, but gets its roles back on reactivation. + $disabled = self::disabled_info( $user->ID ); + if ( ! $privileged && $disabled && ! empty( $disabled['roles'] ) ) { + $privileged = self::roles_privileged( (array) $disabled['roles'] ); + } + if ( ! $privileged ) { + // On multisite the rights on every site of the user count, not only on the current one. + $sites = is_multisite() ? array_keys( get_blogs_of_user( $user->ID ) ) : array( 0 ); + foreach ( $sites as $site_id ) { + $check = $site_id ? new WP_User( $user->ID, '', $site_id ) : $user; + foreach ( self::privileged_caps() as $cap ) { + if ( $check->has_cap( $cap ) ) { + $privileged = true; + break 2; + } + } + } + } + + /** + * Filters whether an account counts as privileged (linked only via a matching user principal name). + * + * @param bool $privileged Whether the account is privileged. + * @param WP_User $user User. + */ + return (bool) apply_filters( 'm365_login_is_privileged_user', $privileged, $user ); + } + + /** + * Filters out actions that would not change anything (already deactivated). + * + * @param array $action Pending action. + * @return bool + */ + private function is_effective_action( $action ) { + return 'delete' === $action['what'] || ! self::disabled_info( $action['user']->ID ); + } + + /** + * Deactivates or deletes an account. + * + * @param array $action Pending action. + */ + private function apply_action( $action ) { + $user = $action['user']; + $reassign = (int) $this->settings->get( 'sync_reassign' ); + $what = $action['what']; + + if ( 'delete' === $what && ( ! $reassign || $reassign === $user->ID || ! get_userdata( $reassign ) ) ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted.', 'm365-login' ), $user->user_email ) ); + $what = 'disable'; + if ( self::disabled_info( $user->ID ) ) { + return; + } + } + + if ( 'delete' === $what ) { + /* translators: 1: e-mail address, 2: reason */ + $this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); + $this->count( 'deleted' ); + if ( ! $this->dry ) { + wp_delete_user( $user->ID, $reassign ); // The delete_user hook removes the photo. + } + return; + } + + /* translators: 1: e-mail address, 2: reason */ + $this->log( 'info', sprintf( __( '%1$s: account deactivated (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); + $this->count( 'disabled' ); + if ( ! $this->dry ) { + self::disable( $user->ID, 'sync', $action['reason'] ); + } + } + + /** + * Accounts the sync never deactivates, deletes or re-roles. + * + * Administrators that existed before the sync are protected; accounts the sync + * created (and may have promoted through a group mapping) are fully managed. + * + * @param WP_User $user User. + * @return bool + */ + private function is_protected( $user ) { + $protected = get_current_user_id() === $user->ID + || ( is_multisite() && is_super_admin( $user->ID ) ) + || ( ! get_user_meta( $user->ID, self::META_SYNCED, true ) && user_can( $user, 'manage_options' ) ); + + /** + * Filters whether the sync must leave an account alone (no role changes, deactivation or deletion). + * + * @param bool $protected Whether the account is protected. + * @param WP_User $user User. + */ + return (bool) apply_filters( 'm365_login_sync_protect_user', $protected, $user ); + } + + /** + * E-mail address of a directory user (mail, else a usable UPN). + * + * @param array $person Graph user. + * @return string Lowercase address or ''. + */ + private function email_of( $person ) { + $candidates = array(); + if ( ! empty( $person['mail'] ) ) { + $candidates[] = (string) $person['mail']; + } + if ( ! empty( $person['userPrincipalName'] ) && false === stripos( (string) $person['userPrincipalName'], '#ext#' ) ) { + $candidates[] = (string) $person['userPrincipalName']; + } + foreach ( $candidates as $candidate ) { + $candidate = strtolower( trim( $candidate ) ); + if ( is_email( $candidate ) ) { + /** + * Filters the e-mail address the sync uses for a Microsoft 365 user. + * + * @param string $email Address. + * @param array $person Graph user object. + */ + return strtolower( (string) apply_filters( 'm365_login_sync_email', $candidate, $person ) ); + } + } + return ''; + } + + /** + * Domain allow-list from the Security tab. + * + * @param string $email E-mail. + * @return bool + */ + private function domain_allowed( $email ) { + $allowed = $this->settings->allowed_domains(); + return empty( $allowed ) || in_array( strtolower( substr( strrchr( $email, '@' ), 1 ) ), $allowed, true ); + } + + /** + * Friendlier text for common Graph permission errors. + * + * @param WP_Error $error Error. + * @return string + */ + private function graph_error_text( $error ) { + $message = $error->get_error_message(); + if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) { + return __( 'Microsoft Graph refused the request. Grant the application permissions "User.Read.All" and "GroupMember.Read.All" with admin consent in Entra ID.', 'm365-login' ); + } + /* translators: %s: error message */ + return sprintf( __( 'Microsoft Graph error: %s', 'm365-login' ), $message ); + } + + /** + * Adds a log line. + * + * @param string $level 'info', 'warning' or 'error'. + * @param string $message Message. + */ + private function log( $level, $message ) { + if ( count( $this->report['log'] ) < self::LOG_LIMIT ) { + $this->report['log'][] = array( + 'level' => $level, + 'message' => $message, + ); + } elseif ( count( $this->report['log'] ) === self::LOG_LIMIT ) { + $this->report['log'][] = array( + 'level' => 'warning', + 'message' => __( 'Log truncated.', 'm365-login' ), + ); + } + } + + /** + * Logs a skipped person. + * + * @param string $message Message. + */ + private function skip( $message ) { + $this->log( 'warning', $message ); + $this->count( 'skipped' ); + } + + /** + * Increments a counter. + * + * @param string $key Counter. + * @param int $delta Amount. + */ + private function count( $key, $delta = 1 ) { + $this->report['counts'][ $key ] += $delta; + } + + /* ------------------------------------------------------------------ */ + /* Profile photos */ + /* ------------------------------------------------------------------ */ + + /** + * Brings the stored photos in line with Microsoft 365: downloads new and changed + * photos and deletes photos that were removed in Microsoft 365. + * + * Photo versions are compared on every run (20 users per Graph batch request); + * only changed photos are downloaded. + * + * @param int[] $user_ids oid => user ID. + */ + private function sync_photos( $user_ids ) { + $users = array(); + foreach ( $user_ids as $oid => $user_id ) { + $user = get_userdata( $user_id ); + if ( $user ) { + $users[ $oid ] = $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', 0 ); + + /** + * Maximum number of photo downloads per sync run (the rest follows in later runs). + * + * @param int $limit Limit. + */ + $limit = (int) apply_filters( 'm365_login_sync_photo_limit', 500 ); + + $check = array(); + foreach ( $users as $oid => $user ) { + $stored = $this->stored_photo( $user->ID ); + if ( $interval > 0 && ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) { + continue; + } + $check[ $oid ] = $user; + } + if ( empty( $check ) ) { + return; + } + + $versions = $this->graph->photo_versions( array_keys( $check ) ); + $downloads = 0; + $deferred = 0; + + foreach ( $check as $oid => $user ) { + $version = array_key_exists( $oid, $versions ) ? $versions[ $oid ] : new WP_Error( 'graph_photo', 'No answer.' ); + $stored = $this->stored_photo( $user->ID ); + + // Never delete anything because of an error – only a clear "no photo" removes it. + if ( is_wp_error( $version ) ) { + /* translators: 1: e-mail address, 2: error message */ + $this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $version->get_error_message() ) ); + continue; + } + + if ( null === $version ) { + if ( ! empty( $stored['file'] ) ) { + $this->remove_photo( $user ); + } elseif ( ! $this->dry ) { + update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); + } + continue; + } + + if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $version && self::is_photo_file( $stored['file'] ) && 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 && self::is_photo_file( $stored['file'] ) ) { + wp_delete_file( self::photo_path( $stored['file'] ) ); + } + update_user_meta( + $user->ID, + self::META_PHOTO, + array( + 'file' => $file, + 'etag' => $version, + 'checked' => time(), + ) + ); + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + } + + if ( $deferred ) { + /* translators: %d: number of photos */ + $this->log( 'info', sprintf( _n( '%d changed profile photo will be downloaded in the next run (download limit per run reached).', '%d changed profile photos will be downloaded in the next run (download limit per run reached).', $deferred, 'm365-login' ), $deferred ) ); + } + } + + /** + * Removes the stored photo of a user whose photo was deleted in Microsoft 365. + * + * @param WP_User $user User. + */ + private function remove_photo( $user ) { + if ( ! $this->dry ) { + $this->delete_photo( $user->ID ); + update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); + } + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + } + + /** + * Deletes every stored photo (the photo sync was switched off). + */ + private function remove_all_photos() { + $users = get_users( + array( + 'meta_key' => self::META_PHOTO, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_compare' => 'EXISTS', + 'fields' => 'ID', + 'number' => -1, + ) + ); + $removed = 0; + foreach ( $users as $user_id ) { + $stored = $this->stored_photo( (int) $user_id ); + if ( ! empty( $stored['file'] ) ) { + ++$removed; + } + if ( ! $this->dry ) { + $this->delete_photo( (int) $user_id ); + } + } + if ( $removed ) { + /* translators: %d: number of photos */ + $this->log( 'info', sprintf( _n( 'Profile photo sync is off: %d stored photo removed.', 'Profile photo sync is off: %d stored photos removed.', $removed, 'm365-login' ), $removed ) ); + $this->count( 'photos', $removed ); + } + } + + /** + * Stored photo record of a user. + * + * @param int $user_id User ID. + * @return array + */ + private function stored_photo( $user_id ) { + $stored = get_user_meta( $user_id, self::META_PHOTO, true ); + return is_array( $stored ) ? $stored : array(); + } + + /** + * Writes the image into uploads/m365-login-avatars/. + * + * @param int $user_id User ID. + * @param string $oid Object ID. + * @param string $etag Photo version. + * @param string $bytes Image data. + * @return string File path relative to the uploads base directory, or ''. + */ + private function store_photo( $user_id, $oid, $etag, $bytes ) { + $size = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid data is expected to fail quietly. + if ( ! is_array( $size ) || empty( $size['mime'] ) || ! in_array( $size['mime'], array( 'image/jpeg', 'image/png', 'image/gif' ), true ) ) { + return ''; + } + // No decompression bombs: Microsoft 365 photos are at most 648×648 (originals up to a few thousand pixels). + if ( empty( $size[0] ) || empty( $size[1] ) || $size[0] > 4096 || $size[1] > 4096 ) { + return ''; + } + + $uploads = wp_upload_dir(); + $dir = trailingslashit( $uploads['basedir'] ) . self::PHOTO_DIR; + if ( ! wp_mkdir_p( $dir ) ) { + return ''; + } + if ( ! file_exists( $dir . '/index.php' ) ) { + file_put_contents( $dir . '/index.php', "resize( 240, 240, true ); + $editor->set_quality( 85 ); + + // JPEG where the server can write it, PNG otherwise (some GD builds lack JPEG). + $ext = 'jpg'; + $out = 'image/jpeg'; + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/jpeg' ) ) ) { + $ext = 'png'; + $out = 'image/png'; + } + $name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $ext; + $saved = $editor->save( $dir . '/' . $name, $out ); + wp_delete_file( $tmp ); + if ( is_wp_error( $saved ) || empty( $saved['path'] ) ) { + return ''; + } + return self::PHOTO_DIR . '/' . $name; + } + + /** + * 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, '/' ); + } + + /** + * Whether a stored photo path is one the plugin wrote (no traversal, fixed folder and pattern). + * + * @param mixed $file Relative path from user meta. + * @return bool + */ + private static function is_photo_file( $file ) { + return is_string( $file ) && (bool) preg_match( self::PHOTO_FILE, $file ); + } + + /** + * Deletes a user's stored photo (also hooked to user deletion). + * + * @param int $user_id User ID. + */ + public function delete_photo( $user_id ) { + self::remove_stored_photo( $user_id ); + } + + /** + * Deletes a user's stored photo file and record. + * + * @param int $user_id User ID. + */ + public static function remove_stored_photo( $user_id ) { + $stored = get_user_meta( $user_id, self::META_PHOTO, true ); + if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) { + 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'] ) || ! self::is_photo_file( $stored['file'] ) ) { + return $args; + } + $uploads = wp_get_upload_dir(); + $args['url'] = trailingslashit( $uploads['baseurl'] ) . ltrim( $stored['file'], '/' ); + $args['found_avatar'] = true; + return $args; + } + + /* ------------------------------------------------------------------ */ + /* Privacy tools (Tools → Export / Erase Personal Data) */ + /* ------------------------------------------------------------------ */ + + /** + * Registers the exporter. + * + * @param array $exporters Exporters. + * @return array + */ + public function register_exporter( $exporters ) { + $exporters['m365-login'] = array( + 'exporter_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ), + 'callback' => array( $this, 'export_personal_data' ), + ); + return $exporters; + } + + /** + * Registers the eraser. + * + * @param array $erasers Erasers. + * @return array + */ + public function register_eraser( $erasers ) { + $erasers['m365-login'] = array( + 'eraser_friendly_name' => __( 'Microsoft 365 (M365 Login)', 'm365-login' ), + 'callback' => array( $this, 'erase_personal_data' ), + ); + return $erasers; + } + + /** + * Exports the data the plugin stores about a user. + * + * @param string $email E-mail address. + * @param int $page Page. + * @return array + */ + public function export_personal_data( $email, $page = 1 ) { + $user = get_user_by( 'email', $email ); + $data = array(); + if ( $user ) { + $fields = array( + __( 'Microsoft object ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ), + __( 'Microsoft tenant ID', 'm365-login' ) => (string) get_user_meta( $user->ID, M365_Login_Auth::META_TID, true ), + ); + foreach ( self::attributes() as $attribute ) { + $target = (string) $attribute['target']; + if ( 0 === strpos( $target, 'm365_' ) ) { + $fields[ (string) $attribute['label'] ] = (string) get_user_meta( $user->ID, $target, true ); + } + } + $stored = get_user_meta( $user->ID, self::META_PHOTO, true ); + if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) { + $uploads = wp_get_upload_dir(); + $fields[ __( 'Profile photo', 'm365-login' ) ] = trailingslashit( $uploads['baseurl'] ) . $stored['file']; + } + $last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true ); + if ( $last ) { + $fields[ __( 'Last sync', 'm365-login' ) ] = wp_date( 'c', $last ); + } + $items = array(); + foreach ( $fields as $name => $value ) { + if ( '' !== $value ) { + $items[] = array( + 'name' => $name, + 'value' => $value, + ); + } + } + if ( $items ) { + $data[] = array( + 'group_id' => 'm365-login', + 'group_label' => __( 'Microsoft 365', 'm365-login' ), + 'item_id' => 'm365-login-' . $user->ID, + 'data' => $items, + ); + } + } + return array( + 'data' => $data, + 'done' => true, + ); + } + + /** + * Erases copied profile data and the photo. The object ID link and a deactivation are kept: + * they protect the account (removing them would unlock or unbind it). + * + * @param string $email E-mail address. + * @param int $page Page. + * @return array + */ + public function erase_personal_data( $email, $page = 1 ) { + $user = get_user_by( 'email', $email ); + $removed = false; + $kept = false; + if ( $user ) { + foreach ( self::attributes() as $attribute ) { + $target = (string) $attribute['target']; + if ( 0 === strpos( $target, 'm365_' ) && '' !== (string) get_user_meta( $user->ID, $target, true ) ) { + delete_user_meta( $user->ID, $target ); + $removed = true; + } + } + if ( get_user_meta( $user->ID, self::META_PHOTO, true ) ) { + self::remove_stored_photo( $user->ID ); + $removed = true; + } + $kept = '' !== (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true ) || (bool) self::disabled_info( $user->ID ); + } + return array( + 'items_removed' => $removed, + 'items_retained' => $kept, + 'messages' => $kept ? array( __( 'The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync.', 'm365-login' ) ) : array(), + 'done' => true, + ); + } + + /* ------------------------------------------------------------------ */ + /* 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 = '' ) { + if ( self::disabled_info( $user_id ) ) { + return; // Already deactivated: keep the remembered roles and the origin. + } + $user = get_userdata( $user_id ); + $roles = $user ? array_values( $user->roles ) : array(); + + update_user_meta( + $user_id, + self::META_DISABLED, + array( + 'time' => time(), + 'by' => $by, + 'reason' => $reason, + 'roles' => $roles, + ) + ); + + // Lock the account for good, also without this plugin: no sessions, no role on this + // site, a random password nobody knows and no application passwords. + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + if ( class_exists( 'WP_Application_Passwords' ) ) { + WP_Application_Passwords::delete_all_application_passwords( $user_id ); + } + wp_set_password( wp_generate_password( 64, true, true ), $user_id ); + if ( $user ) { + $user->set_role( '' ); + } + self::remove_stored_photo( $user_id ); // No public photo of a deactivated account. + + /** + * 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 ); + } + + /** + * One-time hardening of accounts deactivated before 1.1.0 (no role/password/app-password lock yet). + */ + public static function harden_legacy_disabled() { + $users = get_users( + array( + 'meta_key' => self::META_DISABLED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_compare' => 'EXISTS', + 'fields' => 'ID', + 'number' => -1, + 'blog_id' => 0, + ) + ); + foreach ( $users as $user_id ) { + $info = self::disabled_info( (int) $user_id ); + if ( ! $info || array_key_exists( 'roles', $info ) ) { + continue; + } + delete_user_meta( (int) $user_id, self::META_DISABLED ); + self::disable( (int) $user_id, $info['by'], $info['reason'] ); + } + } + + /** + * Reactivates an account. + * + * @param int $user_id User ID. + */ + public static function enable( $user_id ) { + $info = self::disabled_info( $user_id ); + delete_user_meta( $user_id, self::META_DISABLED ); + + // Give back the roles taken away on deactivation (the sync may adjust them afterwards). + $user = get_userdata( $user_id ); + if ( $user && empty( $user->roles ) && $info && ! empty( $info['roles'] ) && is_array( $info['roles'] ) ) { + foreach ( $info['roles'] as $role ) { + if ( is_string( $role ) && get_role( $role ) ) { + $user->add_role( $role ); + } + } + } + + /** + * 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 . '_' . ( $disabled ? 'enable' : 'disable' ) + ); + $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; + $state = isset( $_GET['state'] ) && 'disable' === $_GET['state'] ? 'disable' : 'enable'; + check_admin_referer( self::POST_STATE . '_' . $user_id . '_' . $state ); + 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 ); + } + 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:disable WordPress.Security.NonceVerification.Recommended -- display only. + if ( isset( $_GET['m365_linked'] ) ) { + printf( '

    %s

    ', esc_html__( 'Your Microsoft account is now linked. From now on you can sign in with the Microsoft button.', 'm365-login' ) ); + } + $link_error = isset( $_GET['m365_link_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_link_error'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + if ( '' !== $link_error ) { + printf( '

    %s

    ', esc_html( M365_Login::instance()->auth->error_message( $link_error ) ) ); + } + + // 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 ); + $own = get_current_user_id() === $user->ID; + $is_admin = current_user_can( M365_Login_Admin::capability() ) && current_user_can( 'edit_user', $user->ID ); + $can_link = $own && '' === $oid && $this->settings->is_configured() && ! self::disabled_info( $user->ID ); + if ( '' === $oid && ! self::disabled_info( $user->ID ) && ! $can_link && ! $is_admin ) { + 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; + } + } + $assigned = (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ); + ?> +

    + + + + + + + + + + + + $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' ) ); + add_action( 'init', array( $this, 'maybe_upgrade' ), 1 ); + } + + /** + * One-time data migrations after an update. + */ + public function maybe_upgrade() { + $stored = (string) get_option( 'm365_login_version', '1.0.0' ); + if ( version_compare( $stored, M365_LOGIN_VERSION, '>=' ) ) { + return; + } + update_option( 'm365_login_version', M365_LOGIN_VERSION ); + + if ( version_compare( $stored, '1.1.0', '<' ) ) { + M365_Login_Sync::harden_legacy_disabled(); + $this->settings->normalise_stored_certificate(); + } } /** diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index 36e39e2..7921062 100644 Binary files a/languages/m365-login-de_DE.mo and b/languages/m365-login-de_DE.mo differ diff --git a/languages/m365-login-de_DE.po b/languages/m365-login-de_DE.po index bdba9a9..ba9e662 100644 --- a/languages/m365-login-de_DE.po +++ b/languages/m365-login-de_DE.po @@ -2,13 +2,13 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: M365 Login 1.0.0\n" +"Project-Id-Version: M365 Login 1.1.0\n" "Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-09-22T00:00:00+00:00\n" -"PO-Revision-Date: 2026-09-22 12:00+0000\n" +"POT-Creation-Date: 2026-09-23T00:00:00+00:00\n" +"PO-Revision-Date: 2026-09-23 12:00+0000\n" "Last-Translator: friloo\n" "Language-Team: German\n" "Language: de_DE\n" @@ -16,784 +16,1173 @@ msgstr "" "X-Generator: bin/make-pot.py\n" "X-Domain: m365-login\n" -#: includes/class-m365-login-admin.php:81 includes/class-m365-login-admin.php:82 includes/class-m365-login-admin.php:93 includes/class-m365-login-admin.php:382 +#: includes/class-m365-login-admin.php:108 includes/class-m365-login-admin.php:109 includes/class-m365-login-admin.php:120 includes/class-m365-login-admin.php:779 msgid "M365 Login" msgstr "M365 Login" -#: includes/class-m365-login-admin.php:108 +#: includes/class-m365-login-admin.php:135 msgid "Connection" msgstr "Verbindung" -#: includes/class-m365-login-admin.php:109 +#: includes/class-m365-login-admin.php:136 msgid "Button" msgstr "Button" -#: includes/class-m365-login-admin.php:110 +#: includes/class-m365-login-admin.php:137 msgid "Security" msgstr "Sicherheit" -#: includes/class-m365-login-admin.php:185 -msgid "M365 Login is active but not connected to Microsoft Entra ID yet." -msgstr "M365 Login ist aktiv, aber noch nicht mit Microsoft Entra ID verbunden." +#: includes/class-m365-login-admin.php:138 +msgid "User sync" +msgstr "Benutzer-Sync" -#: includes/class-m365-login-admin.php:187 +#: includes/class-m365-login-admin.php:207 +msgid "M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link." +msgstr "M365 Login: Der Nur-Button-Modus ist aktiv, aber die Verbindung zu Microsoft ist gestört (Secret fehlt oder ist nicht entschlüsselbar, oder das Zertifikat ist abgelaufen). Anmelden ist nur noch über den Fallback-Link möglich." + +#: includes/class-m365-login-admin.php:209 includes/class-m365-login-admin.php:227 msgid "Open the settings" msgstr "Einstellungen öffnen" -#: includes/class-m365-login-admin.php:217 +#: includes/class-m365-login-admin.php:225 +msgid "M365 Login is active but not connected to Microsoft Entra ID yet." +msgstr "M365 Login ist aktiv, aber noch nicht mit Microsoft Entra ID verbunden." + +#: includes/class-m365-login-admin.php:263 msgid "Choose button icon" msgstr "Button-Icon auswählen" -#: includes/class-m365-login-admin.php:218 +#: includes/class-m365-login-admin.php:264 msgid "Use this icon" msgstr "Dieses Icon verwenden" -#: includes/class-m365-login-admin.php:219 +#: includes/class-m365-login-admin.php:265 msgid "Copied!" msgstr "Kopiert!" -#: includes/class-m365-login-admin.php:220 includes/class-m365-login-admin.php:505 includes/class-m365-login-admin.php:783 includes/class-m365-login-admin.php:826 +#: includes/class-m365-login-admin.php:266 includes/class-m365-login-admin.php:905 includes/class-m365-login-admin.php:1172 includes/class-m365-login-admin.php:1217 msgid "Copy" msgstr "Kopieren" -#: includes/class-m365-login-admin.php:221 +#: includes/class-m365-login-admin.php:267 msgid "Testing…" msgstr "Wird geprüft …" -#: includes/class-m365-login-admin.php:222 +#: includes/class-m365-login-admin.php:268 msgid "The tenant could not be reached. Check the tenant ID and the server’s outgoing connections." msgstr "Der Tenant ist nicht erreichbar. Bitte Tenant-ID und ausgehende Verbindungen des Servers prüfen." -#: includes/class-m365-login-admin.php:223 +#: includes/class-m365-login-admin.php:269 msgid "No groups found." msgstr "Keine Gruppen gefunden." -#: includes/class-m365-login-admin.php:224 +#: includes/class-m365-login-admin.php:270 msgid "Searching…" msgstr "Suche läuft …" -#: includes/class-m365-login-admin.php:225 +#: includes/class-m365-login-admin.php:271 msgid "Add" msgstr "Hinzufügen" -#: includes/class-m365-login-admin.php:226 includes/class-m365-login-admin.php:757 +#: includes/class-m365-login-admin.php:272 includes/class-m365-login-admin.php:528 msgid "Remove" msgstr "Entfernen" -#: includes/class-m365-login-admin.php:227 includes/class-m365-login-admin.php:285 includes/class-m365-login-admin.php:742 +#: includes/class-m365-login-admin.php:273 includes/class-m365-login-admin.php:336 includes/class-m365-login-admin.php:501 msgid "Save the connection settings first, then search for groups." msgstr "Zuerst die Verbindungseinstellungen speichern, dann Gruppen suchen." -#: includes/class-m365-login-admin.php:228 +#: includes/class-m365-login-admin.php:274 msgid "Generate a new fallback key on save? The old link stops working." msgstr "Beim Speichern einen neuen Fallback-Schlüssel erzeugen? Der alte Link funktioniert dann nicht mehr." -#: includes/class-m365-login-admin.php:229 +#: includes/class-m365-login-admin.php:275 msgid "Generating a 3072-bit key pair, this takes a moment…" msgstr "3072-Bit-Schlüsselpaar wird erzeugt, das dauert einen Moment …" -#: includes/class-m365-login-admin.php:230 +#: includes/class-m365-login-admin.php:276 msgid "Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID." msgstr "Gespeichertes Zertifikat ersetzen? Die Anmeldung funktioniert erst wieder, wenn das neue Zertifikat in Entra ID hochgeladen ist." -#: includes/class-m365-login-admin.php:231 +#: includes/class-m365-login-admin.php:277 msgid "Remove the stored certificate when saving? Sign-in with the certificate method stops working." msgstr "Gespeichertes Zertifikat beim Speichern entfernen? Die Anmeldung per Zertifikat funktioniert dann nicht mehr." -#: includes/class-m365-login-admin.php:243 includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:308 includes/class-m365-login-admin.php:340 +#: includes/class-m365-login-admin.php:278 +msgid "Sync is running, this can take a while for large directories…" +msgstr "Sync läuft, bei großen Verzeichnissen kann das etwas dauern …" + +#: includes/class-m365-login-admin.php:279 +msgid "Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first." +msgstr "Sync jetzt mit den gespeicherten Einstellungen ausführen? Konten werden angelegt, aktualisiert und eventuell deaktiviert oder gelöscht. Tipp: Führe zuerst einen Testlauf aus." + +#: includes/class-m365-login-admin.php:280 +msgid "The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use \"wp m365-login sync\" (WP-CLI)." +msgstr "Die Anfrage ist fehlgeschlagen oder hat zu lange gedauert. Lade die Seite in ein paar Minuten neu, um den Bericht zu sehen; für sehr große Verzeichnisse nutze „wp m365-login sync“ (WP-CLI)." + +#: includes/class-m365-login-admin.php:281 +msgid "You have unsaved changes. The sync uses the saved settings – save first." +msgstr "Du hast ungespeicherte Änderungen. Der Sync verwendet die gespeicherten Einstellungen – speichere zuerst." + +#: includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:515 +msgid "Move up" +msgstr "Nach oben" + +#: includes/class-m365-login-admin.php:294 includes/class-m365-login-admin.php:333 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:392 includes/class-m365-login-admin.php:566 includes/class-m365-login-sync.php:2230 msgid "You are not allowed to do this." msgstr "Dafür fehlt die Berechtigung." -#: includes/class-m365-login-admin.php:248 +#: includes/class-m365-login-admin.php:299 msgid "Please enter a valid tenant ID first." msgstr "Bitte zuerst eine gültige Tenant-ID eingeben." #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:262 +#: includes/class-m365-login-admin.php:313 msgid "Microsoft answered with HTTP %d. Is the tenant ID correct?" msgstr "Microsoft hat mit HTTP %d geantwortet. Ist die Tenant-ID korrekt?" #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:271 +#: includes/class-m365-login-admin.php:322 msgid "Tenant reachable. The OpenID configuration was loaded successfully." msgstr "Tenant erreichbar. Die OpenID-Konfiguration wurde erfolgreich geladen." -#: includes/class-m365-login-admin.php:294 +#: includes/class-m365-login-admin.php:345 msgid "Microsoft Graph refused the request. Grant the application permission \"GroupMember.Read.All\" (or \"Directory.Read.All\") with admin consent in Entra ID." msgstr "Microsoft Graph hat die Anfrage abgelehnt. In Entra ID die Anwendungsberechtigung „GroupMember.Read.All“ (oder „Directory.Read.All“) mit Administratorzustimmung erteilen." -#: includes/class-m365-login-admin.php:312 +#: includes/class-m365-login-admin.php:363 msgid "Unknown operation." msgstr "Unbekannte Aktion." -#: includes/class-m365-login-admin.php:329 +#: includes/class-m365-login-admin.php:380 msgid "Certificate generated and stored. Download the .cer file and upload it in Entra ID." msgstr "Zertifikat erzeugt und gespeichert. Jetzt die .cer-Datei herunterladen und in Entra ID hochladen." -#: includes/class-m365-login-admin.php:346 +#: includes/class-m365-login-admin.php:407 +msgid "The sync has not run yet." +msgstr "Der Sync ist noch nicht gelaufen." + +#: includes/class-m365-login-admin.php:411 +msgid "Finished" +msgstr "Abgeschlossen" + +#: includes/class-m365-login-admin.php:412 +msgid "Failed" +msgstr "Fehlgeschlagen" + +#: includes/class-m365-login-admin.php:413 +msgid "Stopped by the safety limit" +msgstr "Vom Sicherheitslimit gestoppt" + +#: includes/class-m365-login-admin.php:414 +msgid "Not started" +msgstr "Nicht gestartet" + +#: includes/class-m365-login-admin.php:417 +msgid "started manually" +msgstr "manuell gestartet" + +#: includes/class-m365-login-admin.php:418 +msgid "scheduled" +msgstr "geplant" + +#: includes/class-m365-login-admin.php:419 +msgid "WP-CLI" +msgstr "WP-CLI" + +#: includes/class-m365-login-admin.php:422 +msgid "would be created" +msgstr "würden angelegt" + +#: includes/class-m365-login-admin.php:422 +msgid "created" +msgstr "angelegt" + +#: includes/class-m365-login-admin.php:423 +msgid "would be updated" +msgstr "würden aktualisiert" + +#: includes/class-m365-login-admin.php:423 +msgid "updated" +msgstr "aktualisiert" + +#: includes/class-m365-login-admin.php:424 +msgid "would be linked" +msgstr "würden verknüpft" + +#: includes/class-m365-login-admin.php:424 +msgid "linked" +msgstr "verknüpft" + +#: includes/class-m365-login-admin.php:425 +msgid "unchanged" +msgstr "unverändert" + +#: includes/class-m365-login-admin.php:426 +msgid "would be deactivated" +msgstr "würden deaktiviert" + +#: includes/class-m365-login-admin.php:426 +msgid "deactivated" +msgstr "deaktiviert" + +#: includes/class-m365-login-admin.php:427 +msgid "would be reactivated" +msgstr "würden reaktiviert" + +#: includes/class-m365-login-admin.php:427 +msgid "reactivated" +msgstr "reaktiviert" + +#: includes/class-m365-login-admin.php:428 +msgid "would be deleted" +msgstr "würden gelöscht" + +#: includes/class-m365-login-admin.php:428 +msgid "deleted" +msgstr "gelöscht" + +#: includes/class-m365-login-admin.php:429 +msgid "photos" +msgstr "Profilbilder" + +#: includes/class-m365-login-admin.php:430 +msgid "skipped" +msgstr "übersprungen" + +#: includes/class-m365-login-admin.php:431 +msgid "errors" +msgstr "Fehler" + +#: includes/class-m365-login-admin.php:444 +msgid "Dry run – nothing was changed" +msgstr "Testlauf – nichts wurde geändert" + +#. translators: 1: date and time, 2: how the run was started, 3: duration in seconds +#: includes/class-m365-login-admin.php:449 +msgid "%1$s, %2$s, %3$d s" +msgstr "%1$s, %2$s, %3$d s" + +#. translators: %d: number of log entries +#: includes/class-m365-login-admin.php:467 +msgid "Log (%d entry)" +msgid_plural "Log (%d entries)" +msgstr[0] "Protokoll (%d Eintrag)" +msgstr[1] "Protokoll (%d Einträge)" + +#: includes/class-m365-login-admin.php:495 +msgid "Search groups" +msgstr "Gruppen suchen" + +#: includes/class-m365-login-admin.php:497 +msgid "Type a group name or paste an object ID…" +msgstr "Gruppenname eingeben oder Objekt-ID einfügen …" + +#: includes/class-m365-login-admin.php:498 +msgid "Search" +msgstr "Suchen" + +#: includes/class-m365-login-admin.php:503 +msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." +msgstr "Benötigt die Anwendungsberechtigung „GroupMember.Read.All“ mit Administratorzustimmung. Ohne sie können Gruppen-Objekt-IDs trotzdem eingefügt werden." + +#: includes/class-m365-login-admin.php:509 +msgid "Selected groups" +msgstr "Ausgewählte Gruppen" + +#: includes/class-m365-login-admin.php:521 +msgid "WordPress role" +msgstr "WordPress-Rolle" + +#: includes/class-m365-login-admin.php:573 msgid "No certificate is stored." msgstr "Es ist kein Zertifikat gespeichert." -#: includes/class-m365-login-admin.php:364 +#: includes/class-m365-login-admin.php:598 +msgid "Do nothing" +msgstr "Nichts tun" + +#: includes/class-m365-login-admin.php:599 +msgid "Deactivate the WordPress account" +msgstr "WordPress-Konto deaktivieren" + +#: includes/class-m365-login-admin.php:600 +msgid "Delete the WordPress account" +msgstr "WordPress-Konto löschen" + +#: includes/class-m365-login-admin.php:603 +msgid "Account disabled in Microsoft 365 (sign-in blocked)" +msgstr "Konto in Microsoft 365 deaktiviert (Anmeldung blockiert)" + +#: includes/class-m365-login-admin.php:604 +msgid "Account deleted in Microsoft 365" +msgstr "Konto in Microsoft 365 gelöscht" + +#: includes/class-m365-login-admin.php:605 +msgid "No longer a member of the sync groups" +msgstr "Kein Mitglied der Sync-Gruppen mehr" + +#: includes/class-m365-login-admin.php:610 +msgid "Import users from Microsoft 365" +msgstr "Benutzer aus Microsoft 365 importieren" + +#: includes/class-m365-login-admin.php:611 +msgid "Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button." +msgstr "Legt für jeden Microsoft-365-Benutzer im Geltungsbereich ein WordPress-Konto an, verknüpft bestehende Konten über die E-Mail-Adresse, hält Rollen und Profilfelder aktuell und deaktiviert oder löscht Konten, die in Microsoft 365 deaktiviert oder entfernt wurden. Neue Konten erhalten ein Zufallspasswort und keine E-Mail; die Anmeldung erfolgt über den Microsoft-Button." + +#: includes/class-m365-login-admin.php:616 +msgid "Run the sync automatically" +msgstr "Sync automatisch ausführen" + +#: includes/class-m365-login-admin.php:617 +msgid "Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run \"wp m365-login sync\"." +msgstr "Nutzt WP-Cron, das bei Besuchen der Website ausgelöst wird. Für genaue Zeiten rufe wp-cron.php über einen echten Cronjob auf oder führe „wp m365-login sync“ aus." + +#: includes/class-m365-login-admin.php:623 +msgid "Interval" +msgstr "Intervall" + +#: includes/class-m365-login-admin.php:625 +msgid "Hourly" +msgstr "Stündlich" + +#: includes/class-m365-login-admin.php:626 +msgid "Twice daily" +msgstr "Zweimal täglich" + +#: includes/class-m365-login-admin.php:627 +msgid "Daily" +msgstr "Täglich" + +#. translators: %s: date and time +#: includes/class-m365-login-admin.php:631 +msgid "Next run: %s" +msgstr "Nächster Lauf: %s" + +#: includes/class-m365-login-admin.php:639 +msgid "Also import guest users (B2B)" +msgstr "Auch Gastbenutzer importieren (B2B)" + +#: includes/class-m365-login-admin.php:640 +msgid "Guests are external people invited into your tenant. Off by default." +msgstr "Gäste sind externe Personen, die in deinen Tenant eingeladen wurden. Standardmäßig aus." + +#: includes/class-m365-login-admin.php:644 +msgid "Which users? (optional)" +msgstr "Welche Benutzer? (optional)" + +#: includes/class-m365-login-admin.php:645 +msgid "Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well." +msgstr "Beschränkt den Import auf Mitglieder dieser Gruppen (verschachtelte Mitgliedschaften zählen). Ohne Gruppen wird jeder Benutzer des Tenants importiert. Die Liste erlaubter E-Mail-Domains im Tab „Sicherheit“ gilt ebenfalls." + +#: includes/class-m365-login-admin.php:646 +msgid "No groups selected – all users of the tenant are imported." +msgstr "Keine Gruppen ausgewählt – alle Benutzer des Tenants werden importiert." + +#: includes/class-m365-login-admin.php:650 +msgid "Roles" +msgstr "Rollen" + +#: includes/class-m365-login-admin.php:653 +msgid "Default role" +msgstr "Standardrolle" + +#: includes/class-m365-login-admin.php:657 +msgid "Every imported user gets this role. The sync manages the roles of imported accounts – manual role changes are overwritten on the next run." +msgstr "Jeder importierte Benutzer erhält diese Rolle. Die Rollen importierter Konten verwaltet der Sync – manuelle Rollenänderungen werden beim nächsten Lauf überschrieben." + +#: includes/class-m365-login-admin.php:660 +msgid "Additional roles from Microsoft 365 groups" +msgstr "Zusätzliche Rollen aus Microsoft-365-Gruppen" + +#: includes/class-m365-login-admin.php:661 +msgid "Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync." +msgstr "Mitglieder einer Gruppe (verschachtelte Mitgliedschaften zählen) erhalten die Rolle daneben. Verlässt eine Person die Gruppe, wird die Rolle beim nächsten Sync wieder entfernt." + +#: includes/class-m365-login-admin.php:662 +msgid "Whoever can change a group's members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) – never public Microsoft 365 groups or Teams, which members can join themselves." +msgstr "Wer die Mitglieder einer Gruppe ändern kann, bestimmt über die zugeordnete Rolle. Für Rollen mit Administrationsrechten Sicherheitsgruppen verwenden (am besten rollenzuweisbare) – nie öffentliche Microsoft-365-Gruppen oder Teams, denen Mitglieder selbst beitreten können." + +#: includes/class-m365-login-admin.php:663 +msgid "No group mapping – everybody gets the default role." +msgstr "Keine Gruppenzuordnung – alle erhalten die Standardrolle." + +#: includes/class-m365-login-admin.php:666 +msgid "How are mapped roles applied?" +msgstr "Wie werden zugeordnete Rollen vergeben?" + +#: includes/class-m365-login-admin.php:669 +msgid "In addition to the default role (a user can have several roles)" +msgstr "Zusätzlich zur Standardrolle (ein Benutzer kann mehrere Rollen haben)" + +#: includes/class-m365-login-admin.php:673 +msgid "Instead of the default role – the first matching group in the list wins (use ↑ to reorder)" +msgstr "Anstelle der Standardrolle – die erste passende Gruppe der Liste gewinnt (Reihenfolge mit ↑ ändern)" + +#: includes/class-m365-login-admin.php:680 +msgid "Also manage the roles of accounts that existed before the sync" +msgstr "Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten" + +#: includes/class-m365-login-admin.php:681 +msgid "Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed." +msgstr "Aus: Bestehende Konten werden nur verknüpft und ihre Profilfelder aktualisiert; ihre Rollen bleiben, wie sie sind. Administratoren, die schon vor dem Sync existierten, und dein eigenes Konto werden nie verändert." + +#: includes/class-m365-login-admin.php:687 +msgid "Profile fields" +msgstr "Profilfelder" + +#: includes/class-m365-login-admin.php:688 +msgid "Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with \"m365_\" – usable by themes and other plugins – and is shown on the profile screen." +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:697 +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:701 +msgid "Disabled and deleted Microsoft 365 accounts" +msgstr "Deaktivierte und gelöschte Microsoft-365-Konten" + +#: includes/class-m365-login-admin.php:702 +msgid "Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account." +msgstr "Gilt für WordPress-Konten, die mit einem Microsoft-Konto verknüpft sind (importiert oder mindestens einmal per Microsoft angemeldet). Deaktivierte Konten können sich gar nicht mehr anmelden – weder mit Microsoft noch mit Passwort oder Anwendungspasswort – und werden sofort abgemeldet. Ist die Person in Microsoft 365 wieder aktiv, reaktiviert der Sync das Konto." + +#: includes/class-m365-login-admin.php:713 +msgid "Only relevant when the import is limited to groups." +msgstr "Nur relevant, wenn der Import auf Gruppen beschränkt ist." + +#: includes/class-m365-login-admin.php:719 +msgid "Posts of deleted accounts go to" +msgstr "Beiträge gelöschter Konten übernimmt" + +#: includes/class-m365-login-admin.php:727 +msgid "— Select a user —" +msgstr "— Benutzer auswählen —" + +#: includes/class-m365-login-admin.php:734 +msgid "Required for \"Delete\". Without a user, accounts are deactivated instead, so no content is ever lost." +msgstr "Erforderlich für „Löschen“. Ohne Benutzer werden Konten stattdessen deaktiviert, damit nie Inhalte verloren gehen." + +#: includes/class-m365-login-admin.php:737 +msgid "Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated." +msgstr "Sicherheitsstopp: Würde ein Lauf mehr als 20 % der verknüpften Konten (mindestens 5) deaktivieren oder löschen, wird nichts deaktiviert oder gelöscht und der Lauf als gestoppt gemeldet. Auch eine fehlgeschlagene Microsoft-Graph-Anfrage stoppt den Lauf, bevor etwas deaktiviert wird." + +#: includes/class-m365-login-admin.php:741 +msgid "Run the sync" +msgstr "Sync ausführen" + +#: includes/class-m365-login-admin.php:742 +msgid "The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything." +msgstr "Der Lauf verwendet die gespeicherten Einstellungen. Beginne mit einem Testlauf: Er liest Microsoft 365 und listet auf, was sich ändern würde, ohne etwas zu ändern." + +#: includes/class-m365-login-admin.php:744 +msgid "Dry run" +msgstr "Testlauf" + +#: includes/class-m365-login-admin.php:745 +msgid "Sync now" +msgstr "Jetzt synchronisieren" + +#: includes/class-m365-login-admin.php:747 +msgid "Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used." +msgstr "Benötigte Anwendungsberechtigungen (Microsoft Graph, Administratorzustimmung): User.Read.All, bei Verwendung von Gruppen zusätzlich GroupMember.Read.All." + +#: includes/class-m365-login-admin.php:761 msgid "You are not allowed to access this page." msgstr "Für diese Seite fehlt die Berechtigung." -#: includes/class-m365-login-admin.php:383 +#: includes/class-m365-login-admin.php:780 msgid "Let existing users sign in with their Microsoft 365 / Entra ID account." msgstr "Bestehende Benutzer melden sich mit ihrem Microsoft 365 / Entra ID-Konto an." -#: includes/class-m365-login-admin.php:388 +#: includes/class-m365-login-admin.php:785 msgid "Connected" msgstr "Verbunden" -#: includes/class-m365-login-admin.php:388 +#: includes/class-m365-login-admin.php:785 msgid "Setup incomplete" msgstr "Einrichtung unvollständig" -#: includes/class-m365-login-admin.php:410 +#: includes/class-m365-login-admin.php:807 msgid "Microsoft Entra ID app registration" msgstr "App-Registrierung in Microsoft Entra ID" -#: includes/class-m365-login-admin.php:411 +#: includes/class-m365-login-admin.php:808 msgid "Enter the values from your app registration in the Microsoft Entra admin center." msgstr "Trage hier die Werte aus deiner App-Registrierung im Microsoft Entra Admin Center ein." -#: includes/class-m365-login-admin.php:414 +#: includes/class-m365-login-admin.php:811 msgid "Directory (tenant) ID" msgstr "Verzeichnis-ID (Mandant/Tenant)" -#: includes/class-m365-login-admin.php:417 +#: includes/class-m365-login-admin.php:814 msgid "Test tenant" msgstr "Tenant testen" -#: includes/class-m365-login-admin.php:419 +#: includes/class-m365-login-admin.php:816 msgid "Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. \"organizations\" allows any work or school account." msgstr "Empfohlen: die GUID deines Tenants. Dann werden nur Anmeldungen aus diesem Tenant akzeptiert. „organizations“ erlaubt beliebige Geschäfts-, Schul- oder Unikonten." -#: includes/class-m365-login-admin.php:421 +#: includes/class-m365-login-admin.php:818 msgid "Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their \"email\" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID." msgstr "Multi-Tenant-Modus: Konten aus beliebigen Microsoft-Tenants können sich anmelden. Deren „email“-Attribut ist nicht verifiziert, deshalb ordnet das Plugin nur über den User Principal Name (verifizierte Domain) zu und ignoriert den E-Mail-Claim, sofern Microsoft ihn nicht als domain-verifiziert markiert. Nutze die Domain-Allowlist im Tab „Sicherheit“ oder besser: die Tenant-GUID eintragen." -#: includes/class-m365-login-admin.php:427 +#: includes/class-m365-login-admin.php:824 msgid "Application (client) ID" msgstr "Anwendungs-ID (Client)" -#: includes/class-m365-login-admin.php:432 +#: includes/class-m365-login-admin.php:829 msgid "How should WordPress authenticate to Microsoft?" msgstr "Wie soll sich WordPress bei Microsoft authentifizieren?" -#: includes/class-m365-login-admin.php:437 includes/class-m365-login-admin.php:454 +#: includes/class-m365-login-admin.php:834 includes/class-m365-login-admin.php:851 msgid "Client secret" msgstr "Geheimer Clientschlüssel (Client Secret)" -#: includes/class-m365-login-admin.php:438 +#: includes/class-m365-login-admin.php:835 msgid "Quick to set up. A password-like value created in Entra ID that expires after 6–24 months and must be renewed." msgstr "Schnell eingerichtet. Ein passwortähnlicher Wert aus Entra ID, der nach 6–24 Monaten abläuft und erneuert werden muss." -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Certificate" msgstr "Zertifikat" -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Recommended" msgstr "Empfohlen" -#: includes/class-m365-login-admin.php:445 +#: includes/class-m365-login-admin.php:842 msgid "The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years." msgstr "Der private Schlüssel verlässt diesen Server nie; nur das öffentliche Zertifikat wird in Entra ID hochgeladen. Mit einem Klick hier erzeugt, 2 Jahre gültig." -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "•••••••••••• (stored, leave empty to keep)" msgstr "•••••••••••• (gespeichert – leer lassen, um zu behalten)" -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "Paste the secret value" msgstr "Wert des Secrets einfügen" -#: includes/class-m365-login-admin.php:457 +#: includes/class-m365-login-admin.php:854 msgid "Show secret" msgstr "Secret anzeigen" -#: includes/class-m365-login-admin.php:462 +#: includes/class-m365-login-admin.php:859 msgid "Remove the stored secret" msgstr "Gespeichertes Secret entfernen" -#: includes/class-m365-login-admin.php:465 +#: includes/class-m365-login-admin.php:862 msgid "Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire – note the expiry date in Entra ID." msgstr "Wird verschlüsselt gespeichert (AES-256-GCM, Schlüssel aus den WordPress-Salts abgeleitet) und nie wieder angezeigt. Client Secrets laufen ab – Ablaufdatum in Entra ID notieren." -#: includes/class-m365-login-admin.php:469 +#: includes/class-m365-login-admin.php:864 +msgid "AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database – right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective." +msgstr "AUTH_KEY und SECURE_AUTH_KEY sind nicht in der wp-config.php definiert, daher speichert WordPress seine Salts in der Datenbank – direkt neben dem verschlüsselten Secret. Trage die Salts in die wp-config.php ein, damit die Verschlüsselung wirkt." + +#: includes/class-m365-login-admin.php:869 msgid "Step-by-step: create a client secret in Entra ID" msgstr "Schritt für Schritt: Client Secret in Entra ID erstellen" -#: includes/class-m365-login-admin.php:472 +#: includes/class-m365-login-admin.php:872 msgid "Open entra.microsoft.com and sign in with an account that has the \"Application Administrator\" or \"Global Administrator\" role." msgstr "entra.microsoft.com öffnen und mit einem Konto anmelden, das die Rolle „Anwendungsadministrator“ oder „Globaler Administrator“ hat." -#: includes/class-m365-login-admin.php:473 +#: includes/class-m365-login-admin.php:873 msgid "Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar)." msgstr "Zu Identität → Anwendungen → App-Registrierungen wechseln und die App öffnen (oder zuerst anlegen, siehe allgemeine Anleitung in der Seitenleiste)." -#: includes/class-m365-login-admin.php:474 +#: includes/class-m365-login-admin.php:874 msgid "In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret." msgstr "Im linken Menü „Zertifikate & Geheimnisse“ wählen, dann den Reiter „Geheime Clientschlüssel“ und auf „Neuer geheimer Clientschlüssel“ klicken." -#: includes/class-m365-login-admin.php:475 +#: includes/class-m365-login-admin.php:875 msgid "Enter a description such as \"WordPress login\" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before." msgstr "Eine Beschreibung wie „WordPress Login“ eingeben und eine Gültigkeit wählen. Microsoft erlaubt maximal 24 Monate; zwei Wochen vor Ablauf eine Kalender-Erinnerung setzen." -#: includes/class-m365-login-admin.php:476 +#: includes/class-m365-login-admin.php:876 msgid "Click Add. Copy the Value column immediately – it is shown only once. The Secret ID column is NOT what you need." msgstr "Auf „Hinzufügen“ klicken. Die Spalte „Wert“ sofort kopieren – sie wird nur einmal angezeigt. Die Spalte „Geheimnis-ID“ ist NICHT der gesuchte Wert." -#: includes/class-m365-login-admin.php:477 +#: includes/class-m365-login-admin.php:877 msgid "Paste the value into the Client secret field above and save this page." msgstr "Den Wert oben in das Feld „Geheimer Clientschlüssel“ einfügen und diese Seite speichern." -#: includes/class-m365-login-admin.php:479 +#: includes/class-m365-login-admin.php:879 msgid "When the secret expires, sign-ins fail with \"Could not complete the sign-in with Microsoft\". Create a new secret, paste it here, save, then delete the old one in Entra ID." msgstr "Läuft das Secret ab, scheitern Anmeldungen mit „Die Anmeldung über Microsoft konnte nicht abgeschlossen werden“. Dann ein neues Secret erstellen, hier einfügen, speichern und das alte in Entra ID löschen." -#: includes/class-m365-login-admin.php:492 +#: includes/class-m365-login-admin.php:892 msgid "Expired" msgstr "Abgelaufen" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:496 +#: includes/class-m365-login-admin.php:896 msgid "Expires in %d days" msgstr "Läuft in %d Tagen ab" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:499 +#: includes/class-m365-login-admin.php:899 msgid "Valid" msgstr "Gültig" -#: includes/class-m365-login-admin.php:504 +#: includes/class-m365-login-admin.php:904 msgid "Thumbprint (SHA-1)" msgstr "Fingerabdruck (SHA-1)" -#: includes/class-m365-login-admin.php:506 +#: includes/class-m365-login-admin.php:906 msgid "Subject" msgstr "Antragsteller" -#: includes/class-m365-login-admin.php:508 +#: includes/class-m365-login-admin.php:908 msgid "Key size" msgstr "Schlüssellänge" -#: includes/class-m365-login-admin.php:510 +#: includes/class-m365-login-admin.php:910 msgid "Valid until" msgstr "Gültig bis" -#: includes/class-m365-login-admin.php:514 +#: includes/class-m365-login-admin.php:914 msgid "Download certificate (.cer)" msgstr "Zertifikat herunterladen (.cer)" -#: includes/class-m365-login-admin.php:515 +#: includes/class-m365-login-admin.php:915 msgid "Generate new certificate" msgstr "Neues Zertifikat erzeugen" -#: includes/class-m365-login-admin.php:518 +#: includes/class-m365-login-admin.php:918 msgid "Remove certificate when saving" msgstr "Zertifikat beim Speichern entfernen" -#: includes/class-m365-login-admin.php:522 +#: includes/class-m365-login-admin.php:922 msgid "No certificate stored yet." msgstr "Noch kein Zertifikat gespeichert." -#: includes/class-m365-login-admin.php:524 +#: includes/class-m365-login-admin.php:924 msgid "Generate certificate" msgstr "Zertifikat erzeugen" -#: includes/class-m365-login-admin.php:525 +#: includes/class-m365-login-admin.php:925 msgid "3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable." msgstr "3072 Bit RSA, selbstsigniert, 2 Jahre gültig. Der private Schlüssel wird verschlüsselt gespeichert und nie angezeigt oder zum Download angeboten." -#: includes/class-m365-login-admin.php:529 +#: includes/class-m365-login-admin.php:929 msgid "Use your own certificate instead (paste PEM)" msgstr "Stattdessen eigenes Zertifikat verwenden (PEM einfügen)" -#: includes/class-m365-login-admin.php:532 +#: includes/class-m365-login-admin.php:932 msgid "Private key (PEM, unencrypted)" msgstr "Privater Schlüssel (PEM, unverschlüsselt)" -#: includes/class-m365-login-admin.php:536 +#: includes/class-m365-login-admin.php:936 msgid "Certificate (PEM)" msgstr "Zertifikat (PEM)" -#: includes/class-m365-login-admin.php:538 +#: includes/class-m365-login-admin.php:938 msgid "RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards." msgstr "RSA, mindestens 2048 Bit. Beim Speichern wird das Paar geprüft und der Schlüssel verschlüsselt. Beide Felder bleiben danach leer." -#: includes/class-m365-login-admin.php:544 +#: includes/class-m365-login-admin.php:944 msgid "Step-by-step: register the certificate in Entra ID" msgstr "Schritt für Schritt: Zertifikat in Entra ID hinterlegen" -#: includes/class-m365-login-admin.php:547 +#: includes/class-m365-login-admin.php:947 msgid "Click Generate certificate above (or paste your own). Then click Download certificate (.cer) – the file contains only the public part." msgstr "Oben auf „Zertifikat erzeugen“ klicken (oder ein eigenes einfügen). Danach „Zertifikat herunterladen (.cer)“ – die Datei enthält nur den öffentlichen Teil." -#: includes/class-m365-login-admin.php:548 +#: includes/class-m365-login-admin.php:948 msgid "Open entra.microsoft.com → Identity → Applications → App registrations and open your app." msgstr "entra.microsoft.com → Identität → Anwendungen → App-Registrierungen öffnen und die App auswählen." -#: includes/class-m365-login-admin.php:549 +#: includes/class-m365-login-admin.php:949 msgid "Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate." msgstr "Im linken Menü „Zertifikate & Geheimnisse“ wählen, dann den Reiter „Zertifikate“ und auf „Zertifikat hochladen“ klicken." -#: includes/class-m365-login-admin.php:550 +#: includes/class-m365-login-admin.php:950 msgid "Select the downloaded .cer file, add a description such as \"WordPress login\" and click Add." msgstr "Die heruntergeladene .cer-Datei auswählen, eine Beschreibung wie „WordPress Login“ eingeben und auf „Hinzufügen“ klicken." -#: includes/class-m365-login-admin.php:551 +#: includes/class-m365-login-admin.php:951 msgid "Compare the thumbprint Entra ID shows with the thumbprint above – they must match exactly." msgstr "Den in Entra ID angezeigten Fingerabdruck mit dem Fingerabdruck oben vergleichen – beide müssen exakt übereinstimmen." -#: includes/class-m365-login-admin.php:552 +#: includes/class-m365-login-admin.php:952 msgid "Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now." msgstr "Sicherstellen, dass oben „Zertifikat“ ausgewählt ist, und diese Seite speichern. War vorher ein Client Secret gespeichert, kann es jetzt in Entra ID gelöscht werden." -#: includes/class-m365-login-admin.php:554 +#: includes/class-m365-login-admin.php:954 msgid "How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted." msgstr "So funktioniert es: Für jede Token-Anfrage signiert WordPress ein kurzlebiges JWT (Client Assertion) mit dem privaten Schlüssel; Microsoft prüft es mit dem hochgeladenen Zertifikat. Es wird nie ein Geheimnis übertragen." -#: includes/class-m365-login-admin.php:555 +#: includes/class-m365-login-admin.php:955 msgid "Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch." msgstr "Vor Ablauf des Zertifikats: hier ein neues erzeugen, in Entra ID hochladen (beide dürfen gleichzeitig hinterlegt sein), speichern und danach das alte in Entra ID entfernen. Anmeldungen funktionieren während des Wechsels weiter." -#: includes/class-m365-login-admin.php:561 +#: includes/class-m365-login-admin.php:961 msgid "Account prompt" msgstr "Kontoauswahl" -#: includes/class-m365-login-admin.php:563 +#: includes/class-m365-login-admin.php:963 msgid "Always let the user pick an account (recommended)" msgstr "Benutzer wählt immer ein Konto aus (empfohlen)" -#: includes/class-m365-login-admin.php:564 +#: includes/class-m365-login-admin.php:964 msgid "Use the current Microsoft session if available" msgstr "Vorhandene Microsoft-Sitzung verwenden, falls vorhanden" -#: includes/class-m365-login-admin.php:565 +#: includes/class-m365-login-admin.php:965 msgid "Always require re-entering credentials" msgstr "Immer erneute Eingabe der Anmeldedaten verlangen" -#: includes/class-m365-login-admin.php:574 +#: includes/class-m365-login-admin.php:974 msgid "Appearance" msgstr "Darstellung" -#: includes/class-m365-login-admin.php:577 +#: includes/class-m365-login-admin.php:977 msgid "Live preview" msgstr "Live-Vorschau" -#: includes/class-m365-login-admin.php:591 +#: includes/class-m365-login-admin.php:991 msgid "Button text" msgstr "Button-Text" -#: includes/class-m365-login-admin.php:595 +#: includes/class-m365-login-admin.php:995 msgid "Divider text" msgstr "Trennlinien-Text" -#: includes/class-m365-login-admin.php:597 +#: includes/class-m365-login-admin.php:997 msgid "Leave empty to hide the divider line." msgstr "Leer lassen, um die Trennlinie auszublenden." -#: includes/class-m365-login-admin.php:602 +#: includes/class-m365-login-admin.php:1002 msgid "Icon" msgstr "Icon" -#: includes/class-m365-login-admin.php:605 +#: includes/class-m365-login-admin.php:1005 msgid "Show an icon on the button" msgstr "Icon auf dem Button anzeigen" -#: includes/class-m365-login-admin.php:616 +#: includes/class-m365-login-admin.php:1016 msgid "Default: Microsoft logo" msgstr "Standard: Microsoft-Logo" -#: includes/class-m365-login-admin.php:618 +#: includes/class-m365-login-admin.php:1018 msgid "Choose from media library" msgstr "Aus Mediathek wählen" -#: includes/class-m365-login-admin.php:619 +#: includes/class-m365-login-admin.php:1019 msgid "Use Microsoft logo" msgstr "Microsoft-Logo verwenden" -#: includes/class-m365-login-admin.php:621 +#: includes/class-m365-login-admin.php:1021 msgid "PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best." msgstr "PNG, SVG, JPG oder WebP. Quadratische Bilder (z. B. 64×64 px) eignen sich am besten." -#: includes/class-m365-login-admin.php:629 +#: includes/class-m365-login-admin.php:1029 msgid "Background" msgstr "Hintergrund" -#: includes/class-m365-login-admin.php:630 +#: includes/class-m365-login-admin.php:1030 msgid "Background (hover)" msgstr "Hintergrund (Hover)" -#: includes/class-m365-login-admin.php:631 +#: includes/class-m365-login-admin.php:1031 msgid "Text colour" msgstr "Textfarbe" -#: includes/class-m365-login-admin.php:632 +#: includes/class-m365-login-admin.php:1032 msgid "Border" msgstr "Rahmen" -#: includes/class-m365-login-admin.php:645 +#: includes/class-m365-login-admin.php:1045 msgid "Corner radius" msgstr "Eckenradius" -#: includes/class-m365-login-admin.php:649 +#: includes/class-m365-login-admin.php:1049 msgid "Position on the login page" msgstr "Position auf der Login-Seite" -#: includes/class-m365-login-admin.php:651 +#: includes/class-m365-login-admin.php:1051 msgid "Below the login form" msgstr "Unter dem Login-Formular" -#: includes/class-m365-login-admin.php:652 +#: includes/class-m365-login-admin.php:1052 msgid "Above the login form" msgstr "Über dem Login-Formular" -#: includes/class-m365-login-admin.php:658 +#: includes/class-m365-login-admin.php:1058 msgid "Quick presets" msgstr "Schnellauswahl" -#: includes/class-m365-login-admin.php:659 +#: includes/class-m365-login-admin.php:1059 msgid "Microsoft dark" msgstr "Microsoft dunkel" -#: includes/class-m365-login-admin.php:660 +#: includes/class-m365-login-admin.php:1060 msgid "Microsoft light" msgstr "Microsoft hell" -#: includes/class-m365-login-admin.php:661 +#: includes/class-m365-login-admin.php:1061 msgid "Azure blue" msgstr "Azure-Blau" -#: includes/class-m365-login-admin.php:662 +#: includes/class-m365-login-admin.php:1062 msgid "WordPress blue" msgstr "WordPress-Blau" -#: includes/class-m365-login-admin.php:666 +#: includes/class-m365-login-admin.php:1066 msgid "Custom login page" msgstr "Eigene Login-Seite" -#: includes/class-m365-login-admin.php:667 +#: includes/class-m365-login-admin.php:1067 msgid "Using your own login page instead of wp-login.php? Tell the plugin where it is so error messages, the fallback link and the post-logout redirect point there." msgstr "Eigene Login-Seite statt wp-login.php? Hier eintragen, damit Fehlermeldungen, der Fallback-Link und die Weiterleitung nach dem Abmelden dorthin zeigen." -#: includes/class-m365-login-admin.php:670 +#: includes/class-m365-login-admin.php:1070 msgid "URL of your login page" msgstr "URL der Login-Seite" -#: includes/class-m365-login-admin.php:672 +#: includes/class-m365-login-admin.php:1072 msgid "Must be on this site. Leave empty to use wp-login.php." msgstr "Muss auf dieser Website liegen. Leer lassen, um wp-login.php zu verwenden." -#: includes/class-m365-login-admin.php:678 +#: includes/class-m365-login-admin.php:1078 msgid "Add the button to every wp_login_form() form automatically" msgstr "Button automatisch in jedes wp_login_form()-Formular einfügen" -#: includes/class-m365-login-admin.php:679 +#: includes/class-m365-login-admin.php:1079 msgid "Covers themes and plugins that use the WordPress login form function. Page-builder widgets need the shortcode or the template function below." msgstr "Deckt Themes und Plugins ab, die die WordPress-Login-Formularfunktion verwenden. Page-Builder-Widgets benötigen den Shortcode oder die Template-Funktion unten." -#: includes/class-m365-login-admin.php:684 +#: includes/class-m365-login-admin.php:1084 msgid "Manual placement" msgstr "Manuelle Platzierung" -#: includes/class-m365-login-admin.php:685 +#: includes/class-m365-login-admin.php:1085 msgid "Shortcode (block editor, page builders):" msgstr "Shortcode (Block-Editor, Page Builder):" -#: includes/class-m365-login-admin.php:687 +#: includes/class-m365-login-admin.php:1087 msgid "Template function (theme files):" msgstr "Template-Funktion (Theme-Dateien):" -#: includes/class-m365-login-admin.php:689 +#: includes/class-m365-login-admin.php:1089 msgid "Both show the error messages of the last attempt; use m365_login_messages() to place them separately." msgstr "Beide zeigen die Fehlermeldungen des letzten Versuchs; mit m365_login_messages() lassen sie sich separat platzieren." -#: includes/class-m365-login-admin.php:697 +#: includes/class-m365-login-admin.php:1097 msgid "User matching & hardening" msgstr "Benutzerzuordnung & Härtung" -#: includes/class-m365-login-admin.php:698 -msgid "Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists." -msgstr "Benutzer werden nie automatisch angelegt. Eine Microsoft-Anmeldung gelingt nur, wenn bereits ein WordPress-Benutzer mit derselben E-Mail-Adresse existiert." +#: includes/class-m365-login-admin.php:1098 +msgid "Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync." +msgstr "Die Anmeldung legt nie Benutzer an. Eine Microsoft-Anmeldung klappt nur, wenn bereits ein WordPress-Benutzer mit derselben E-Mail-Adresse existiert – von Hand angelegt oder vom Benutzer-Sync importiert." -#: includes/class-m365-login-admin.php:703 +#: includes/class-m365-login-admin.php:1103 msgid "Bind WordPress accounts to the Microsoft object ID" msgstr "WordPress-Konten an die Microsoft-Objekt-ID binden" -#: includes/class-m365-login-admin.php:704 +#: includes/class-m365-login-admin.php:1104 msgid "On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended." msgstr "Bei der ersten Anmeldung wird die unveränderliche Microsoft-Objekt-ID am Benutzer gespeichert. Spätere Anmeldungen mit gleicher E-Mail, aber anderer Microsoft-Identität werden abgelehnt. Dringend empfohlen." -#: includes/class-m365-login-admin.php:711 +#: includes/class-m365-login-admin.php:1111 msgid "Fall back to the user principal name (UPN)" msgstr "Auf den User Principal Name (UPN) zurückgreifen" -#: includes/class-m365-login-admin.php:712 +#: includes/class-m365-login-admin.php:1112 msgid "If the token contains no \"email\" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts." msgstr "Enthält das Token keinen „email“-Claim, wird der UPN (z. B. jane@contoso.com) verwendet, sofern er eine gültige E-Mail-Adresse ist. Für Geschäftskonten meist erforderlich." -#: includes/class-m365-login-admin.php:719 +#: includes/class-m365-login-admin.php:1119 msgid "Keep users signed in (\"Remember me\")" msgstr "Benutzer angemeldet lassen („Angemeldet bleiben“)" -#: includes/class-m365-login-admin.php:720 +#: includes/class-m365-login-admin.php:1120 msgid "Issues a 14-day WordPress session instead of a browser session." msgstr "Erstellt eine 14-tägige WordPress-Sitzung statt einer Browser-Sitzung." -#: includes/class-m365-login-admin.php:725 +#: includes/class-m365-login-admin.php:1125 msgid "Allowed e-mail domains (optional)" msgstr "Erlaubte E-Mail-Domains (optional)" -#: includes/class-m365-login-admin.php:727 +#: includes/class-m365-login-admin.php:1127 msgid "One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant." msgstr "Eine oder mehrere Domains, getrennt durch Kommas oder Zeilenumbrüche. Leer lassen, um alle Domains des Tenants zuzulassen." -#: includes/class-m365-login-admin.php:732 +#: includes/class-m365-login-admin.php:1132 msgid "Allowed Entra groups (optional)" msgstr "Erlaubte Entra-Gruppen (optional)" -#: includes/class-m365-login-admin.php:733 +#: includes/class-m365-login-admin.php:1133 msgid "Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count." msgstr "Nur Mitglieder mindestens einer dieser Gruppen dürfen sich anmelden. Leer lassen, um alle zugeordneten Benutzer zuzulassen. Verschachtelte Mitgliedschaften zählen." -#: includes/class-m365-login-admin.php:736 -msgid "Search groups" -msgstr "Gruppen suchen" - -#: includes/class-m365-login-admin.php:738 -msgid "Type a group name or paste an object ID…" -msgstr "Gruppenname eingeben oder Objekt-ID einfügen …" - -#: includes/class-m365-login-admin.php:739 -msgid "Search" -msgstr "Suchen" - -#: includes/class-m365-login-admin.php:744 -msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." -msgstr "Benötigt die Anwendungsberechtigung „GroupMember.Read.All“ mit Administratorzustimmung. Ohne sie können Gruppen-Objekt-IDs trotzdem eingefügt werden." - -#: includes/class-m365-login-admin.php:750 -msgid "Selected groups" -msgstr "Ausgewählte Gruppen" - -#: includes/class-m365-login-admin.php:751 +#: includes/class-m365-login-admin.php:1135 msgid "No groups selected – every matched user may sign in." msgstr "Keine Gruppen ausgewählt – jeder zugeordnete Benutzer darf sich anmelden." -#: includes/class-m365-login-admin.php:761 +#: includes/class-m365-login-admin.php:1137 msgid "Membership is read from the \"groups\" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission \"User.Read.All\" or \"Directory.Read.All\"). If neither works, the sign-in is refused." msgstr "Die Mitgliedschaft wird aus dem „groups“-Claim des ID-Tokens gelesen, falls vorhanden; andernfalls fragt das Plugin Microsoft Graph (Anwendungsberechtigung „User.Read.All“ oder „Directory.Read.All“). Funktioniert beides nicht, wird die Anmeldung abgelehnt." -#: includes/class-m365-login-admin.php:766 +#: includes/class-m365-login-admin.php:1142 +msgid "Excluded Entra groups (optional)" +msgstr "Ausgeschlossene Entra-Gruppen (optional)" + +#: includes/class-m365-login-admin.php:1143 +msgid "Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count." +msgstr "Mitglieder dieser Gruppen können sich nie per Microsoft anmelden – auch nicht, wenn sie in einer erlaubten Gruppe sind. Verschachtelte Mitgliedschaften zählen." + +#: includes/class-m365-login-admin.php:1146 +msgid "Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above." +msgstr "Gruppenregeln brauchen eine feste Tenant-ID (GUID) im Tab „Verbindung“. Im Multi-Tenant-Modus kann die Gruppenprüfung Microsoft Graph nicht fragen, deshalb wird jede Anmeldung abgelehnt, solange hier oder oben Gruppen ausgewählt sind." + +#: includes/class-m365-login-admin.php:1148 +msgid "No groups excluded." +msgstr "Keine Gruppen ausgeschlossen." + +#: includes/class-m365-login-admin.php:1150 +msgid "The plugin asks Microsoft Graph on every sign-in (application permission \"User.Read.All\" or \"Directory.Read.All\"), because a \"groups\" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed." +msgstr "Das Plugin fragt bei jeder Anmeldung Microsoft Graph (Anwendungsberechtigung „User.Read.All“ oder „Directory.Read.All“), weil ein „groups“-Claim gefiltert sein kann und nicht beweist, dass jemand kein Mitglied ist. Schlägt die Prüfung fehl, wird die Anmeldung abgelehnt. Die Passwort-Anmeldung ist nicht betroffen – bei Bedarf mit dem Nur-Button-Modus kombinieren." + +#: includes/class-m365-login-admin.php:1155 msgid "Button-only mode" msgstr "Nur-Button-Modus" -#: includes/class-m365-login-admin.php:767 -msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected." -msgstr "Blendet die Benutzername/Passwort-Felder aus (auf wp-login.php und in wp_login_form()-Formularen) und lehnt jede interaktive Passwort-Anmeldung auf der Website ab, auch in eigenen Login-Formularen. Anwendungspasswörter, REST, XML-RPC und WP-CLI sind nicht betroffen." +#: includes/class-m365-login-admin.php:1156 +msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site – custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie." +msgstr "Blendet die Felder für Benutzername/Passwort aus (auf wp-login.php und in wp_login_form()-Formularen) und lehnt jede Anmeldung mit einem normalen Passwort auf der Website ab – auch in eigenen Login-Formularen, über XML-RPC und über Login-Endpunkte anderer Plugins. Anwendungspasswörter (REST, XML-RPC) und WP-CLI funktionieren weiter; API-Anfragen erhalten nie ein Login-Cookie." -#: includes/class-m365-login-admin.php:772 +#: includes/class-m365-login-admin.php:1161 msgid "Show only the Microsoft button on the login page" msgstr "Auf der Login-Seite nur den Microsoft-Button anzeigen" -#: includes/class-m365-login-admin.php:773 +#: includes/class-m365-login-admin.php:1162 msgid "Becomes active once the connection is configured. Make sure your own account can sign in via Microsoft before enabling this." msgstr "Wird aktiv, sobald die Verbindung eingerichtet ist. Vor dem Aktivieren sicherstellen, dass das eigene Konto sich per Microsoft anmelden kann." -#: includes/class-m365-login-admin.php:778 +#: includes/class-m365-login-admin.php:1167 msgid "Fallback link (keep it secret)" msgstr "Fallback-Link (geheim halten)" -#: includes/class-m365-login-admin.php:779 +#: includes/class-m365-login-admin.php:1168 msgid "Opening this link shows the password form again in that browser for 30 minutes and allows password sign-in there. Bookmark it somewhere safe – it is your way back in if Microsoft sign-in ever breaks." msgstr "Wer diesen Link öffnet, sieht in diesem Browser 30 Minuten lang wieder das Passwort-Formular und kann sich dort mit Passwort anmelden. Sicher aufbewahren – er ist der Weg zurück, falls die Microsoft-Anmeldung einmal nicht funktioniert." -#: includes/class-m365-login-admin.php:787 +#: includes/class-m365-login-admin.php:1176 msgid "Generate a new key when saving" msgstr "Beim Speichern einen neuen Schlüssel erzeugen" -#: includes/class-m365-login-admin.php:790 +#: includes/class-m365-login-admin.php:1179 msgid "A key is generated automatically the first time you save these settings." msgstr "Beim ersten Speichern dieser Einstellungen wird automatisch ein Schlüssel erzeugt." #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:796 +#: includes/class-m365-login-admin.php:1185 msgid "Emergency switch: add %s to wp-config.php to disable button-only mode entirely." msgstr "Notschalter: %s in die wp-config.php eintragen, um den Nur-Button-Modus vollständig abzuschalten." #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:805 +#: includes/class-m365-login-admin.php:1194 msgid "What the plugin does to keep sign-ins safe" msgstr "So schützt das Plugin die Anmeldung" -#: includes/class-m365-login-admin.php:807 +#: includes/class-m365-login-admin.php:1196 msgid "OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser." msgstr "OpenID Connect Authorization Code Flow mit PKCE (S256) – Tokens laufen nie durch den Browser." -#: includes/class-m365-login-admin.php:808 +#: includes/class-m365-login-admin.php:1197 msgid "Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection)." msgstr "Einmalige State- und Nonce-Werte, per HttpOnly-Cookie an den Browser gebunden (CSRF- und Replay-Schutz)." -#: includes/class-m365-login-admin.php:809 +#: includes/class-m365-login-admin.php:1198 msgid "ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked." msgstr "Signatur des ID-Tokens wird gegen Microsofts veröffentlichte Signaturschlüssel geprüft; Issuer, Audience, Tenant, Ablauf und Nonce werden kontrolliert." -#: includes/class-m365-login-admin.php:810 -msgid "Client secret encrypted at rest; no accounts are created, no passwords are changed." -msgstr "Client Secret verschlüsselt gespeichert; es werden keine Konten angelegt und keine Passwörter geändert." +#: includes/class-m365-login-admin.php:1199 +msgid "Client secret encrypted at rest; sign-in never creates accounts or changes passwords." +msgstr "Client Secret verschlüsselt gespeichert; die Anmeldung legt nie Konten an und ändert keine Passwörter." -#: includes/class-m365-login-admin.php:816 +#: includes/class-m365-login-admin.php:1207 msgid "Save changes" msgstr "Änderungen speichern" -#: includes/class-m365-login-admin.php:822 +#: includes/class-m365-login-admin.php:1213 msgid "Redirect URI" msgstr "Umleitungs-URI (Redirect URI)" -#: includes/class-m365-login-admin.php:823 +#: includes/class-m365-login-admin.php:1214 msgid "Register this URI in your app registration under Authentication → Web → Redirect URIs:" msgstr "Diese URI in der App-Registrierung unter Authentifizierung → Web → Umleitungs-URIs eintragen:" -#: includes/class-m365-login-admin.php:829 +#: includes/class-m365-login-admin.php:1220 msgid "Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID." msgstr "Einfache Permalinks sind aktiv, daher verwendet der Callback einen Query-String. Werden später sprechende Permalinks aktiviert, ändert sich die Umleitungs-URI und muss in Entra ID angepasst werden." -#: includes/class-m365-login-admin.php:832 +#: includes/class-m365-login-admin.php:1223 msgid "Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS." msgstr "Diese Website nutzt kein HTTPS. Microsoft akzeptiert http://-Umleitungs-URIs nur für localhost; produktive Websites benötigen HTTPS." -#: includes/class-m365-login-admin.php:837 +#: includes/class-m365-login-admin.php:1228 msgid "Setup guide: app registration" msgstr "Anleitung: App-Registrierung" -#: includes/class-m365-login-admin.php:839 +#: includes/class-m365-login-admin.php:1230 msgid "Open entra.microsoft.com → Identity → Applications → App registrations → New registration." msgstr "entra.microsoft.com → Identität → Anwendungen → App-Registrierungen → Neue Registrierung öffnen." -#: includes/class-m365-login-admin.php:840 +#: includes/class-m365-login-admin.php:1231 msgid "Name: e.g. \"WordPress login\". Supported account types: \"Accounts in this organizational directory only\" (single tenant)." msgstr "Name: z. B. „WordPress Login“. Unterstützte Kontotypen: „Nur Konten in diesem Organisationsverzeichnis“ (Single Tenant)." -#: includes/class-m365-login-admin.php:841 +#: includes/class-m365-login-admin.php:1232 msgid "Redirect URI: choose the platform Web and paste the URI shown above. Then click Register." msgstr "Umleitungs-URI: Plattform „Web“ wählen und die oben angezeigte URI einfügen. Dann auf „Registrieren“ klicken." -#: includes/class-m365-login-admin.php:842 +#: includes/class-m365-login-admin.php:1233 msgid "On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab." msgstr "Auf der Übersichtsseite die Anwendungs-ID (Client) und die Verzeichnis-ID (Mandant) in den Tab „Verbindung“ kopieren." -#: includes/class-m365-login-admin.php:843 +#: includes/class-m365-login-admin.php:1234 msgid "Authentication: leave \"ID tokens\" unchecked (the plugin uses the authorization code flow) and \"Allow public client flows\" on No." msgstr "Authentifizierung: „ID-Token“ nicht anhaken (das Plugin nutzt den Authorization Code Flow) und „Öffentliche Clientflows zulassen“ auf „Nein“ lassen." -#: includes/class-m365-login-admin.php:844 +#: includes/class-m365-login-admin.php:1235 msgid "Token configuration → Add optional claim → ID → tick \"email\" → Add. Confirm the API permission prompt." msgstr "Tokenkonfiguration → Optionalen Anspruch hinzufügen → ID → „email“ anhaken → Hinzufügen. Die Rückfrage zur API-Berechtigung bestätigen." -#: includes/class-m365-login-admin.php:845 +#: includes/class-m365-login-admin.php:1236 msgid "Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate)." msgstr "Im Tab „Verbindung“ die Authentifizierungsmethode wählen und der zugehörigen Schritt-für-Schritt-Anleitung folgen (Client Secret oder Zertifikat)." -#: includes/class-m365-login-admin.php:846 +#: includes/class-m365-login-admin.php:1237 msgid "Optional: restrict who may use the app under Enterprise applications → your app → Properties → \"Assignment required\" = Yes, then assign users/groups." msgstr "Optional: Unter Unternehmensanwendungen → deine App → Eigenschaften → „Zuweisung erforderlich“ = Ja einschränken, wer die App nutzen darf, und dann Benutzer/Gruppen zuweisen." -#: includes/class-m365-login-admin.php:848 +#: includes/class-m365-login-admin.php:1239 msgid "Required API permission: openid, profile, email (delegated) – granted by default." msgstr "Benötigte API-Berechtigungen: openid, profile, email (delegiert) – standardmäßig vorhanden." -#: includes/class-m365-login-admin.php:849 -msgid "Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." -msgstr "Optional für Gruppen-Beschränkungen: Anwendungsberechtigungen GroupMember.Read.All und User.Read.All (Microsoft Graph) mit Administratorzustimmung." +#: includes/class-m365-login-admin.php:1240 +msgid "Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." +msgstr "Optional für Gruppen-Beschränkungen und den Benutzer-Sync: Anwendungsberechtigungen GroupMember.Read.All und User.Read.All (Microsoft Graph) mit Administratorzustimmung." -#: includes/class-m365-login-admin.php:853 +#: includes/class-m365-login-admin.php:1244 msgid "Shortcode" msgstr "Shortcode" -#: includes/class-m365-login-admin.php:854 +#: includes/class-m365-login-admin.php:1245 msgid "Place the button on a custom login page:" msgstr "Button auf einer eigenen Login-Seite platzieren:" -#: includes/class-m365-login-admin.php:856 +#: includes/class-m365-login-admin.php:1247 msgid "More options on the Button tab under \"Custom login page\"." msgstr "Weitere Optionen im Tab „Button“ unter „Eigene Login-Seite“." -#: includes/class-m365-login-auth.php:173 +#: includes/class-m365-login-auth.php:203 msgid "Password sign-in is disabled on this site. Please use the Microsoft button." msgstr "Die Anmeldung mit Passwort ist auf dieser Website deaktiviert. Bitte den Microsoft-Button verwenden." -#: includes/class-m365-login-auth.php:823 +#: includes/class-m365-login-auth.php:235 includes/class-m365-login-auth.php:1336 +msgid "Passwords are not used on this site. Please sign in with the Microsoft button." +msgstr "Auf dieser Website werden keine Passwörter verwendet. Bitte melde dich mit dem Microsoft-Button an." + +#: includes/class-m365-login-auth.php:1286 +msgid "Microsoft sign-in is temporarily unavailable. Please contact an administrator." +msgstr "Die Microsoft-Anmeldung ist vorübergehend nicht verfügbar. Bitte wende dich an einen Administrator." + +#: includes/class-m365-login-auth.php:1293 msgid "Password sign-in is temporarily enabled for this browser (30 minutes)." msgstr "Die Passwort-Anmeldung ist für diesen Browser vorübergehend aktiviert (30 Minuten)." -#: includes/class-m365-login-auth.php:844 includes/class-m365-login-graph.php:63 +#: includes/class-m365-login-auth.php:1314 includes/class-m365-login-graph.php:63 msgid "Microsoft login is not configured yet." msgstr "Die Microsoft-Anmeldung ist noch nicht eingerichtet." -#: includes/class-m365-login-auth.php:845 +#: includes/class-m365-login-auth.php:1315 msgid "The login request expired or was invalid. Please try again." msgstr "Die Anmeldeanfrage ist abgelaufen oder ungültig. Bitte erneut versuchen." -#: includes/class-m365-login-auth.php:846 +#: includes/class-m365-login-auth.php:1316 msgid "Microsoft sign-in was cancelled." msgstr "Die Microsoft-Anmeldung wurde abgebrochen." -#: includes/class-m365-login-auth.php:847 +#: includes/class-m365-login-auth.php:1317 msgid "Microsoft returned an error. Please try again." msgstr "Microsoft hat einen Fehler gemeldet. Bitte erneut versuchen." -#: includes/class-m365-login-auth.php:848 +#: includes/class-m365-login-auth.php:1318 msgid "Could not complete the sign-in with Microsoft. Please try again or contact an administrator." msgstr "Die Anmeldung über Microsoft konnte nicht abgeschlossen werden. Bitte erneut versuchen oder einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:849 +#: includes/class-m365-login-auth.php:1319 msgid "The Microsoft sign-in could not be verified." msgstr "Die Microsoft-Anmeldung konnte nicht verifiziert werden." -#: includes/class-m365-login-auth.php:850 +#: includes/class-m365-login-auth.php:1320 msgid "Your Microsoft account did not provide an e-mail address." msgstr "Das Microsoft-Konto hat keine E-Mail-Adresse übermittelt." -#: includes/class-m365-login-auth.php:851 +#: includes/class-m365-login-auth.php:1321 msgid "Your e-mail domain is not allowed to sign in here." msgstr "Diese E-Mail-Domain ist hier nicht zur Anmeldung zugelassen." -#: includes/class-m365-login-auth.php:852 +#: includes/class-m365-login-auth.php:1322 msgid "No WordPress account exists for your Microsoft e-mail address." msgstr "Für die E-Mail-Adresse des Microsoft-Kontos existiert kein WordPress-Konto." -#: includes/class-m365-login-auth.php:853 +#: includes/class-m365-login-auth.php:1323 msgid "This WordPress account is linked to a different Microsoft account. Please contact an administrator." msgstr "Dieses WordPress-Konto ist mit einem anderen Microsoft-Konto verknüpft. Bitte einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:854 +#: includes/class-m365-login-auth.php:1324 msgid "You are not allowed to sign in with this account." msgstr "Die Anmeldung mit diesem Konto ist nicht erlaubt." -#: includes/class-m365-login-auth.php:855 +#: includes/class-m365-login-auth.php:1325 msgid "Your Microsoft account is not a member of a group that is allowed to sign in here." msgstr "Das Microsoft-Konto ist in keiner Gruppe, die sich hier anmelden darf." -#: includes/class-m365-login-auth.php:856 +#: includes/class-m365-login-auth.php:1326 msgid "Your group membership could not be verified. Please contact an administrator." msgstr "Die Gruppenmitgliedschaft konnte nicht geprüft werden. Bitte einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:857 +#: includes/class-m365-login-auth.php:1327 +msgid "Your Microsoft account is a member of a group that is not allowed to sign in here." +msgstr "Dein Microsoft-Konto ist Mitglied einer Gruppe, die sich hier nicht anmelden darf." + +#: includes/class-m365-login-auth.php:1328 msgid "The fallback key is not valid." msgstr "Der Fallback-Schlüssel ist ungültig." -#: includes/class-m365-login-auth.php:858 +#: includes/class-m365-login-auth.php:1329 msgid "Too many attempts. Please wait 15 minutes." msgstr "Zu viele Versuche. Bitte 15 Minuten warten." -#: includes/class-m365-login-auth.php:859 +#: includes/class-m365-login-auth.php:1330 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:1331 includes/class-m365-login-sync.php:2138 +msgid "This account has been deactivated." +msgstr "Dieses Konto wurde deaktiviert." + +#: includes/class-m365-login-auth.php:1332 +msgid "For security reasons this administrator account is not linked automatically. Sign in once with your password and click \"Link Microsoft account\" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile." +msgstr "Aus Sicherheitsgründen wird dieses Administrator-Konto nicht automatisch verknüpft. Melde dich einmal mit deinem Passwort an und klicke in deinem Profil auf „Mit Microsoft-Konto verknüpfen“ – oder bitte einen Administrator, dein Microsoft-Konto (Benutzerprinzipalname) in deinem WordPress-Profil einzutragen." + +#: includes/class-m365-login-auth.php:1333 +msgid "The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again." +msgstr "Die Verknüpfung konnte nicht abgeschlossen werden, weil du nicht mehr bei WordPress angemeldet bist. Bitte melde dich an und versuche es erneut." + +#: includes/class-m365-login-auth.php:1334 +msgid "Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile." +msgstr "Dein WordPress-Konto ist bereits mit einem anderen Microsoft-Konto verknüpft. Ein Administrator kann die Verknüpfung in deinem Profil aufheben." + +#: includes/class-m365-login-auth.php:1335 +msgid "Guest and external accounts cannot sign in here." +msgstr "Gast- und externe Konten können sich hier nicht anmelden." + #: includes/class-m365-login-certificate.php:29 msgid "The PHP OpenSSL extension is not available." msgstr "Die PHP-Erweiterung OpenSSL ist nicht verfügbar." @@ -834,87 +1223,489 @@ msgstr "Es werden nur RSA-Schlüssel unterstützt." msgid "The RSA key must have at least 2048 bits." msgstr "Der RSA-Schlüssel muss mindestens 2048 Bit haben." -#: includes/class-m365-login-certificate.php:102 +#: includes/class-m365-login-certificate.php:101 +msgid "The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there." +msgstr "Das Zertifikatsfeld enthält einen privaten Schlüssel. Füge dort nur das Zertifikat ein (-----BEGIN CERTIFICATE-----)." + +#: includes/class-m365-login-certificate.php:105 includes/class-m365-login-certificate.php:119 msgid "The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----)." msgstr "Das Zertifikat konnte nicht gelesen werden. Bitte im PEM-Format einfügen (-----BEGIN CERTIFICATE-----)." -#: includes/class-m365-login-certificate.php:105 +#: includes/class-m365-login-certificate.php:108 msgid "The certificate does not belong to this private key." msgstr "Das Zertifikat gehört nicht zu diesem privaten Schlüssel." -#: includes/class-m365-login-certificate.php:110 +#: includes/class-m365-login-certificate.php:113 msgid "The certificate has already expired." msgstr "Das Zertifikat ist bereits abgelaufen." -#: includes/class-m365-login-graph.php:201 +#: includes/class-m365-login-graph.php:440 msgid "Group" msgstr "Gruppe" -#: includes/class-m365-login-graph.php:203 +#: includes/class-m365-login-graph.php:442 msgid "Security group" msgstr "Sicherheitsgruppe" -#: includes/class-m365-login-graph.php:205 +#: includes/class-m365-login-graph.php:444 msgid "Microsoft 365 group" msgstr "Microsoft 365-Gruppe" -#: includes/class-m365-login-settings.php:47 +#: includes/class-m365-login-graph.php:448 +msgid "Public Microsoft 365 group – anyone in the organisation can join" +msgstr "Öffentliche Microsoft-365-Gruppe – jeder in der Organisation kann beitreten" + +#: includes/class-m365-login-settings.php:55 msgid "Sign in with Microsoft" msgstr "Login mit Microsoft" -#: includes/class-m365-login-settings.php:56 +#: includes/class-m365-login-settings.php:64 msgid "or" msgstr "oder" -#: includes/class-m365-login-settings.php:197 includes/class-m365-login-settings.php:445 +#: includes/class-m365-login-settings.php:226 includes/class-m365-login-settings.php:559 msgid "The private key could not be encrypted. Is the OpenSSL extension available?" msgstr "Der private Schlüssel konnte nicht verschlüsselt werden. Ist die OpenSSL-Erweiterung verfügbar?" -#: includes/class-m365-login-settings.php:391 +#: includes/class-m365-login-settings.php:505 msgid "The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of \"organizations\", \"common\", \"consumers\"." msgstr "Die Tenant-ID muss eine GUID (z. B. 1a2b3c4d-…) oder einer der Werte „organizations“, „common“, „consumers“ sein." -#: includes/class-m365-login-settings.php:399 +#: includes/class-m365-login-settings.php:513 msgid "The application (client) ID must be a GUID." msgstr "Die Anwendungs-ID (Client) muss eine GUID sein." -#: includes/class-m365-login-settings.php:411 +#: includes/class-m365-login-settings.php:525 msgid "The client secret contains invalid characters." msgstr "Das Client Secret enthält ungültige Zeichen." -#: includes/class-m365-login-settings.php:415 +#: includes/class-m365-login-settings.php:529 msgid "The client secret could not be encrypted. Is the OpenSSL extension available?" msgstr "Das Client Secret konnte nicht verschlüsselt werden. Ist die OpenSSL-Erweiterung verfügbar?" -#: includes/class-m365-login-settings.php:435 +#: includes/class-m365-login-settings.php:549 msgid "Please paste both the private key and the certificate." msgstr "Bitte sowohl den privaten Schlüssel als auch das Zertifikat einfügen." -#: includes/class-m365-login-settings.php:437 +#: includes/class-m365-login-settings.php:551 msgid "The pasted key or certificate is too large." msgstr "Der eingefügte Schlüssel oder das Zertifikat ist zu groß." -#: includes/class-m365-login-settings.php:454 +#: includes/class-m365-login-settings.php:568 msgid "Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then." msgstr "Zertifikats-Authentifizierung ist ausgewählt, aber es ist noch kein Zertifikat gespeichert. Eines erzeugen oder ein eigenes einfügen; bis dahin bleibt der Microsoft-Button ausgeblendet." -#: includes/class-m365-login-settings.php:500 +#: includes/class-m365-login-settings.php:601 msgid "The custom login page must be a URL on this site." msgstr "Die eigene Login-Seite muss eine URL dieser Website sein." -#: includes/class-m365-login.php:102 +#: includes/class-m365-login-settings.php:711 +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:136 +msgid "Display name" +msgstr "Anzeigename" + +#: includes/class-m365-login-sync.php:140 +msgid "First name" +msgstr "Vorname" + +#: includes/class-m365-login-sync.php:144 +msgid "Last name" +msgstr "Nachname" + +#: includes/class-m365-login-sync.php:148 +msgid "Profile photo (used as avatar)" +msgstr "Profilbild (als Avatar)" + +#: includes/class-m365-login-sync.php:152 +msgid "Job title" +msgstr "Position" + +#: includes/class-m365-login-sync.php:156 +msgid "Department" +msgstr "Abteilung" + +#: includes/class-m365-login-sync.php:160 +msgid "Company" +msgstr "Firma" + +#: includes/class-m365-login-sync.php:164 +msgid "Office" +msgstr "Büro" + +#: includes/class-m365-login-sync.php:168 +msgid "Employee ID" +msgstr "Personalnummer" + +#: includes/class-m365-login-sync.php:172 +msgid "Business phone" +msgstr "Telefon (geschäftlich)" + +#: includes/class-m365-login-sync.php:176 +msgid "Mobile phone" +msgstr "Mobiltelefon" + +#: includes/class-m365-login-sync.php:180 +msgid "Street address" +msgstr "Straße" + +#: includes/class-m365-login-sync.php:184 +msgid "Postal code" +msgstr "Postleitzahl" + +#: includes/class-m365-login-sync.php:188 +msgid "City" +msgstr "Ort" + +#: includes/class-m365-login-sync.php:192 +msgid "State / province" +msgstr "Bundesland / Region" + +#: includes/class-m365-login-sync.php:196 +msgid "Country" +msgstr "Land" + +#: includes/class-m365-login-sync.php:200 +msgid "Language (sets the admin language if installed)" +msgstr "Sprache (setzt die Backend-Sprache, falls installiert)" + +#: includes/class-m365-login-sync.php:336 +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:418 +msgid "The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use \"wp m365-login sync\"." +msgstr "Der Sync wurde unerwartet beendet (PHP-Fehler, Speicher- oder Zeitlimit). Konten danach wurden nicht verarbeitet, nichts wurde deaktiviert oder gelöscht. Für große Verzeichnisse „wp m365-login sync“ verwenden." + +#: includes/class-m365-login-sync.php:480 +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:484 +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:488 +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:500 +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." + +#: includes/class-m365-login-sync.php:511 +msgid "Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups." +msgstr "Microsoft 365 hat überhaupt keine Benutzer geliefert, obwohl Konten verknüpft sind. Es wurde nichts geändert. Prüfe den Tenant und die Sync-Gruppen." + +#. translators: %d: number of accounts +#: includes/class-m365-login-sync.php:585 +msgid "%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed." +msgid_plural "%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed." +msgstr[0] "%d verknüpftes Konto gehört zu einem anderen (oder unbekannten) Tenant und wurde nicht deaktiviert oder gelöscht. Verknüpfung bei Bedarf von Hand aufheben." +msgstr[1] "%d verknüpfte Konten gehören zu einem anderen (oder unbekannten) Tenant und wurden nicht deaktiviert oder gelöscht. Verknüpfung bei Bedarf von Hand aufheben." + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:596 +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)." + +#: includes/class-m365-login-sync.php:722 includes/class-m365-login-sync.php:785 includes/class-m365-login-sync.php:1233 includes/class-m365-login-sync.php:2283 +msgid "disabled in Microsoft 365" +msgstr "in Microsoft 365 deaktiviert" + +#. translators: %s: user principal name +#: includes/class-m365-login-sync.php:733 +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:740 +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:761 +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:766 +msgid "%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped." +msgstr "%s: privilegiertes WordPress-Konto – wird nur verknüpft, wenn der Microsoft-Benutzerprinzipalname seiner E-Mail-Adresse oder dem im Profil zugewiesenen Microsoft-Konto entspricht, oder wenn die Person es im Profil selbst verknüpft. Übersprungen." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:770 +msgid "%s: existing account linked." +msgstr "%s: bestehendes Konto verknüpft." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:796 +msgid "%s: added to this site." +msgstr "%s: zu dieser Website hinzugefügt." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:805 +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:816 +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:843 +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:870 +msgid "%1$s: account could not be created: %2$s" +msgstr "%1$s: Konto konnte nicht angelegt werden: %2$s" + +#. translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 +#: includes/class-m365-login-sync.php:929 +msgid "%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended." +msgstr "%1$s: Die E-Mail-Adresse in Microsoft 365 wurde zu %2$s geändert. Bei privilegierten Konten wird sie nicht automatisch geändert – bei Bedarf von Hand anpassen." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:932 +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:935 +msgid "e-mail" +msgstr "E-Mail" + +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:985 +msgid "%s removed" +msgstr "%s entfernt" + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:995 +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:1104 +msgid "roles: %s" +msgstr "Rollen: %s" + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:1174 +msgid "Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit)." +msgstr "Sicherheitsstopp: %1$d Konten würden Administrationsrechte verlieren, mehr als das Limit von %2$d pro Lauf (oder alle). Es wurde nichts herabgestuft, deaktiviert oder gelöscht. Prüfe die Gruppen-Rollen-Zuordnung und starte den Sync dann erneut (Filter m365_login_sync_demotion_limit)." + +#: includes/class-m365-login-sync.php:1225 includes/class-m365-login-sync.php:2284 +msgid "deleted in Microsoft 365" +msgstr "in Microsoft 365 gelöscht" + +#: includes/class-m365-login-sync.php:1236 includes/class-m365-login-sync.php:2285 +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:1268 +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:1412 +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:1421 +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:1430 +msgid "%1$s: account deactivated (%2$s)." +msgstr "%1$s: Konto deaktiviert (%2$s)." + +#: includes/class-m365-login-sync.php:1509 +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:1512 +msgid "Microsoft Graph error: %s" +msgstr "Microsoft-Graph-Fehler: %s" + +#: includes/class-m365-login-sync.php:1530 +msgid "Log truncated." +msgstr "Protokoll gekürzt." + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:1613 +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:1637 includes/class-m365-login-sync.php:1680 +msgid "%s: profile photo updated." +msgstr "%s: Profilbild aktualisiert." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1656 +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:1663 +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: %d: number of photos +#: includes/class-m365-login-sync.php:1686 +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)." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1701 +msgid "%s: profile photo removed." +msgstr "%s: Profilbild entfernt." + +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1729 +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:1901 includes/class-m365-login-sync.php:1915 +msgid "Microsoft 365 (M365 Login)" +msgstr "Microsoft 365 (M365 Login)" + +#: includes/class-m365-login-sync.php:1933 +msgid "Microsoft object ID" +msgstr "Microsoft-Objekt-ID" + +#: includes/class-m365-login-sync.php:1934 +msgid "Microsoft tenant ID" +msgstr "Microsoft-Tenant-ID" + +#: includes/class-m365-login-sync.php:1945 +msgid "Profile photo" +msgstr "Profilbild" + +#: includes/class-m365-login-sync.php:1949 includes/class-m365-login-sync.php:2300 +msgid "Last sync" +msgstr "Letzter Sync" + +#: includes/class-m365-login-sync.php:1963 includes/class-m365-login-sync.php:2167 includes/class-m365-login-sync.php:2314 +msgid "Microsoft 365" +msgstr "Microsoft 365" + +#: includes/class-m365-login-sync.php:2004 +msgid "The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync." +msgstr "Die Verknüpfung mit dem Microsoft-Konto und eine eventuelle Deaktivierung wurden behalten, weil sie das Konto absichern. Der nächste Benutzer-Sync überträgt ausgewählte Profilfelder erneut, sofern die Person nicht vom Sync ausgenommen ist." + +#: includes/class-m365-login-sync.php:2185 +msgid "Deactivated" +msgstr "Deaktiviert" + +#: includes/class-m365-login-sync.php:2188 +msgid "Imported" +msgstr "Importiert" + +#: includes/class-m365-login-sync.php:2190 includes/class-m365-login-sync.php:2320 +msgid "Linked" +msgstr "Verknüpft" + +#: includes/class-m365-login-sync.php:2218 +msgid "Reactivate" +msgstr "Reaktivieren" + +#: includes/class-m365-login-sync.php:2218 +msgid "Deactivate" +msgstr "Deaktivieren" + +#: includes/class-m365-login-sync.php:2247 +msgid "Your Microsoft account is now linked. From now on you can sign in with the Microsoft button." +msgstr "Dein Microsoft-Konto ist jetzt verknüpft. Ab sofort kannst du dich mit dem Microsoft-Button anmelden." + +#: includes/class-m365-login-sync.php:2261 +msgid "The account has been deactivated and signed out everywhere." +msgstr "Das Konto wurde deaktiviert und überall abgemeldet." + +#: includes/class-m365-login-sync.php:2262 +msgid "The account has been reactivated." +msgstr "Das Konto wurde reaktiviert." + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2289 +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:2291 +msgid "manually" +msgstr "manuell" + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2293 +msgid "Status" +msgstr "Status" + +#: includes/class-m365-login-sync.php:2296 +msgid "Object ID" +msgstr "Objekt-ID" + +#: includes/class-m365-login-sync.php:2317 +msgid "Microsoft account" +msgstr "Microsoft-Konto" + +#: includes/class-m365-login-sync.php:2322 +msgid "Not linked" +msgstr "Nicht verknüpft" + +#: includes/class-m365-login-sync.php:2325 +msgid "Link Microsoft account" +msgstr "Mit Microsoft-Konto verknüpfen" + +#: includes/class-m365-login-sync.php:2326 +msgid "You sign in with Microsoft once; afterwards this WordPress account is bound to that Microsoft account, even if its user principal name differs from your e-mail address." +msgstr "Du meldest dich einmal bei Microsoft an; danach ist dieses WordPress-Konto an dieses Microsoft-Konto gebunden – auch wenn dessen Benutzerprinzipalname von deiner E-Mail-Adresse abweicht." + +#: includes/class-m365-login-sync.php:2332 +msgid "Assigned Microsoft account (UPN)" +msgstr "Zugewiesenes Microsoft-Konto (UPN)" + +#: includes/class-m365-login-sync.php:2335 +msgid "Optional. The user principal name of the Microsoft account that belongs to this user. Sign-in and user sync link exactly this account – needed for administrators whose user principal name differs from their WordPress e-mail address." +msgstr "Optional. Der Benutzerprinzipalname des Microsoft-Kontos, das zu diesem Benutzer gehört. Anmeldung und Benutzer-Sync verknüpfen genau dieses Konto – nötig für Administratoren, deren Benutzerprinzipalname von ihrer WordPress-E-Mail-Adresse abweicht." + +#: includes/class-m365-login-sync.php:2337 +msgid "Remove the link to the Microsoft account" +msgstr "Verknüpfung mit dem Microsoft-Konto aufheben" + +#: includes/class-m365-login-sync.php:2350 +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." + +#: includes/class-m365-login.php:127 msgid "Settings" msgstr "Einstellungen" -#: includes/class-m365-login.php:113 +#: includes/class-m365-login.php:138 msgid "M365 Login requires PHP 7.4 or newer." msgstr "M365 Login benötigt PHP 7.4 oder neuer." -#: includes/class-m365-login.php:114 includes/class-m365-login.php:123 +#: includes/class-m365-login.php:139 includes/class-m365-login.php:148 msgid "Plugin activation failed" msgstr "Plugin-Aktivierung fehlgeschlagen" -#: includes/class-m365-login.php:122 +#: includes/class-m365-login.php:147 msgid "M365 Login requires the PHP OpenSSL extension (needed to verify Microsoft token signatures and to encrypt the client secret)." msgstr "M365 Login benötigt die PHP-Erweiterung OpenSSL (zur Prüfung der Microsoft-Token-Signaturen und zur Verschlüsselung des Client Secrets)." - diff --git a/languages/m365-login-de_DE_formal.mo b/languages/m365-login-de_DE_formal.mo index e5f63fa..6fe97cc 100644 Binary files a/languages/m365-login-de_DE_formal.mo and b/languages/m365-login-de_DE_formal.mo differ diff --git a/languages/m365-login-de_DE_formal.po b/languages/m365-login-de_DE_formal.po index 326d9d9..713041b 100644 --- a/languages/m365-login-de_DE_formal.po +++ b/languages/m365-login-de_DE_formal.po @@ -2,13 +2,13 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: M365 Login 1.0.0\n" +"Project-Id-Version: M365 Login 1.1.0\n" "Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-09-22T00:00:00+00:00\n" -"PO-Revision-Date: 2026-09-22 12:00+0000\n" +"POT-Creation-Date: 2026-09-23T00:00:00+00:00\n" +"PO-Revision-Date: 2026-09-23 12:00+0000\n" "Last-Translator: friloo\n" "Language-Team: German\n" "Language: de_DE_formal\n" @@ -16,784 +16,1173 @@ msgstr "" "X-Generator: bin/make-pot.py\n" "X-Domain: m365-login\n" -#: includes/class-m365-login-admin.php:81 includes/class-m365-login-admin.php:82 includes/class-m365-login-admin.php:93 includes/class-m365-login-admin.php:382 +#: includes/class-m365-login-admin.php:108 includes/class-m365-login-admin.php:109 includes/class-m365-login-admin.php:120 includes/class-m365-login-admin.php:779 msgid "M365 Login" msgstr "M365 Login" -#: includes/class-m365-login-admin.php:108 +#: includes/class-m365-login-admin.php:135 msgid "Connection" msgstr "Verbindung" -#: includes/class-m365-login-admin.php:109 +#: includes/class-m365-login-admin.php:136 msgid "Button" msgstr "Button" -#: includes/class-m365-login-admin.php:110 +#: includes/class-m365-login-admin.php:137 msgid "Security" msgstr "Sicherheit" -#: includes/class-m365-login-admin.php:185 -msgid "M365 Login is active but not connected to Microsoft Entra ID yet." -msgstr "M365 Login ist aktiv, aber noch nicht mit Microsoft Entra ID verbunden." +#: includes/class-m365-login-admin.php:138 +msgid "User sync" +msgstr "Benutzer-Sync" -#: includes/class-m365-login-admin.php:187 +#: includes/class-m365-login-admin.php:207 +msgid "M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link." +msgstr "M365 Login: Der Nur-Button-Modus ist aktiv, aber die Verbindung zu Microsoft ist gestört (Secret fehlt oder ist nicht entschlüsselbar, oder das Zertifikat ist abgelaufen). Anmelden ist nur noch über den Fallback-Link möglich." + +#: includes/class-m365-login-admin.php:209 includes/class-m365-login-admin.php:227 msgid "Open the settings" msgstr "Einstellungen öffnen" -#: includes/class-m365-login-admin.php:217 +#: includes/class-m365-login-admin.php:225 +msgid "M365 Login is active but not connected to Microsoft Entra ID yet." +msgstr "M365 Login ist aktiv, aber noch nicht mit Microsoft Entra ID verbunden." + +#: includes/class-m365-login-admin.php:263 msgid "Choose button icon" msgstr "Button-Icon auswählen" -#: includes/class-m365-login-admin.php:218 +#: includes/class-m365-login-admin.php:264 msgid "Use this icon" msgstr "Dieses Icon verwenden" -#: includes/class-m365-login-admin.php:219 +#: includes/class-m365-login-admin.php:265 msgid "Copied!" msgstr "Kopiert!" -#: includes/class-m365-login-admin.php:220 includes/class-m365-login-admin.php:505 includes/class-m365-login-admin.php:783 includes/class-m365-login-admin.php:826 +#: includes/class-m365-login-admin.php:266 includes/class-m365-login-admin.php:905 includes/class-m365-login-admin.php:1172 includes/class-m365-login-admin.php:1217 msgid "Copy" msgstr "Kopieren" -#: includes/class-m365-login-admin.php:221 +#: includes/class-m365-login-admin.php:267 msgid "Testing…" msgstr "Wird geprüft …" -#: includes/class-m365-login-admin.php:222 +#: includes/class-m365-login-admin.php:268 msgid "The tenant could not be reached. Check the tenant ID and the server’s outgoing connections." msgstr "Der Tenant ist nicht erreichbar. Bitte Tenant-ID und ausgehende Verbindungen des Servers prüfen." -#: includes/class-m365-login-admin.php:223 +#: includes/class-m365-login-admin.php:269 msgid "No groups found." msgstr "Keine Gruppen gefunden." -#: includes/class-m365-login-admin.php:224 +#: includes/class-m365-login-admin.php:270 msgid "Searching…" msgstr "Suche läuft …" -#: includes/class-m365-login-admin.php:225 +#: includes/class-m365-login-admin.php:271 msgid "Add" msgstr "Hinzufügen" -#: includes/class-m365-login-admin.php:226 includes/class-m365-login-admin.php:757 +#: includes/class-m365-login-admin.php:272 includes/class-m365-login-admin.php:528 msgid "Remove" msgstr "Entfernen" -#: includes/class-m365-login-admin.php:227 includes/class-m365-login-admin.php:285 includes/class-m365-login-admin.php:742 +#: includes/class-m365-login-admin.php:273 includes/class-m365-login-admin.php:336 includes/class-m365-login-admin.php:501 msgid "Save the connection settings first, then search for groups." msgstr "Zuerst die Verbindungseinstellungen speichern, dann Gruppen suchen." -#: includes/class-m365-login-admin.php:228 +#: includes/class-m365-login-admin.php:274 msgid "Generate a new fallback key on save? The old link stops working." msgstr "Beim Speichern einen neuen Fallback-Schlüssel erzeugen? Der alte Link funktioniert dann nicht mehr." -#: includes/class-m365-login-admin.php:229 +#: includes/class-m365-login-admin.php:275 msgid "Generating a 3072-bit key pair, this takes a moment…" msgstr "3072-Bit-Schlüsselpaar wird erzeugt, das dauert einen Moment …" -#: includes/class-m365-login-admin.php:230 +#: includes/class-m365-login-admin.php:276 msgid "Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID." msgstr "Gespeichertes Zertifikat ersetzen? Die Anmeldung funktioniert erst wieder, wenn das neue Zertifikat in Entra ID hochgeladen ist." -#: includes/class-m365-login-admin.php:231 +#: includes/class-m365-login-admin.php:277 msgid "Remove the stored certificate when saving? Sign-in with the certificate method stops working." msgstr "Gespeichertes Zertifikat beim Speichern entfernen? Die Anmeldung per Zertifikat funktioniert dann nicht mehr." -#: includes/class-m365-login-admin.php:243 includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:308 includes/class-m365-login-admin.php:340 +#: includes/class-m365-login-admin.php:278 +msgid "Sync is running, this can take a while for large directories…" +msgstr "Sync läuft, bei großen Verzeichnissen kann das etwas dauern …" + +#: includes/class-m365-login-admin.php:279 +msgid "Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first." +msgstr "Sync jetzt mit den gespeicherten Einstellungen ausführen? Konten werden angelegt, aktualisiert und eventuell deaktiviert oder gelöscht. Tipp: Führen Sie zuerst einen Testlauf aus." + +#: includes/class-m365-login-admin.php:280 +msgid "The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use \"wp m365-login sync\" (WP-CLI)." +msgstr "Die Anfrage ist fehlgeschlagen oder hat zu lange gedauert. Laden Sie die Seite in ein paar Minuten neu, um den Bericht zu sehen; für sehr große Verzeichnisse nutzen Sie „wp m365-login sync“ (WP-CLI)." + +#: includes/class-m365-login-admin.php:281 +msgid "You have unsaved changes. The sync uses the saved settings – save first." +msgstr "Sie haben ungespeicherte Änderungen. Der Sync verwendet die gespeicherten Einstellungen – speichern Sie zuerst." + +#: includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:515 +msgid "Move up" +msgstr "Nach oben" + +#: includes/class-m365-login-admin.php:294 includes/class-m365-login-admin.php:333 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:392 includes/class-m365-login-admin.php:566 includes/class-m365-login-sync.php:2230 msgid "You are not allowed to do this." msgstr "Dafür fehlt die Berechtigung." -#: includes/class-m365-login-admin.php:248 +#: includes/class-m365-login-admin.php:299 msgid "Please enter a valid tenant ID first." msgstr "Bitte zuerst eine gültige Tenant-ID eingeben." #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:262 +#: includes/class-m365-login-admin.php:313 msgid "Microsoft answered with HTTP %d. Is the tenant ID correct?" msgstr "Microsoft hat mit HTTP %d geantwortet. Ist die Tenant-ID korrekt?" #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:271 +#: includes/class-m365-login-admin.php:322 msgid "Tenant reachable. The OpenID configuration was loaded successfully." msgstr "Tenant erreichbar. Die OpenID-Konfiguration wurde erfolgreich geladen." -#: includes/class-m365-login-admin.php:294 +#: includes/class-m365-login-admin.php:345 msgid "Microsoft Graph refused the request. Grant the application permission \"GroupMember.Read.All\" (or \"Directory.Read.All\") with admin consent in Entra ID." msgstr "Microsoft Graph hat die Anfrage abgelehnt. In Entra ID die Anwendungsberechtigung „GroupMember.Read.All“ (oder „Directory.Read.All“) mit Administratorzustimmung erteilen." -#: includes/class-m365-login-admin.php:312 +#: includes/class-m365-login-admin.php:363 msgid "Unknown operation." msgstr "Unbekannte Aktion." -#: includes/class-m365-login-admin.php:329 +#: includes/class-m365-login-admin.php:380 msgid "Certificate generated and stored. Download the .cer file and upload it in Entra ID." msgstr "Zertifikat erzeugt und gespeichert. Jetzt die .cer-Datei herunterladen und in Entra ID hochladen." -#: includes/class-m365-login-admin.php:346 +#: includes/class-m365-login-admin.php:407 +msgid "The sync has not run yet." +msgstr "Der Sync ist noch nicht gelaufen." + +#: includes/class-m365-login-admin.php:411 +msgid "Finished" +msgstr "Abgeschlossen" + +#: includes/class-m365-login-admin.php:412 +msgid "Failed" +msgstr "Fehlgeschlagen" + +#: includes/class-m365-login-admin.php:413 +msgid "Stopped by the safety limit" +msgstr "Vom Sicherheitslimit gestoppt" + +#: includes/class-m365-login-admin.php:414 +msgid "Not started" +msgstr "Nicht gestartet" + +#: includes/class-m365-login-admin.php:417 +msgid "started manually" +msgstr "manuell gestartet" + +#: includes/class-m365-login-admin.php:418 +msgid "scheduled" +msgstr "geplant" + +#: includes/class-m365-login-admin.php:419 +msgid "WP-CLI" +msgstr "WP-CLI" + +#: includes/class-m365-login-admin.php:422 +msgid "would be created" +msgstr "würden angelegt" + +#: includes/class-m365-login-admin.php:422 +msgid "created" +msgstr "angelegt" + +#: includes/class-m365-login-admin.php:423 +msgid "would be updated" +msgstr "würden aktualisiert" + +#: includes/class-m365-login-admin.php:423 +msgid "updated" +msgstr "aktualisiert" + +#: includes/class-m365-login-admin.php:424 +msgid "would be linked" +msgstr "würden verknüpft" + +#: includes/class-m365-login-admin.php:424 +msgid "linked" +msgstr "verknüpft" + +#: includes/class-m365-login-admin.php:425 +msgid "unchanged" +msgstr "unverändert" + +#: includes/class-m365-login-admin.php:426 +msgid "would be deactivated" +msgstr "würden deaktiviert" + +#: includes/class-m365-login-admin.php:426 +msgid "deactivated" +msgstr "deaktiviert" + +#: includes/class-m365-login-admin.php:427 +msgid "would be reactivated" +msgstr "würden reaktiviert" + +#: includes/class-m365-login-admin.php:427 +msgid "reactivated" +msgstr "reaktiviert" + +#: includes/class-m365-login-admin.php:428 +msgid "would be deleted" +msgstr "würden gelöscht" + +#: includes/class-m365-login-admin.php:428 +msgid "deleted" +msgstr "gelöscht" + +#: includes/class-m365-login-admin.php:429 +msgid "photos" +msgstr "Profilbilder" + +#: includes/class-m365-login-admin.php:430 +msgid "skipped" +msgstr "übersprungen" + +#: includes/class-m365-login-admin.php:431 +msgid "errors" +msgstr "Fehler" + +#: includes/class-m365-login-admin.php:444 +msgid "Dry run – nothing was changed" +msgstr "Testlauf – nichts wurde geändert" + +#. translators: 1: date and time, 2: how the run was started, 3: duration in seconds +#: includes/class-m365-login-admin.php:449 +msgid "%1$s, %2$s, %3$d s" +msgstr "%1$s, %2$s, %3$d s" + +#. translators: %d: number of log entries +#: includes/class-m365-login-admin.php:467 +msgid "Log (%d entry)" +msgid_plural "Log (%d entries)" +msgstr[0] "Protokoll (%d Eintrag)" +msgstr[1] "Protokoll (%d Einträge)" + +#: includes/class-m365-login-admin.php:495 +msgid "Search groups" +msgstr "Gruppen suchen" + +#: includes/class-m365-login-admin.php:497 +msgid "Type a group name or paste an object ID…" +msgstr "Gruppenname eingeben oder Objekt-ID einfügen …" + +#: includes/class-m365-login-admin.php:498 +msgid "Search" +msgstr "Suchen" + +#: includes/class-m365-login-admin.php:503 +msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." +msgstr "Benötigt die Anwendungsberechtigung „GroupMember.Read.All“ mit Administratorzustimmung. Ohne sie können Gruppen-Objekt-IDs trotzdem eingefügt werden." + +#: includes/class-m365-login-admin.php:509 +msgid "Selected groups" +msgstr "Ausgewählte Gruppen" + +#: includes/class-m365-login-admin.php:521 +msgid "WordPress role" +msgstr "WordPress-Rolle" + +#: includes/class-m365-login-admin.php:573 msgid "No certificate is stored." msgstr "Es ist kein Zertifikat gespeichert." -#: includes/class-m365-login-admin.php:364 +#: includes/class-m365-login-admin.php:598 +msgid "Do nothing" +msgstr "Nichts tun" + +#: includes/class-m365-login-admin.php:599 +msgid "Deactivate the WordPress account" +msgstr "WordPress-Konto deaktivieren" + +#: includes/class-m365-login-admin.php:600 +msgid "Delete the WordPress account" +msgstr "WordPress-Konto löschen" + +#: includes/class-m365-login-admin.php:603 +msgid "Account disabled in Microsoft 365 (sign-in blocked)" +msgstr "Konto in Microsoft 365 deaktiviert (Anmeldung blockiert)" + +#: includes/class-m365-login-admin.php:604 +msgid "Account deleted in Microsoft 365" +msgstr "Konto in Microsoft 365 gelöscht" + +#: includes/class-m365-login-admin.php:605 +msgid "No longer a member of the sync groups" +msgstr "Kein Mitglied der Sync-Gruppen mehr" + +#: includes/class-m365-login-admin.php:610 +msgid "Import users from Microsoft 365" +msgstr "Benutzer aus Microsoft 365 importieren" + +#: includes/class-m365-login-admin.php:611 +msgid "Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button." +msgstr "Legt für jeden Microsoft-365-Benutzer im Geltungsbereich ein WordPress-Konto an, verknüpft bestehende Konten über die E-Mail-Adresse, hält Rollen und Profilfelder aktuell und deaktiviert oder löscht Konten, die in Microsoft 365 deaktiviert oder entfernt wurden. Neue Konten erhalten ein Zufallspasswort und keine E-Mail; die Anmeldung erfolgt über den Microsoft-Button." + +#: includes/class-m365-login-admin.php:616 +msgid "Run the sync automatically" +msgstr "Sync automatisch ausführen" + +#: includes/class-m365-login-admin.php:617 +msgid "Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run \"wp m365-login sync\"." +msgstr "Nutzt WP-Cron, das bei Besuchen der Website ausgelöst wird. Für genaue Zeiten rufen Sie wp-cron.php über einen echten Cronjob auf oder führen Sie „wp m365-login sync“ aus." + +#: includes/class-m365-login-admin.php:623 +msgid "Interval" +msgstr "Intervall" + +#: includes/class-m365-login-admin.php:625 +msgid "Hourly" +msgstr "Stündlich" + +#: includes/class-m365-login-admin.php:626 +msgid "Twice daily" +msgstr "Zweimal täglich" + +#: includes/class-m365-login-admin.php:627 +msgid "Daily" +msgstr "Täglich" + +#. translators: %s: date and time +#: includes/class-m365-login-admin.php:631 +msgid "Next run: %s" +msgstr "Nächster Lauf: %s" + +#: includes/class-m365-login-admin.php:639 +msgid "Also import guest users (B2B)" +msgstr "Auch Gastbenutzer importieren (B2B)" + +#: includes/class-m365-login-admin.php:640 +msgid "Guests are external people invited into your tenant. Off by default." +msgstr "Gäste sind externe Personen, die in Ihren Tenant eingeladen wurden. Standardmäßig aus." + +#: includes/class-m365-login-admin.php:644 +msgid "Which users? (optional)" +msgstr "Welche Benutzer? (optional)" + +#: includes/class-m365-login-admin.php:645 +msgid "Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well." +msgstr "Beschränkt den Import auf Mitglieder dieser Gruppen (verschachtelte Mitgliedschaften zählen). Ohne Gruppen wird jeder Benutzer des Tenants importiert. Die Liste erlaubter E-Mail-Domains im Tab „Sicherheit“ gilt ebenfalls." + +#: includes/class-m365-login-admin.php:646 +msgid "No groups selected – all users of the tenant are imported." +msgstr "Keine Gruppen ausgewählt – alle Benutzer des Tenants werden importiert." + +#: includes/class-m365-login-admin.php:650 +msgid "Roles" +msgstr "Rollen" + +#: includes/class-m365-login-admin.php:653 +msgid "Default role" +msgstr "Standardrolle" + +#: includes/class-m365-login-admin.php:657 +msgid "Every imported user gets this role. The sync manages the roles of imported accounts – manual role changes are overwritten on the next run." +msgstr "Jeder importierte Benutzer erhält diese Rolle. Die Rollen importierter Konten verwaltet der Sync – manuelle Rollenänderungen werden beim nächsten Lauf überschrieben." + +#: includes/class-m365-login-admin.php:660 +msgid "Additional roles from Microsoft 365 groups" +msgstr "Zusätzliche Rollen aus Microsoft-365-Gruppen" + +#: includes/class-m365-login-admin.php:661 +msgid "Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync." +msgstr "Mitglieder einer Gruppe (verschachtelte Mitgliedschaften zählen) erhalten die Rolle daneben. Verlässt eine Person die Gruppe, wird die Rolle beim nächsten Sync wieder entfernt." + +#: includes/class-m365-login-admin.php:662 +msgid "Whoever can change a group's members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) – never public Microsoft 365 groups or Teams, which members can join themselves." +msgstr "Wer die Mitglieder einer Gruppe ändern kann, bestimmt über die zugeordnete Rolle. Für Rollen mit Administrationsrechten Sicherheitsgruppen verwenden (am besten rollenzuweisbare) – nie öffentliche Microsoft-365-Gruppen oder Teams, denen Mitglieder selbst beitreten können." + +#: includes/class-m365-login-admin.php:663 +msgid "No group mapping – everybody gets the default role." +msgstr "Keine Gruppenzuordnung – alle erhalten die Standardrolle." + +#: includes/class-m365-login-admin.php:666 +msgid "How are mapped roles applied?" +msgstr "Wie werden zugeordnete Rollen vergeben?" + +#: includes/class-m365-login-admin.php:669 +msgid "In addition to the default role (a user can have several roles)" +msgstr "Zusätzlich zur Standardrolle (ein Benutzer kann mehrere Rollen haben)" + +#: includes/class-m365-login-admin.php:673 +msgid "Instead of the default role – the first matching group in the list wins (use ↑ to reorder)" +msgstr "Anstelle der Standardrolle – die erste passende Gruppe der Liste gewinnt (Reihenfolge mit ↑ ändern)" + +#: includes/class-m365-login-admin.php:680 +msgid "Also manage the roles of accounts that existed before the sync" +msgstr "Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten" + +#: includes/class-m365-login-admin.php:681 +msgid "Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed." +msgstr "Aus: Bestehende Konten werden nur verknüpft und ihre Profilfelder aktualisiert; ihre Rollen bleiben, wie sie sind. Administratoren, die schon vor dem Sync existierten, und Ihr eigenes Konto werden nie verändert." + +#: includes/class-m365-login-admin.php:687 +msgid "Profile fields" +msgstr "Profilfelder" + +#: includes/class-m365-login-admin.php:688 +msgid "Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with \"m365_\" – usable by themes and other plugins – and is shown on the profile screen." +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:697 +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:701 +msgid "Disabled and deleted Microsoft 365 accounts" +msgstr "Deaktivierte und gelöschte Microsoft-365-Konten" + +#: includes/class-m365-login-admin.php:702 +msgid "Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account." +msgstr "Gilt für WordPress-Konten, die mit einem Microsoft-Konto verknüpft sind (importiert oder mindestens einmal per Microsoft angemeldet). Deaktivierte Konten können sich gar nicht mehr anmelden – weder mit Microsoft noch mit Passwort oder Anwendungspasswort – und werden sofort abgemeldet. Ist die Person in Microsoft 365 wieder aktiv, reaktiviert der Sync das Konto." + +#: includes/class-m365-login-admin.php:713 +msgid "Only relevant when the import is limited to groups." +msgstr "Nur relevant, wenn der Import auf Gruppen beschränkt ist." + +#: includes/class-m365-login-admin.php:719 +msgid "Posts of deleted accounts go to" +msgstr "Beiträge gelöschter Konten übernimmt" + +#: includes/class-m365-login-admin.php:727 +msgid "— Select a user —" +msgstr "— Benutzer auswählen —" + +#: includes/class-m365-login-admin.php:734 +msgid "Required for \"Delete\". Without a user, accounts are deactivated instead, so no content is ever lost." +msgstr "Erforderlich für „Löschen“. Ohne Benutzer werden Konten stattdessen deaktiviert, damit nie Inhalte verloren gehen." + +#: includes/class-m365-login-admin.php:737 +msgid "Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated." +msgstr "Sicherheitsstopp: Würde ein Lauf mehr als 20 % der verknüpften Konten (mindestens 5) deaktivieren oder löschen, wird nichts deaktiviert oder gelöscht und der Lauf als gestoppt gemeldet. Auch eine fehlgeschlagene Microsoft-Graph-Anfrage stoppt den Lauf, bevor etwas deaktiviert wird." + +#: includes/class-m365-login-admin.php:741 +msgid "Run the sync" +msgstr "Sync ausführen" + +#: includes/class-m365-login-admin.php:742 +msgid "The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything." +msgstr "Der Lauf verwendet die gespeicherten Einstellungen. Beginnen Sie mit einem Testlauf: Er liest Microsoft 365 und listet auf, was sich ändern würde, ohne etwas zu ändern." + +#: includes/class-m365-login-admin.php:744 +msgid "Dry run" +msgstr "Testlauf" + +#: includes/class-m365-login-admin.php:745 +msgid "Sync now" +msgstr "Jetzt synchronisieren" + +#: includes/class-m365-login-admin.php:747 +msgid "Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used." +msgstr "Benötigte Anwendungsberechtigungen (Microsoft Graph, Administratorzustimmung): User.Read.All, bei Verwendung von Gruppen zusätzlich GroupMember.Read.All." + +#: includes/class-m365-login-admin.php:761 msgid "You are not allowed to access this page." msgstr "Für diese Seite fehlt die Berechtigung." -#: includes/class-m365-login-admin.php:383 +#: includes/class-m365-login-admin.php:780 msgid "Let existing users sign in with their Microsoft 365 / Entra ID account." msgstr "Bestehende Benutzer melden sich mit ihrem Microsoft 365 / Entra ID-Konto an." -#: includes/class-m365-login-admin.php:388 +#: includes/class-m365-login-admin.php:785 msgid "Connected" msgstr "Verbunden" -#: includes/class-m365-login-admin.php:388 +#: includes/class-m365-login-admin.php:785 msgid "Setup incomplete" msgstr "Einrichtung unvollständig" -#: includes/class-m365-login-admin.php:410 +#: includes/class-m365-login-admin.php:807 msgid "Microsoft Entra ID app registration" msgstr "App-Registrierung in Microsoft Entra ID" -#: includes/class-m365-login-admin.php:411 +#: includes/class-m365-login-admin.php:808 msgid "Enter the values from your app registration in the Microsoft Entra admin center." msgstr "Tragen Sie hier die Werte aus Ihrer App-Registrierung im Microsoft Entra Admin Center ein." -#: includes/class-m365-login-admin.php:414 +#: includes/class-m365-login-admin.php:811 msgid "Directory (tenant) ID" msgstr "Verzeichnis-ID (Mandant/Tenant)" -#: includes/class-m365-login-admin.php:417 +#: includes/class-m365-login-admin.php:814 msgid "Test tenant" msgstr "Tenant testen" -#: includes/class-m365-login-admin.php:419 +#: includes/class-m365-login-admin.php:816 msgid "Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. \"organizations\" allows any work or school account." msgstr "Empfohlen: die GUID Ihres Tenants. Dann werden nur Anmeldungen aus diesem Tenant akzeptiert. „organizations“ erlaubt beliebige Geschäfts-, Schul- oder Unikonten." -#: includes/class-m365-login-admin.php:421 +#: includes/class-m365-login-admin.php:818 msgid "Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their \"email\" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID." msgstr "Multi-Tenant-Modus: Konten aus beliebigen Microsoft-Tenants können sich anmelden. Deren „email“-Attribut ist nicht verifiziert, deshalb ordnet das Plugin nur über den User Principal Name (verifizierte Domain) zu und ignoriert den E-Mail-Claim, sofern Microsoft ihn nicht als domain-verifiziert markiert. Nutze die Domain-Allowlist im Tab „Sicherheit“ oder besser: die Tenant-GUID eintragen." -#: includes/class-m365-login-admin.php:427 +#: includes/class-m365-login-admin.php:824 msgid "Application (client) ID" msgstr "Anwendungs-ID (Client)" -#: includes/class-m365-login-admin.php:432 +#: includes/class-m365-login-admin.php:829 msgid "How should WordPress authenticate to Microsoft?" msgstr "Wie soll sich WordPress bei Microsoft authentifizieren?" -#: includes/class-m365-login-admin.php:437 includes/class-m365-login-admin.php:454 +#: includes/class-m365-login-admin.php:834 includes/class-m365-login-admin.php:851 msgid "Client secret" msgstr "Geheimer Clientschlüssel (Client Secret)" -#: includes/class-m365-login-admin.php:438 +#: includes/class-m365-login-admin.php:835 msgid "Quick to set up. A password-like value created in Entra ID that expires after 6–24 months and must be renewed." msgstr "Schnell eingerichtet. Ein passwortähnlicher Wert aus Entra ID, der nach 6–24 Monaten abläuft und erneuert werden muss." -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Certificate" msgstr "Zertifikat" -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Recommended" msgstr "Empfohlen" -#: includes/class-m365-login-admin.php:445 +#: includes/class-m365-login-admin.php:842 msgid "The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years." msgstr "Der private Schlüssel verlässt diesen Server nie; nur das öffentliche Zertifikat wird in Entra ID hochgeladen. Mit einem Klick hier erzeugt, 2 Jahre gültig." -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "•••••••••••• (stored, leave empty to keep)" msgstr "•••••••••••• (gespeichert – leer lassen, um zu behalten)" -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "Paste the secret value" msgstr "Wert des Secrets einfügen" -#: includes/class-m365-login-admin.php:457 +#: includes/class-m365-login-admin.php:854 msgid "Show secret" msgstr "Secret anzeigen" -#: includes/class-m365-login-admin.php:462 +#: includes/class-m365-login-admin.php:859 msgid "Remove the stored secret" msgstr "Gespeichertes Secret entfernen" -#: includes/class-m365-login-admin.php:465 +#: includes/class-m365-login-admin.php:862 msgid "Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire – note the expiry date in Entra ID." msgstr "Wird verschlüsselt gespeichert (AES-256-GCM, Schlüssel aus den WordPress-Salts abgeleitet) und nie wieder angezeigt. Client Secrets laufen ab – Ablaufdatum in Entra ID notieren." -#: includes/class-m365-login-admin.php:469 +#: includes/class-m365-login-admin.php:864 +msgid "AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database – right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective." +msgstr "AUTH_KEY und SECURE_AUTH_KEY sind nicht in der wp-config.php definiert, daher speichert WordPress seine Salts in der Datenbank – direkt neben dem verschlüsselten Secret. Tragen Sie die Salts in die wp-config.php ein, damit die Verschlüsselung wirkt." + +#: includes/class-m365-login-admin.php:869 msgid "Step-by-step: create a client secret in Entra ID" msgstr "Schritt für Schritt: Client Secret in Entra ID erstellen" -#: includes/class-m365-login-admin.php:472 +#: includes/class-m365-login-admin.php:872 msgid "Open entra.microsoft.com and sign in with an account that has the \"Application Administrator\" or \"Global Administrator\" role." msgstr "entra.microsoft.com öffnen und mit einem Konto anmelden, das die Rolle „Anwendungsadministrator“ oder „Globaler Administrator“ hat." -#: includes/class-m365-login-admin.php:473 +#: includes/class-m365-login-admin.php:873 msgid "Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar)." msgstr "Zu Identität → Anwendungen → App-Registrierungen wechseln und die App öffnen (oder zuerst anlegen, siehe allgemeine Anleitung in der Seitenleiste)." -#: includes/class-m365-login-admin.php:474 +#: includes/class-m365-login-admin.php:874 msgid "In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret." msgstr "Im linken Menü „Zertifikate & Geheimnisse“ wählen, dann den Reiter „Geheime Clientschlüssel“ und auf „Neuer geheimer Clientschlüssel“ klicken." -#: includes/class-m365-login-admin.php:475 +#: includes/class-m365-login-admin.php:875 msgid "Enter a description such as \"WordPress login\" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before." msgstr "Eine Beschreibung wie „WordPress Login“ eingeben und eine Gültigkeit wählen. Microsoft erlaubt maximal 24 Monate; zwei Wochen vor Ablauf eine Kalender-Erinnerung setzen." -#: includes/class-m365-login-admin.php:476 +#: includes/class-m365-login-admin.php:876 msgid "Click Add. Copy the Value column immediately – it is shown only once. The Secret ID column is NOT what you need." msgstr "Auf „Hinzufügen“ klicken. Die Spalte „Wert“ sofort kopieren – sie wird nur einmal angezeigt. Die Spalte „Geheimnis-ID“ ist NICHT der gesuchte Wert." -#: includes/class-m365-login-admin.php:477 +#: includes/class-m365-login-admin.php:877 msgid "Paste the value into the Client secret field above and save this page." msgstr "Den Wert oben in das Feld „Geheimer Clientschlüssel“ einfügen und diese Seite speichern." -#: includes/class-m365-login-admin.php:479 +#: includes/class-m365-login-admin.php:879 msgid "When the secret expires, sign-ins fail with \"Could not complete the sign-in with Microsoft\". Create a new secret, paste it here, save, then delete the old one in Entra ID." msgstr "Läuft das Secret ab, scheitern Anmeldungen mit „Die Anmeldung über Microsoft konnte nicht abgeschlossen werden“. Dann ein neues Secret erstellen, hier einfügen, speichern und das alte in Entra ID löschen." -#: includes/class-m365-login-admin.php:492 +#: includes/class-m365-login-admin.php:892 msgid "Expired" msgstr "Abgelaufen" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:496 +#: includes/class-m365-login-admin.php:896 msgid "Expires in %d days" msgstr "Läuft in %d Tagen ab" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:499 +#: includes/class-m365-login-admin.php:899 msgid "Valid" msgstr "Gültig" -#: includes/class-m365-login-admin.php:504 +#: includes/class-m365-login-admin.php:904 msgid "Thumbprint (SHA-1)" msgstr "Fingerabdruck (SHA-1)" -#: includes/class-m365-login-admin.php:506 +#: includes/class-m365-login-admin.php:906 msgid "Subject" msgstr "Antragsteller" -#: includes/class-m365-login-admin.php:508 +#: includes/class-m365-login-admin.php:908 msgid "Key size" msgstr "Schlüssellänge" -#: includes/class-m365-login-admin.php:510 +#: includes/class-m365-login-admin.php:910 msgid "Valid until" msgstr "Gültig bis" -#: includes/class-m365-login-admin.php:514 +#: includes/class-m365-login-admin.php:914 msgid "Download certificate (.cer)" msgstr "Zertifikat herunterladen (.cer)" -#: includes/class-m365-login-admin.php:515 +#: includes/class-m365-login-admin.php:915 msgid "Generate new certificate" msgstr "Neues Zertifikat erzeugen" -#: includes/class-m365-login-admin.php:518 +#: includes/class-m365-login-admin.php:918 msgid "Remove certificate when saving" msgstr "Zertifikat beim Speichern entfernen" -#: includes/class-m365-login-admin.php:522 +#: includes/class-m365-login-admin.php:922 msgid "No certificate stored yet." msgstr "Noch kein Zertifikat gespeichert." -#: includes/class-m365-login-admin.php:524 +#: includes/class-m365-login-admin.php:924 msgid "Generate certificate" msgstr "Zertifikat erzeugen" -#: includes/class-m365-login-admin.php:525 +#: includes/class-m365-login-admin.php:925 msgid "3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable." msgstr "3072 Bit RSA, selbstsigniert, 2 Jahre gültig. Der private Schlüssel wird verschlüsselt gespeichert und nie angezeigt oder zum Download angeboten." -#: includes/class-m365-login-admin.php:529 +#: includes/class-m365-login-admin.php:929 msgid "Use your own certificate instead (paste PEM)" msgstr "Stattdessen eigenes Zertifikat verwenden (PEM einfügen)" -#: includes/class-m365-login-admin.php:532 +#: includes/class-m365-login-admin.php:932 msgid "Private key (PEM, unencrypted)" msgstr "Privater Schlüssel (PEM, unverschlüsselt)" -#: includes/class-m365-login-admin.php:536 +#: includes/class-m365-login-admin.php:936 msgid "Certificate (PEM)" msgstr "Zertifikat (PEM)" -#: includes/class-m365-login-admin.php:538 +#: includes/class-m365-login-admin.php:938 msgid "RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards." msgstr "RSA, mindestens 2048 Bit. Beim Speichern wird das Paar geprüft und der Schlüssel verschlüsselt. Beide Felder bleiben danach leer." -#: includes/class-m365-login-admin.php:544 +#: includes/class-m365-login-admin.php:944 msgid "Step-by-step: register the certificate in Entra ID" msgstr "Schritt für Schritt: Zertifikat in Entra ID hinterlegen" -#: includes/class-m365-login-admin.php:547 +#: includes/class-m365-login-admin.php:947 msgid "Click Generate certificate above (or paste your own). Then click Download certificate (.cer) – the file contains only the public part." msgstr "Oben auf „Zertifikat erzeugen“ klicken (oder ein eigenes einfügen). Danach „Zertifikat herunterladen (.cer)“ – die Datei enthält nur den öffentlichen Teil." -#: includes/class-m365-login-admin.php:548 +#: includes/class-m365-login-admin.php:948 msgid "Open entra.microsoft.com → Identity → Applications → App registrations and open your app." msgstr "entra.microsoft.com → Identität → Anwendungen → App-Registrierungen öffnen und die App auswählen." -#: includes/class-m365-login-admin.php:549 +#: includes/class-m365-login-admin.php:949 msgid "Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate." msgstr "Im linken Menü „Zertifikate & Geheimnisse“ wählen, dann den Reiter „Zertifikate“ und auf „Zertifikat hochladen“ klicken." -#: includes/class-m365-login-admin.php:550 +#: includes/class-m365-login-admin.php:950 msgid "Select the downloaded .cer file, add a description such as \"WordPress login\" and click Add." msgstr "Die heruntergeladene .cer-Datei auswählen, eine Beschreibung wie „WordPress Login“ eingeben und auf „Hinzufügen“ klicken." -#: includes/class-m365-login-admin.php:551 +#: includes/class-m365-login-admin.php:951 msgid "Compare the thumbprint Entra ID shows with the thumbprint above – they must match exactly." msgstr "Den in Entra ID angezeigten Fingerabdruck mit dem Fingerabdruck oben vergleichen – beide müssen exakt übereinstimmen." -#: includes/class-m365-login-admin.php:552 +#: includes/class-m365-login-admin.php:952 msgid "Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now." msgstr "Sicherstellen, dass oben „Zertifikat“ ausgewählt ist, und diese Seite speichern. War vorher ein Client Secret gespeichert, kann es jetzt in Entra ID gelöscht werden." -#: includes/class-m365-login-admin.php:554 +#: includes/class-m365-login-admin.php:954 msgid "How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted." msgstr "So funktioniert es: Für jede Token-Anfrage signiert WordPress ein kurzlebiges JWT (Client Assertion) mit dem privaten Schlüssel; Microsoft prüft es mit dem hochgeladenen Zertifikat. Es wird nie ein Geheimnis übertragen." -#: includes/class-m365-login-admin.php:555 +#: includes/class-m365-login-admin.php:955 msgid "Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch." msgstr "Vor Ablauf des Zertifikats: hier ein neues erzeugen, in Entra ID hochladen (beide dürfen gleichzeitig hinterlegt sein), speichern und danach das alte in Entra ID entfernen. Anmeldungen funktionieren während des Wechsels weiter." -#: includes/class-m365-login-admin.php:561 +#: includes/class-m365-login-admin.php:961 msgid "Account prompt" msgstr "Kontoauswahl" -#: includes/class-m365-login-admin.php:563 +#: includes/class-m365-login-admin.php:963 msgid "Always let the user pick an account (recommended)" msgstr "Benutzer wählt immer ein Konto aus (empfohlen)" -#: includes/class-m365-login-admin.php:564 +#: includes/class-m365-login-admin.php:964 msgid "Use the current Microsoft session if available" msgstr "Vorhandene Microsoft-Sitzung verwenden, falls vorhanden" -#: includes/class-m365-login-admin.php:565 +#: includes/class-m365-login-admin.php:965 msgid "Always require re-entering credentials" msgstr "Immer erneute Eingabe der Anmeldedaten verlangen" -#: includes/class-m365-login-admin.php:574 +#: includes/class-m365-login-admin.php:974 msgid "Appearance" msgstr "Darstellung" -#: includes/class-m365-login-admin.php:577 +#: includes/class-m365-login-admin.php:977 msgid "Live preview" msgstr "Live-Vorschau" -#: includes/class-m365-login-admin.php:591 +#: includes/class-m365-login-admin.php:991 msgid "Button text" msgstr "Button-Text" -#: includes/class-m365-login-admin.php:595 +#: includes/class-m365-login-admin.php:995 msgid "Divider text" msgstr "Trennlinien-Text" -#: includes/class-m365-login-admin.php:597 +#: includes/class-m365-login-admin.php:997 msgid "Leave empty to hide the divider line." msgstr "Leer lassen, um die Trennlinie auszublenden." -#: includes/class-m365-login-admin.php:602 +#: includes/class-m365-login-admin.php:1002 msgid "Icon" msgstr "Icon" -#: includes/class-m365-login-admin.php:605 +#: includes/class-m365-login-admin.php:1005 msgid "Show an icon on the button" msgstr "Icon auf dem Button anzeigen" -#: includes/class-m365-login-admin.php:616 +#: includes/class-m365-login-admin.php:1016 msgid "Default: Microsoft logo" msgstr "Standard: Microsoft-Logo" -#: includes/class-m365-login-admin.php:618 +#: includes/class-m365-login-admin.php:1018 msgid "Choose from media library" msgstr "Aus Mediathek wählen" -#: includes/class-m365-login-admin.php:619 +#: includes/class-m365-login-admin.php:1019 msgid "Use Microsoft logo" msgstr "Microsoft-Logo verwenden" -#: includes/class-m365-login-admin.php:621 +#: includes/class-m365-login-admin.php:1021 msgid "PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best." msgstr "PNG, SVG, JPG oder WebP. Quadratische Bilder (z. B. 64×64 px) eignen sich am besten." -#: includes/class-m365-login-admin.php:629 +#: includes/class-m365-login-admin.php:1029 msgid "Background" msgstr "Hintergrund" -#: includes/class-m365-login-admin.php:630 +#: includes/class-m365-login-admin.php:1030 msgid "Background (hover)" msgstr "Hintergrund (Hover)" -#: includes/class-m365-login-admin.php:631 +#: includes/class-m365-login-admin.php:1031 msgid "Text colour" msgstr "Textfarbe" -#: includes/class-m365-login-admin.php:632 +#: includes/class-m365-login-admin.php:1032 msgid "Border" msgstr "Rahmen" -#: includes/class-m365-login-admin.php:645 +#: includes/class-m365-login-admin.php:1045 msgid "Corner radius" msgstr "Eckenradius" -#: includes/class-m365-login-admin.php:649 +#: includes/class-m365-login-admin.php:1049 msgid "Position on the login page" msgstr "Position auf der Login-Seite" -#: includes/class-m365-login-admin.php:651 +#: includes/class-m365-login-admin.php:1051 msgid "Below the login form" msgstr "Unter dem Login-Formular" -#: includes/class-m365-login-admin.php:652 +#: includes/class-m365-login-admin.php:1052 msgid "Above the login form" msgstr "Über dem Login-Formular" -#: includes/class-m365-login-admin.php:658 +#: includes/class-m365-login-admin.php:1058 msgid "Quick presets" msgstr "Schnellauswahl" -#: includes/class-m365-login-admin.php:659 +#: includes/class-m365-login-admin.php:1059 msgid "Microsoft dark" msgstr "Microsoft dunkel" -#: includes/class-m365-login-admin.php:660 +#: includes/class-m365-login-admin.php:1060 msgid "Microsoft light" msgstr "Microsoft hell" -#: includes/class-m365-login-admin.php:661 +#: includes/class-m365-login-admin.php:1061 msgid "Azure blue" msgstr "Azure-Blau" -#: includes/class-m365-login-admin.php:662 +#: includes/class-m365-login-admin.php:1062 msgid "WordPress blue" msgstr "WordPress-Blau" -#: includes/class-m365-login-admin.php:666 +#: includes/class-m365-login-admin.php:1066 msgid "Custom login page" msgstr "Eigene Login-Seite" -#: includes/class-m365-login-admin.php:667 +#: includes/class-m365-login-admin.php:1067 msgid "Using your own login page instead of wp-login.php? Tell the plugin where it is so error messages, the fallback link and the post-logout redirect point there." msgstr "Eigene Login-Seite statt wp-login.php? Hier eintragen, damit Fehlermeldungen, der Fallback-Link und die Weiterleitung nach dem Abmelden dorthin zeigen." -#: includes/class-m365-login-admin.php:670 +#: includes/class-m365-login-admin.php:1070 msgid "URL of your login page" msgstr "URL der Login-Seite" -#: includes/class-m365-login-admin.php:672 +#: includes/class-m365-login-admin.php:1072 msgid "Must be on this site. Leave empty to use wp-login.php." msgstr "Muss auf dieser Website liegen. Leer lassen, um wp-login.php zu verwenden." -#: includes/class-m365-login-admin.php:678 +#: includes/class-m365-login-admin.php:1078 msgid "Add the button to every wp_login_form() form automatically" msgstr "Button automatisch in jedes wp_login_form()-Formular einfügen" -#: includes/class-m365-login-admin.php:679 +#: includes/class-m365-login-admin.php:1079 msgid "Covers themes and plugins that use the WordPress login form function. Page-builder widgets need the shortcode or the template function below." msgstr "Deckt Themes und Plugins ab, die die WordPress-Login-Formularfunktion verwenden. Page-Builder-Widgets benötigen den Shortcode oder die Template-Funktion unten." -#: includes/class-m365-login-admin.php:684 +#: includes/class-m365-login-admin.php:1084 msgid "Manual placement" msgstr "Manuelle Platzierung" -#: includes/class-m365-login-admin.php:685 +#: includes/class-m365-login-admin.php:1085 msgid "Shortcode (block editor, page builders):" msgstr "Shortcode (Block-Editor, Page Builder):" -#: includes/class-m365-login-admin.php:687 +#: includes/class-m365-login-admin.php:1087 msgid "Template function (theme files):" msgstr "Template-Funktion (Theme-Dateien):" -#: includes/class-m365-login-admin.php:689 +#: includes/class-m365-login-admin.php:1089 msgid "Both show the error messages of the last attempt; use m365_login_messages() to place them separately." msgstr "Beide zeigen die Fehlermeldungen des letzten Versuchs; mit m365_login_messages() lassen sie sich separat platzieren." -#: includes/class-m365-login-admin.php:697 +#: includes/class-m365-login-admin.php:1097 msgid "User matching & hardening" msgstr "Benutzerzuordnung & Härtung" -#: includes/class-m365-login-admin.php:698 -msgid "Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists." -msgstr "Benutzer werden nie automatisch angelegt. Eine Microsoft-Anmeldung gelingt nur, wenn bereits ein WordPress-Benutzer mit derselben E-Mail-Adresse existiert." +#: includes/class-m365-login-admin.php:1098 +msgid "Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync." +msgstr "Die Anmeldung legt nie Benutzer an. Eine Microsoft-Anmeldung klappt nur, wenn bereits ein WordPress-Benutzer mit derselben E-Mail-Adresse existiert – von Hand angelegt oder vom Benutzer-Sync importiert." -#: includes/class-m365-login-admin.php:703 +#: includes/class-m365-login-admin.php:1103 msgid "Bind WordPress accounts to the Microsoft object ID" msgstr "WordPress-Konten an die Microsoft-Objekt-ID binden" -#: includes/class-m365-login-admin.php:704 +#: includes/class-m365-login-admin.php:1104 msgid "On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended." msgstr "Bei der ersten Anmeldung wird die unveränderliche Microsoft-Objekt-ID am Benutzer gespeichert. Spätere Anmeldungen mit gleicher E-Mail, aber anderer Microsoft-Identität werden abgelehnt. Dringend empfohlen." -#: includes/class-m365-login-admin.php:711 +#: includes/class-m365-login-admin.php:1111 msgid "Fall back to the user principal name (UPN)" msgstr "Auf den User Principal Name (UPN) zurückgreifen" -#: includes/class-m365-login-admin.php:712 +#: includes/class-m365-login-admin.php:1112 msgid "If the token contains no \"email\" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts." msgstr "Enthält das Token keinen „email“-Claim, wird der UPN (z. B. jane@contoso.com) verwendet, sofern er eine gültige E-Mail-Adresse ist. Für Geschäftskonten meist erforderlich." -#: includes/class-m365-login-admin.php:719 +#: includes/class-m365-login-admin.php:1119 msgid "Keep users signed in (\"Remember me\")" msgstr "Benutzer angemeldet lassen („Angemeldet bleiben“)" -#: includes/class-m365-login-admin.php:720 +#: includes/class-m365-login-admin.php:1120 msgid "Issues a 14-day WordPress session instead of a browser session." msgstr "Erstellt eine 14-tägige WordPress-Sitzung statt einer Browser-Sitzung." -#: includes/class-m365-login-admin.php:725 +#: includes/class-m365-login-admin.php:1125 msgid "Allowed e-mail domains (optional)" msgstr "Erlaubte E-Mail-Domains (optional)" -#: includes/class-m365-login-admin.php:727 +#: includes/class-m365-login-admin.php:1127 msgid "One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant." msgstr "Eine oder mehrere Domains, getrennt durch Kommas oder Zeilenumbrüche. Leer lassen, um alle Domains des Tenants zuzulassen." -#: includes/class-m365-login-admin.php:732 +#: includes/class-m365-login-admin.php:1132 msgid "Allowed Entra groups (optional)" msgstr "Erlaubte Entra-Gruppen (optional)" -#: includes/class-m365-login-admin.php:733 +#: includes/class-m365-login-admin.php:1133 msgid "Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count." msgstr "Nur Mitglieder mindestens einer dieser Gruppen dürfen sich anmelden. Leer lassen, um alle zugeordneten Benutzer zuzulassen. Verschachtelte Mitgliedschaften zählen." -#: includes/class-m365-login-admin.php:736 -msgid "Search groups" -msgstr "Gruppen suchen" - -#: includes/class-m365-login-admin.php:738 -msgid "Type a group name or paste an object ID…" -msgstr "Gruppenname eingeben oder Objekt-ID einfügen …" - -#: includes/class-m365-login-admin.php:739 -msgid "Search" -msgstr "Suchen" - -#: includes/class-m365-login-admin.php:744 -msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." -msgstr "Benötigt die Anwendungsberechtigung „GroupMember.Read.All“ mit Administratorzustimmung. Ohne sie können Gruppen-Objekt-IDs trotzdem eingefügt werden." - -#: includes/class-m365-login-admin.php:750 -msgid "Selected groups" -msgstr "Ausgewählte Gruppen" - -#: includes/class-m365-login-admin.php:751 +#: includes/class-m365-login-admin.php:1135 msgid "No groups selected – every matched user may sign in." msgstr "Keine Gruppen ausgewählt – jeder zugeordnete Benutzer darf sich anmelden." -#: includes/class-m365-login-admin.php:761 +#: includes/class-m365-login-admin.php:1137 msgid "Membership is read from the \"groups\" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission \"User.Read.All\" or \"Directory.Read.All\"). If neither works, the sign-in is refused." msgstr "Die Mitgliedschaft wird aus dem „groups“-Claim des ID-Tokens gelesen, falls vorhanden; andernfalls fragt das Plugin Microsoft Graph (Anwendungsberechtigung „User.Read.All“ oder „Directory.Read.All“). Funktioniert beides nicht, wird die Anmeldung abgelehnt." -#: includes/class-m365-login-admin.php:766 +#: includes/class-m365-login-admin.php:1142 +msgid "Excluded Entra groups (optional)" +msgstr "Ausgeschlossene Entra-Gruppen (optional)" + +#: includes/class-m365-login-admin.php:1143 +msgid "Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count." +msgstr "Mitglieder dieser Gruppen können sich nie per Microsoft anmelden – auch nicht, wenn sie in einer erlaubten Gruppe sind. Verschachtelte Mitgliedschaften zählen." + +#: includes/class-m365-login-admin.php:1146 +msgid "Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above." +msgstr "Gruppenregeln brauchen eine feste Tenant-ID (GUID) im Tab „Verbindung“. Im Multi-Tenant-Modus kann die Gruppenprüfung Microsoft Graph nicht fragen, deshalb wird jede Anmeldung abgelehnt, solange hier oder oben Gruppen ausgewählt sind." + +#: includes/class-m365-login-admin.php:1148 +msgid "No groups excluded." +msgstr "Keine Gruppen ausgeschlossen." + +#: includes/class-m365-login-admin.php:1150 +msgid "The plugin asks Microsoft Graph on every sign-in (application permission \"User.Read.All\" or \"Directory.Read.All\"), because a \"groups\" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed." +msgstr "Das Plugin fragt bei jeder Anmeldung Microsoft Graph (Anwendungsberechtigung „User.Read.All“ oder „Directory.Read.All“), weil ein „groups“-Claim gefiltert sein kann und nicht beweist, dass jemand kein Mitglied ist. Schlägt die Prüfung fehl, wird die Anmeldung abgelehnt. Die Passwort-Anmeldung ist nicht betroffen – bei Bedarf mit dem Nur-Button-Modus kombinieren." + +#: includes/class-m365-login-admin.php:1155 msgid "Button-only mode" msgstr "Nur-Button-Modus" -#: includes/class-m365-login-admin.php:767 -msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected." -msgstr "Blendet die Benutzername/Passwort-Felder aus (auf wp-login.php und in wp_login_form()-Formularen) und lehnt jede interaktive Passwort-Anmeldung auf der Website ab, auch in eigenen Login-Formularen. Anwendungspasswörter, REST, XML-RPC und WP-CLI sind nicht betroffen." +#: includes/class-m365-login-admin.php:1156 +msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site – custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie." +msgstr "Blendet die Felder für Benutzername/Passwort aus (auf wp-login.php und in wp_login_form()-Formularen) und lehnt jede Anmeldung mit einem normalen Passwort auf der Website ab – auch in eigenen Login-Formularen, über XML-RPC und über Login-Endpunkte anderer Plugins. Anwendungspasswörter (REST, XML-RPC) und WP-CLI funktionieren weiter; API-Anfragen erhalten nie ein Login-Cookie." -#: includes/class-m365-login-admin.php:772 +#: includes/class-m365-login-admin.php:1161 msgid "Show only the Microsoft button on the login page" msgstr "Auf der Login-Seite nur den Microsoft-Button anzeigen" -#: includes/class-m365-login-admin.php:773 +#: includes/class-m365-login-admin.php:1162 msgid "Becomes active once the connection is configured. Make sure your own account can sign in via Microsoft before enabling this." msgstr "Wird aktiv, sobald die Verbindung eingerichtet ist. Vor dem Aktivieren sicherstellen, dass das eigene Konto sich per Microsoft anmelden kann." -#: includes/class-m365-login-admin.php:778 +#: includes/class-m365-login-admin.php:1167 msgid "Fallback link (keep it secret)" msgstr "Fallback-Link (geheim halten)" -#: includes/class-m365-login-admin.php:779 +#: includes/class-m365-login-admin.php:1168 msgid "Opening this link shows the password form again in that browser for 30 minutes and allows password sign-in there. Bookmark it somewhere safe – it is your way back in if Microsoft sign-in ever breaks." msgstr "Wer diesen Link öffnet, sieht in diesem Browser 30 Minuten lang wieder das Passwort-Formular und kann sich dort mit Passwort anmelden. Sicher aufbewahren – er ist der Weg zurück, falls die Microsoft-Anmeldung einmal nicht funktioniert." -#: includes/class-m365-login-admin.php:787 +#: includes/class-m365-login-admin.php:1176 msgid "Generate a new key when saving" msgstr "Beim Speichern einen neuen Schlüssel erzeugen" -#: includes/class-m365-login-admin.php:790 +#: includes/class-m365-login-admin.php:1179 msgid "A key is generated automatically the first time you save these settings." msgstr "Beim ersten Speichern dieser Einstellungen wird automatisch ein Schlüssel erzeugt." #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:796 +#: includes/class-m365-login-admin.php:1185 msgid "Emergency switch: add %s to wp-config.php to disable button-only mode entirely." msgstr "Notschalter: %s in die wp-config.php eintragen, um den Nur-Button-Modus vollständig abzuschalten." #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:805 +#: includes/class-m365-login-admin.php:1194 msgid "What the plugin does to keep sign-ins safe" msgstr "So schützt das Plugin die Anmeldung" -#: includes/class-m365-login-admin.php:807 +#: includes/class-m365-login-admin.php:1196 msgid "OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser." msgstr "OpenID Connect Authorization Code Flow mit PKCE (S256) – Tokens laufen nie durch den Browser." -#: includes/class-m365-login-admin.php:808 +#: includes/class-m365-login-admin.php:1197 msgid "Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection)." msgstr "Einmalige State- und Nonce-Werte, per HttpOnly-Cookie an den Browser gebunden (CSRF- und Replay-Schutz)." -#: includes/class-m365-login-admin.php:809 +#: includes/class-m365-login-admin.php:1198 msgid "ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked." msgstr "Signatur des ID-Tokens wird gegen Microsofts veröffentlichte Signaturschlüssel geprüft; Issuer, Audience, Tenant, Ablauf und Nonce werden kontrolliert." -#: includes/class-m365-login-admin.php:810 -msgid "Client secret encrypted at rest; no accounts are created, no passwords are changed." -msgstr "Client Secret verschlüsselt gespeichert; es werden keine Konten angelegt und keine Passwörter geändert." +#: includes/class-m365-login-admin.php:1199 +msgid "Client secret encrypted at rest; sign-in never creates accounts or changes passwords." +msgstr "Client Secret verschlüsselt gespeichert; die Anmeldung legt nie Konten an und ändert keine Passwörter." -#: includes/class-m365-login-admin.php:816 +#: includes/class-m365-login-admin.php:1207 msgid "Save changes" msgstr "Änderungen speichern" -#: includes/class-m365-login-admin.php:822 +#: includes/class-m365-login-admin.php:1213 msgid "Redirect URI" msgstr "Umleitungs-URI (Redirect URI)" -#: includes/class-m365-login-admin.php:823 +#: includes/class-m365-login-admin.php:1214 msgid "Register this URI in your app registration under Authentication → Web → Redirect URIs:" msgstr "Diese URI in der App-Registrierung unter Authentifizierung → Web → Umleitungs-URIs eintragen:" -#: includes/class-m365-login-admin.php:829 +#: includes/class-m365-login-admin.php:1220 msgid "Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID." msgstr "Einfache Permalinks sind aktiv, daher verwendet der Callback einen Query-String. Werden später sprechende Permalinks aktiviert, ändert sich die Umleitungs-URI und muss in Entra ID angepasst werden." -#: includes/class-m365-login-admin.php:832 +#: includes/class-m365-login-admin.php:1223 msgid "Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS." msgstr "Diese Website nutzt kein HTTPS. Microsoft akzeptiert http://-Umleitungs-URIs nur für localhost; produktive Websites benötigen HTTPS." -#: includes/class-m365-login-admin.php:837 +#: includes/class-m365-login-admin.php:1228 msgid "Setup guide: app registration" msgstr "Anleitung: App-Registrierung" -#: includes/class-m365-login-admin.php:839 +#: includes/class-m365-login-admin.php:1230 msgid "Open entra.microsoft.com → Identity → Applications → App registrations → New registration." msgstr "entra.microsoft.com → Identität → Anwendungen → App-Registrierungen → Neue Registrierung öffnen." -#: includes/class-m365-login-admin.php:840 +#: includes/class-m365-login-admin.php:1231 msgid "Name: e.g. \"WordPress login\". Supported account types: \"Accounts in this organizational directory only\" (single tenant)." msgstr "Name: z. B. „WordPress Login“. Unterstützte Kontotypen: „Nur Konten in diesem Organisationsverzeichnis“ (Single Tenant)." -#: includes/class-m365-login-admin.php:841 +#: includes/class-m365-login-admin.php:1232 msgid "Redirect URI: choose the platform Web and paste the URI shown above. Then click Register." msgstr "Umleitungs-URI: Plattform „Web“ wählen und die oben angezeigte URI einfügen. Dann auf „Registrieren“ klicken." -#: includes/class-m365-login-admin.php:842 +#: includes/class-m365-login-admin.php:1233 msgid "On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab." msgstr "Auf der Übersichtsseite die Anwendungs-ID (Client) und die Verzeichnis-ID (Mandant) in den Tab „Verbindung“ kopieren." -#: includes/class-m365-login-admin.php:843 +#: includes/class-m365-login-admin.php:1234 msgid "Authentication: leave \"ID tokens\" unchecked (the plugin uses the authorization code flow) and \"Allow public client flows\" on No." msgstr "Authentifizierung: „ID-Token“ nicht anhaken (das Plugin nutzt den Authorization Code Flow) und „Öffentliche Clientflows zulassen“ auf „Nein“ lassen." -#: includes/class-m365-login-admin.php:844 +#: includes/class-m365-login-admin.php:1235 msgid "Token configuration → Add optional claim → ID → tick \"email\" → Add. Confirm the API permission prompt." msgstr "Tokenkonfiguration → Optionalen Anspruch hinzufügen → ID → „email“ anhaken → Hinzufügen. Die Rückfrage zur API-Berechtigung bestätigen." -#: includes/class-m365-login-admin.php:845 +#: includes/class-m365-login-admin.php:1236 msgid "Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate)." msgstr "Im Tab „Verbindung“ die Authentifizierungsmethode wählen und der zugehörigen Schritt-für-Schritt-Anleitung folgen (Client Secret oder Zertifikat)." -#: includes/class-m365-login-admin.php:846 +#: includes/class-m365-login-admin.php:1237 msgid "Optional: restrict who may use the app under Enterprise applications → your app → Properties → \"Assignment required\" = Yes, then assign users/groups." msgstr "Optional: Unter Unternehmensanwendungen → Ihre App → Eigenschaften → „Zuweisung erforderlich“ = Ja einschränken, wer die App nutzen darf, und dann Benutzer/Gruppen zuweisen." -#: includes/class-m365-login-admin.php:848 +#: includes/class-m365-login-admin.php:1239 msgid "Required API permission: openid, profile, email (delegated) – granted by default." msgstr "Benötigte API-Berechtigungen: openid, profile, email (delegiert) – standardmäßig vorhanden." -#: includes/class-m365-login-admin.php:849 -msgid "Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." -msgstr "Optional für Gruppen-Beschränkungen: Anwendungsberechtigungen GroupMember.Read.All und User.Read.All (Microsoft Graph) mit Administratorzustimmung." +#: includes/class-m365-login-admin.php:1240 +msgid "Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." +msgstr "Optional für Gruppen-Beschränkungen und den Benutzer-Sync: Anwendungsberechtigungen GroupMember.Read.All und User.Read.All (Microsoft Graph) mit Administratorzustimmung." -#: includes/class-m365-login-admin.php:853 +#: includes/class-m365-login-admin.php:1244 msgid "Shortcode" msgstr "Shortcode" -#: includes/class-m365-login-admin.php:854 +#: includes/class-m365-login-admin.php:1245 msgid "Place the button on a custom login page:" msgstr "Button auf einer eigenen Login-Seite platzieren:" -#: includes/class-m365-login-admin.php:856 +#: includes/class-m365-login-admin.php:1247 msgid "More options on the Button tab under \"Custom login page\"." msgstr "Weitere Optionen im Tab „Button“ unter „Eigene Login-Seite“." -#: includes/class-m365-login-auth.php:173 +#: includes/class-m365-login-auth.php:203 msgid "Password sign-in is disabled on this site. Please use the Microsoft button." msgstr "Die Anmeldung mit Passwort ist auf dieser Website deaktiviert. Bitte den Microsoft-Button verwenden." -#: includes/class-m365-login-auth.php:823 +#: includes/class-m365-login-auth.php:235 includes/class-m365-login-auth.php:1336 +msgid "Passwords are not used on this site. Please sign in with the Microsoft button." +msgstr "Auf dieser Website werden keine Passwörter verwendet. Bitte melden Sie sich mit dem Microsoft-Button an." + +#: includes/class-m365-login-auth.php:1286 +msgid "Microsoft sign-in is temporarily unavailable. Please contact an administrator." +msgstr "Die Microsoft-Anmeldung ist vorübergehend nicht verfügbar. Bitte wenden Sie sich an einen Administrator." + +#: includes/class-m365-login-auth.php:1293 msgid "Password sign-in is temporarily enabled for this browser (30 minutes)." msgstr "Die Passwort-Anmeldung ist für diesen Browser vorübergehend aktiviert (30 Minuten)." -#: includes/class-m365-login-auth.php:844 includes/class-m365-login-graph.php:63 +#: includes/class-m365-login-auth.php:1314 includes/class-m365-login-graph.php:63 msgid "Microsoft login is not configured yet." msgstr "Die Microsoft-Anmeldung ist noch nicht eingerichtet." -#: includes/class-m365-login-auth.php:845 +#: includes/class-m365-login-auth.php:1315 msgid "The login request expired or was invalid. Please try again." msgstr "Die Anmeldeanfrage ist abgelaufen oder ungültig. Bitte erneut versuchen." -#: includes/class-m365-login-auth.php:846 +#: includes/class-m365-login-auth.php:1316 msgid "Microsoft sign-in was cancelled." msgstr "Die Microsoft-Anmeldung wurde abgebrochen." -#: includes/class-m365-login-auth.php:847 +#: includes/class-m365-login-auth.php:1317 msgid "Microsoft returned an error. Please try again." msgstr "Microsoft hat einen Fehler gemeldet. Bitte erneut versuchen." -#: includes/class-m365-login-auth.php:848 +#: includes/class-m365-login-auth.php:1318 msgid "Could not complete the sign-in with Microsoft. Please try again or contact an administrator." msgstr "Die Anmeldung über Microsoft konnte nicht abgeschlossen werden. Bitte erneut versuchen oder einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:849 +#: includes/class-m365-login-auth.php:1319 msgid "The Microsoft sign-in could not be verified." msgstr "Die Microsoft-Anmeldung konnte nicht verifiziert werden." -#: includes/class-m365-login-auth.php:850 +#: includes/class-m365-login-auth.php:1320 msgid "Your Microsoft account did not provide an e-mail address." msgstr "Das Microsoft-Konto hat keine E-Mail-Adresse übermittelt." -#: includes/class-m365-login-auth.php:851 +#: includes/class-m365-login-auth.php:1321 msgid "Your e-mail domain is not allowed to sign in here." msgstr "Diese E-Mail-Domain ist hier nicht zur Anmeldung zugelassen." -#: includes/class-m365-login-auth.php:852 +#: includes/class-m365-login-auth.php:1322 msgid "No WordPress account exists for your Microsoft e-mail address." msgstr "Für die E-Mail-Adresse des Microsoft-Kontos existiert kein WordPress-Konto." -#: includes/class-m365-login-auth.php:853 +#: includes/class-m365-login-auth.php:1323 msgid "This WordPress account is linked to a different Microsoft account. Please contact an administrator." msgstr "Dieses WordPress-Konto ist mit einem anderen Microsoft-Konto verknüpft. Bitte einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:854 +#: includes/class-m365-login-auth.php:1324 msgid "You are not allowed to sign in with this account." msgstr "Die Anmeldung mit diesem Konto ist nicht erlaubt." -#: includes/class-m365-login-auth.php:855 +#: includes/class-m365-login-auth.php:1325 msgid "Your Microsoft account is not a member of a group that is allowed to sign in here." msgstr "Das Microsoft-Konto ist in keiner Gruppe, die sich hier anmelden darf." -#: includes/class-m365-login-auth.php:856 +#: includes/class-m365-login-auth.php:1326 msgid "Your group membership could not be verified. Please contact an administrator." msgstr "Die Gruppenmitgliedschaft konnte nicht geprüft werden. Bitte einen Administrator kontaktieren." -#: includes/class-m365-login-auth.php:857 +#: includes/class-m365-login-auth.php:1327 +msgid "Your Microsoft account is a member of a group that is not allowed to sign in here." +msgstr "Ihr Microsoft-Konto ist Mitglied einer Gruppe, die sich hier nicht anmelden darf." + +#: includes/class-m365-login-auth.php:1328 msgid "The fallback key is not valid." msgstr "Der Fallback-Schlüssel ist ungültig." -#: includes/class-m365-login-auth.php:858 +#: includes/class-m365-login-auth.php:1329 msgid "Too many attempts. Please wait 15 minutes." msgstr "Zu viele Versuche. Bitte 15 Minuten warten." -#: includes/class-m365-login-auth.php:859 +#: includes/class-m365-login-auth.php:1330 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:1331 includes/class-m365-login-sync.php:2138 +msgid "This account has been deactivated." +msgstr "Dieses Konto wurde deaktiviert." + +#: includes/class-m365-login-auth.php:1332 +msgid "For security reasons this administrator account is not linked automatically. Sign in once with your password and click \"Link Microsoft account\" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile." +msgstr "Aus Sicherheitsgründen wird dieses Administrator-Konto nicht automatisch verknüpft. Melden Sie sich einmal mit Ihrem Passwort an und klicken Sie in Ihrem Profil auf „Mit Microsoft-Konto verknüpfen“ – oder bitten Sie einen Administrator, Ihr Microsoft-Konto (Benutzerprinzipalname) in Ihrem WordPress-Profil einzutragen." + +#: includes/class-m365-login-auth.php:1333 +msgid "The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again." +msgstr "Die Verknüpfung konnte nicht abgeschlossen werden, weil Sie nicht mehr bei WordPress angemeldet sind. Bitte melden Sie sich an und versuchen Sie es erneut." + +#: includes/class-m365-login-auth.php:1334 +msgid "Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile." +msgstr "Ihr WordPress-Konto ist bereits mit einem anderen Microsoft-Konto verknüpft. Ein Administrator kann die Verknüpfung in Ihrem Profil aufheben." + +#: includes/class-m365-login-auth.php:1335 +msgid "Guest and external accounts cannot sign in here." +msgstr "Gast- und externe Konten können sich hier nicht anmelden." + #: includes/class-m365-login-certificate.php:29 msgid "The PHP OpenSSL extension is not available." msgstr "Die PHP-Erweiterung OpenSSL ist nicht verfügbar." @@ -834,87 +1223,489 @@ msgstr "Es werden nur RSA-Schlüssel unterstützt." msgid "The RSA key must have at least 2048 bits." msgstr "Der RSA-Schlüssel muss mindestens 2048 Bit haben." -#: includes/class-m365-login-certificate.php:102 +#: includes/class-m365-login-certificate.php:101 +msgid "The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there." +msgstr "Das Zertifikatsfeld enthält einen privaten Schlüssel. Fügen Sie dort nur das Zertifikat ein (-----BEGIN CERTIFICATE-----)." + +#: includes/class-m365-login-certificate.php:105 includes/class-m365-login-certificate.php:119 msgid "The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----)." msgstr "Das Zertifikat konnte nicht gelesen werden. Bitte im PEM-Format einfügen (-----BEGIN CERTIFICATE-----)." -#: includes/class-m365-login-certificate.php:105 +#: includes/class-m365-login-certificate.php:108 msgid "The certificate does not belong to this private key." msgstr "Das Zertifikat gehört nicht zu diesem privaten Schlüssel." -#: includes/class-m365-login-certificate.php:110 +#: includes/class-m365-login-certificate.php:113 msgid "The certificate has already expired." msgstr "Das Zertifikat ist bereits abgelaufen." -#: includes/class-m365-login-graph.php:201 +#: includes/class-m365-login-graph.php:440 msgid "Group" msgstr "Gruppe" -#: includes/class-m365-login-graph.php:203 +#: includes/class-m365-login-graph.php:442 msgid "Security group" msgstr "Sicherheitsgruppe" -#: includes/class-m365-login-graph.php:205 +#: includes/class-m365-login-graph.php:444 msgid "Microsoft 365 group" msgstr "Microsoft 365-Gruppe" -#: includes/class-m365-login-settings.php:47 +#: includes/class-m365-login-graph.php:448 +msgid "Public Microsoft 365 group – anyone in the organisation can join" +msgstr "Öffentliche Microsoft-365-Gruppe – jeder in der Organisation kann beitreten" + +#: includes/class-m365-login-settings.php:55 msgid "Sign in with Microsoft" msgstr "Login mit Microsoft" -#: includes/class-m365-login-settings.php:56 +#: includes/class-m365-login-settings.php:64 msgid "or" msgstr "oder" -#: includes/class-m365-login-settings.php:197 includes/class-m365-login-settings.php:445 +#: includes/class-m365-login-settings.php:226 includes/class-m365-login-settings.php:559 msgid "The private key could not be encrypted. Is the OpenSSL extension available?" msgstr "Der private Schlüssel konnte nicht verschlüsselt werden. Ist die OpenSSL-Erweiterung verfügbar?" -#: includes/class-m365-login-settings.php:391 +#: includes/class-m365-login-settings.php:505 msgid "The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of \"organizations\", \"common\", \"consumers\"." msgstr "Die Tenant-ID muss eine GUID (z. B. 1a2b3c4d-…) oder einer der Werte „organizations“, „common“, „consumers“ sein." -#: includes/class-m365-login-settings.php:399 +#: includes/class-m365-login-settings.php:513 msgid "The application (client) ID must be a GUID." msgstr "Die Anwendungs-ID (Client) muss eine GUID sein." -#: includes/class-m365-login-settings.php:411 +#: includes/class-m365-login-settings.php:525 msgid "The client secret contains invalid characters." msgstr "Das Client Secret enthält ungültige Zeichen." -#: includes/class-m365-login-settings.php:415 +#: includes/class-m365-login-settings.php:529 msgid "The client secret could not be encrypted. Is the OpenSSL extension available?" msgstr "Das Client Secret konnte nicht verschlüsselt werden. Ist die OpenSSL-Erweiterung verfügbar?" -#: includes/class-m365-login-settings.php:435 +#: includes/class-m365-login-settings.php:549 msgid "Please paste both the private key and the certificate." msgstr "Bitte sowohl den privaten Schlüssel als auch das Zertifikat einfügen." -#: includes/class-m365-login-settings.php:437 +#: includes/class-m365-login-settings.php:551 msgid "The pasted key or certificate is too large." msgstr "Der eingefügte Schlüssel oder das Zertifikat ist zu groß." -#: includes/class-m365-login-settings.php:454 +#: includes/class-m365-login-settings.php:568 msgid "Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then." msgstr "Zertifikats-Authentifizierung ist ausgewählt, aber es ist noch kein Zertifikat gespeichert. Eines erzeugen oder ein eigenes einfügen; bis dahin bleibt der Microsoft-Button ausgeblendet." -#: includes/class-m365-login-settings.php:500 +#: includes/class-m365-login-settings.php:601 msgid "The custom login page must be a URL on this site." msgstr "Die eigene Login-Seite muss eine URL dieser Website sein." -#: includes/class-m365-login.php:102 +#: includes/class-m365-login-settings.php:711 +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:136 +msgid "Display name" +msgstr "Anzeigename" + +#: includes/class-m365-login-sync.php:140 +msgid "First name" +msgstr "Vorname" + +#: includes/class-m365-login-sync.php:144 +msgid "Last name" +msgstr "Nachname" + +#: includes/class-m365-login-sync.php:148 +msgid "Profile photo (used as avatar)" +msgstr "Profilbild (als Avatar)" + +#: includes/class-m365-login-sync.php:152 +msgid "Job title" +msgstr "Position" + +#: includes/class-m365-login-sync.php:156 +msgid "Department" +msgstr "Abteilung" + +#: includes/class-m365-login-sync.php:160 +msgid "Company" +msgstr "Firma" + +#: includes/class-m365-login-sync.php:164 +msgid "Office" +msgstr "Büro" + +#: includes/class-m365-login-sync.php:168 +msgid "Employee ID" +msgstr "Personalnummer" + +#: includes/class-m365-login-sync.php:172 +msgid "Business phone" +msgstr "Telefon (geschäftlich)" + +#: includes/class-m365-login-sync.php:176 +msgid "Mobile phone" +msgstr "Mobiltelefon" + +#: includes/class-m365-login-sync.php:180 +msgid "Street address" +msgstr "Straße" + +#: includes/class-m365-login-sync.php:184 +msgid "Postal code" +msgstr "Postleitzahl" + +#: includes/class-m365-login-sync.php:188 +msgid "City" +msgstr "Ort" + +#: includes/class-m365-login-sync.php:192 +msgid "State / province" +msgstr "Bundesland / Region" + +#: includes/class-m365-login-sync.php:196 +msgid "Country" +msgstr "Land" + +#: includes/class-m365-login-sync.php:200 +msgid "Language (sets the admin language if installed)" +msgstr "Sprache (setzt die Backend-Sprache, falls installiert)" + +#: includes/class-m365-login-sync.php:336 +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:418 +msgid "The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use \"wp m365-login sync\"." +msgstr "Der Sync wurde unerwartet beendet (PHP-Fehler, Speicher- oder Zeitlimit). Konten danach wurden nicht verarbeitet, nichts wurde deaktiviert oder gelöscht. Für große Verzeichnisse „wp m365-login sync“ verwenden." + +#: includes/class-m365-login-sync.php:480 +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:484 +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:488 +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:500 +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." + +#: includes/class-m365-login-sync.php:511 +msgid "Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups." +msgstr "Microsoft 365 hat überhaupt keine Benutzer geliefert, obwohl Konten verknüpft sind. Es wurde nichts geändert. Prüfen Sie den Tenant und die Sync-Gruppen." + +#. translators: %d: number of accounts +#: includes/class-m365-login-sync.php:585 +msgid "%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed." +msgid_plural "%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed." +msgstr[0] "%d verknüpftes Konto gehört zu einem anderen (oder unbekannten) Tenant und wurde nicht deaktiviert oder gelöscht. Verknüpfung bei Bedarf von Hand aufheben." +msgstr[1] "%d verknüpfte Konten gehören zu einem anderen (oder unbekannten) Tenant und wurden nicht deaktiviert oder gelöscht. Verknüpfung bei Bedarf von Hand aufheben." + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:596 +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)." + +#: includes/class-m365-login-sync.php:722 includes/class-m365-login-sync.php:785 includes/class-m365-login-sync.php:1233 includes/class-m365-login-sync.php:2283 +msgid "disabled in Microsoft 365" +msgstr "in Microsoft 365 deaktiviert" + +#. translators: %s: user principal name +#: includes/class-m365-login-sync.php:733 +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:740 +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:761 +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:766 +msgid "%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped." +msgstr "%s: privilegiertes WordPress-Konto – wird nur verknüpft, wenn der Microsoft-Benutzerprinzipalname seiner E-Mail-Adresse oder dem im Profil zugewiesenen Microsoft-Konto entspricht, oder wenn die Person es im Profil selbst verknüpft. Übersprungen." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:770 +msgid "%s: existing account linked." +msgstr "%s: bestehendes Konto verknüpft." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:796 +msgid "%s: added to this site." +msgstr "%s: zu dieser Website hinzugefügt." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:805 +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:816 +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:843 +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:870 +msgid "%1$s: account could not be created: %2$s" +msgstr "%1$s: Konto konnte nicht angelegt werden: %2$s" + +#. translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 +#: includes/class-m365-login-sync.php:929 +msgid "%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended." +msgstr "%1$s: Die E-Mail-Adresse in Microsoft 365 wurde zu %2$s geändert. Bei privilegierten Konten wird sie nicht automatisch geändert – bei Bedarf von Hand anpassen." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:932 +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:935 +msgid "e-mail" +msgstr "E-Mail" + +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:985 +msgid "%s removed" +msgstr "%s entfernt" + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:995 +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:1104 +msgid "roles: %s" +msgstr "Rollen: %s" + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:1174 +msgid "Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit)." +msgstr "Sicherheitsstopp: %1$d Konten würden Administrationsrechte verlieren, mehr als das Limit von %2$d pro Lauf (oder alle). Es wurde nichts herabgestuft, deaktiviert oder gelöscht. Prüfen Sie die Gruppen-Rollen-Zuordnung und starten Sie den Sync dann erneut (Filter m365_login_sync_demotion_limit)." + +#: includes/class-m365-login-sync.php:1225 includes/class-m365-login-sync.php:2284 +msgid "deleted in Microsoft 365" +msgstr "in Microsoft 365 gelöscht" + +#: includes/class-m365-login-sync.php:1236 includes/class-m365-login-sync.php:2285 +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:1268 +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:1412 +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:1421 +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:1430 +msgid "%1$s: account deactivated (%2$s)." +msgstr "%1$s: Konto deaktiviert (%2$s)." + +#: includes/class-m365-login-sync.php:1509 +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:1512 +msgid "Microsoft Graph error: %s" +msgstr "Microsoft-Graph-Fehler: %s" + +#: includes/class-m365-login-sync.php:1530 +msgid "Log truncated." +msgstr "Protokoll gekürzt." + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:1613 +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:1637 includes/class-m365-login-sync.php:1680 +msgid "%s: profile photo updated." +msgstr "%s: Profilbild aktualisiert." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1656 +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:1663 +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: %d: number of photos +#: includes/class-m365-login-sync.php:1686 +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)." + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1701 +msgid "%s: profile photo removed." +msgstr "%s: Profilbild entfernt." + +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1729 +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:1901 includes/class-m365-login-sync.php:1915 +msgid "Microsoft 365 (M365 Login)" +msgstr "Microsoft 365 (M365 Login)" + +#: includes/class-m365-login-sync.php:1933 +msgid "Microsoft object ID" +msgstr "Microsoft-Objekt-ID" + +#: includes/class-m365-login-sync.php:1934 +msgid "Microsoft tenant ID" +msgstr "Microsoft-Tenant-ID" + +#: includes/class-m365-login-sync.php:1945 +msgid "Profile photo" +msgstr "Profilbild" + +#: includes/class-m365-login-sync.php:1949 includes/class-m365-login-sync.php:2300 +msgid "Last sync" +msgstr "Letzter Sync" + +#: includes/class-m365-login-sync.php:1963 includes/class-m365-login-sync.php:2167 includes/class-m365-login-sync.php:2314 +msgid "Microsoft 365" +msgstr "Microsoft 365" + +#: includes/class-m365-login-sync.php:2004 +msgid "The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync." +msgstr "Die Verknüpfung mit dem Microsoft-Konto und eine eventuelle Deaktivierung wurden behalten, weil sie das Konto absichern. Der nächste Benutzer-Sync überträgt ausgewählte Profilfelder erneut, sofern die Person nicht vom Sync ausgenommen ist." + +#: includes/class-m365-login-sync.php:2185 +msgid "Deactivated" +msgstr "Deaktiviert" + +#: includes/class-m365-login-sync.php:2188 +msgid "Imported" +msgstr "Importiert" + +#: includes/class-m365-login-sync.php:2190 includes/class-m365-login-sync.php:2320 +msgid "Linked" +msgstr "Verknüpft" + +#: includes/class-m365-login-sync.php:2218 +msgid "Reactivate" +msgstr "Reaktivieren" + +#: includes/class-m365-login-sync.php:2218 +msgid "Deactivate" +msgstr "Deaktivieren" + +#: includes/class-m365-login-sync.php:2247 +msgid "Your Microsoft account is now linked. From now on you can sign in with the Microsoft button." +msgstr "Ihr Microsoft-Konto ist jetzt verknüpft. Ab sofort können Sie sich mit dem Microsoft-Button anmelden." + +#: includes/class-m365-login-sync.php:2261 +msgid "The account has been deactivated and signed out everywhere." +msgstr "Das Konto wurde deaktiviert und überall abgemeldet." + +#: includes/class-m365-login-sync.php:2262 +msgid "The account has been reactivated." +msgstr "Das Konto wurde reaktiviert." + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2289 +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:2291 +msgid "manually" +msgstr "manuell" + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2293 +msgid "Status" +msgstr "Status" + +#: includes/class-m365-login-sync.php:2296 +msgid "Object ID" +msgstr "Objekt-ID" + +#: includes/class-m365-login-sync.php:2317 +msgid "Microsoft account" +msgstr "Microsoft-Konto" + +#: includes/class-m365-login-sync.php:2322 +msgid "Not linked" +msgstr "Nicht verknüpft" + +#: includes/class-m365-login-sync.php:2325 +msgid "Link Microsoft account" +msgstr "Mit Microsoft-Konto verknüpfen" + +#: includes/class-m365-login-sync.php:2326 +msgid "You sign in with Microsoft once; afterwards this WordPress account is bound to that Microsoft account, even if its user principal name differs from your e-mail address." +msgstr "Sie melden sich einmal bei Microsoft an; danach ist dieses WordPress-Konto an dieses Microsoft-Konto gebunden – auch wenn dessen Benutzerprinzipalname von Ihrer E-Mail-Adresse abweicht." + +#: includes/class-m365-login-sync.php:2332 +msgid "Assigned Microsoft account (UPN)" +msgstr "Zugewiesenes Microsoft-Konto (UPN)" + +#: includes/class-m365-login-sync.php:2335 +msgid "Optional. The user principal name of the Microsoft account that belongs to this user. Sign-in and user sync link exactly this account – needed for administrators whose user principal name differs from their WordPress e-mail address." +msgstr "Optional. Der Benutzerprinzipalname des Microsoft-Kontos, das zu diesem Benutzer gehört. Anmeldung und Benutzer-Sync verknüpfen genau dieses Konto – nötig für Administratoren, deren Benutzerprinzipalname von ihrer WordPress-E-Mail-Adresse abweicht." + +#: includes/class-m365-login-sync.php:2337 +msgid "Remove the link to the Microsoft account" +msgstr "Verknüpfung mit dem Microsoft-Konto aufheben" + +#: includes/class-m365-login-sync.php:2350 +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." + +#: includes/class-m365-login.php:127 msgid "Settings" msgstr "Einstellungen" -#: includes/class-m365-login.php:113 +#: includes/class-m365-login.php:138 msgid "M365 Login requires PHP 7.4 or newer." msgstr "M365 Login benötigt PHP 7.4 oder neuer." -#: includes/class-m365-login.php:114 includes/class-m365-login.php:123 +#: includes/class-m365-login.php:139 includes/class-m365-login.php:148 msgid "Plugin activation failed" msgstr "Plugin-Aktivierung fehlgeschlagen" -#: includes/class-m365-login.php:122 +#: includes/class-m365-login.php:147 msgid "M365 Login requires the PHP OpenSSL extension (needed to verify Microsoft token signatures and to encrypt the client secret)." msgstr "M365 Login benötigt die PHP-Erweiterung OpenSSL (zur Prüfung der Microsoft-Token-Signaturen und zur Verschlüsselung des Client Secrets)." - diff --git a/languages/m365-login.pot b/languages/m365-login.pot index 48fd0bc..fa0240c 100644 --- a/languages/m365-login.pot +++ b/languages/m365-login.pot @@ -2,796 +2,1185 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: M365 Login 1.0.0\n" +"Project-Id-Version: M365 Login 1.1.0\n" "Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-09-22T00:00:00+00:00\n" +"POT-Creation-Date: 2026-09-23T00:00:00+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "X-Generator: bin/make-pot.py\n" "X-Domain: m365-login\n" -#: includes/class-m365-login-admin.php:81 includes/class-m365-login-admin.php:82 includes/class-m365-login-admin.php:93 includes/class-m365-login-admin.php:382 +#: includes/class-m365-login-admin.php:108 includes/class-m365-login-admin.php:109 includes/class-m365-login-admin.php:120 includes/class-m365-login-admin.php:779 msgid "M365 Login" msgstr "" -#: includes/class-m365-login-admin.php:108 +#: includes/class-m365-login-admin.php:135 msgid "Connection" msgstr "" -#: includes/class-m365-login-admin.php:109 +#: includes/class-m365-login-admin.php:136 msgid "Button" msgstr "" -#: includes/class-m365-login-admin.php:110 +#: includes/class-m365-login-admin.php:137 msgid "Security" msgstr "" -#: includes/class-m365-login-admin.php:185 -msgid "M365 Login is active but not connected to Microsoft Entra ID yet." +#: includes/class-m365-login-admin.php:138 +msgid "User sync" msgstr "" -#: includes/class-m365-login-admin.php:187 +#: includes/class-m365-login-admin.php:207 +msgid "M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link." +msgstr "" + +#: includes/class-m365-login-admin.php:209 includes/class-m365-login-admin.php:227 msgid "Open the settings" msgstr "" -#: includes/class-m365-login-admin.php:217 +#: includes/class-m365-login-admin.php:225 +msgid "M365 Login is active but not connected to Microsoft Entra ID yet." +msgstr "" + +#: includes/class-m365-login-admin.php:263 msgid "Choose button icon" msgstr "" -#: includes/class-m365-login-admin.php:218 +#: includes/class-m365-login-admin.php:264 msgid "Use this icon" msgstr "" -#: includes/class-m365-login-admin.php:219 +#: includes/class-m365-login-admin.php:265 msgid "Copied!" msgstr "" -#: includes/class-m365-login-admin.php:220 includes/class-m365-login-admin.php:505 includes/class-m365-login-admin.php:783 includes/class-m365-login-admin.php:826 +#: includes/class-m365-login-admin.php:266 includes/class-m365-login-admin.php:905 includes/class-m365-login-admin.php:1172 includes/class-m365-login-admin.php:1217 msgid "Copy" msgstr "" -#: includes/class-m365-login-admin.php:221 +#: includes/class-m365-login-admin.php:267 msgid "Testing…" msgstr "" -#: includes/class-m365-login-admin.php:222 +#: includes/class-m365-login-admin.php:268 msgid "The tenant could not be reached. Check the tenant ID and the server’s outgoing connections." msgstr "" -#: includes/class-m365-login-admin.php:223 +#: includes/class-m365-login-admin.php:269 msgid "No groups found." msgstr "" -#: includes/class-m365-login-admin.php:224 +#: includes/class-m365-login-admin.php:270 msgid "Searching…" msgstr "" -#: includes/class-m365-login-admin.php:225 +#: includes/class-m365-login-admin.php:271 msgid "Add" msgstr "" -#: includes/class-m365-login-admin.php:226 includes/class-m365-login-admin.php:757 +#: includes/class-m365-login-admin.php:272 includes/class-m365-login-admin.php:528 msgid "Remove" msgstr "" -#: includes/class-m365-login-admin.php:227 includes/class-m365-login-admin.php:285 includes/class-m365-login-admin.php:742 +#: includes/class-m365-login-admin.php:273 includes/class-m365-login-admin.php:336 includes/class-m365-login-admin.php:501 msgid "Save the connection settings first, then search for groups." msgstr "" -#: includes/class-m365-login-admin.php:228 +#: includes/class-m365-login-admin.php:274 msgid "Generate a new fallback key on save? The old link stops working." msgstr "" -#: includes/class-m365-login-admin.php:229 +#: includes/class-m365-login-admin.php:275 msgid "Generating a 3072-bit key pair, this takes a moment…" msgstr "" -#: includes/class-m365-login-admin.php:230 +#: includes/class-m365-login-admin.php:276 msgid "Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:231 +#: includes/class-m365-login-admin.php:277 msgid "Remove the stored certificate when saving? Sign-in with the certificate method stops working." msgstr "" -#: includes/class-m365-login-admin.php:243 includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:308 includes/class-m365-login-admin.php:340 +#: includes/class-m365-login-admin.php:278 +msgid "Sync is running, this can take a while for large directories…" +msgstr "" + +#: includes/class-m365-login-admin.php:279 +msgid "Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first." +msgstr "" + +#: includes/class-m365-login-admin.php:280 +msgid "The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use \"wp m365-login sync\" (WP-CLI)." +msgstr "" + +#: includes/class-m365-login-admin.php:281 +msgid "You have unsaved changes. The sync uses the saved settings – save first." +msgstr "" + +#: includes/class-m365-login-admin.php:282 includes/class-m365-login-admin.php:515 +msgid "Move up" +msgstr "" + +#: includes/class-m365-login-admin.php:294 includes/class-m365-login-admin.php:333 includes/class-m365-login-admin.php:359 includes/class-m365-login-admin.php:392 includes/class-m365-login-admin.php:566 includes/class-m365-login-sync.php:2230 msgid "You are not allowed to do this." msgstr "" -#: includes/class-m365-login-admin.php:248 +#: includes/class-m365-login-admin.php:299 msgid "Please enter a valid tenant ID first." msgstr "" #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:262 +#: includes/class-m365-login-admin.php:313 msgid "Microsoft answered with HTTP %d. Is the tenant ID correct?" msgstr "" #. translators: %d: HTTP status code -#: includes/class-m365-login-admin.php:271 +#: includes/class-m365-login-admin.php:322 msgid "Tenant reachable. The OpenID configuration was loaded successfully." msgstr "" -#: includes/class-m365-login-admin.php:294 +#: includes/class-m365-login-admin.php:345 msgid "Microsoft Graph refused the request. Grant the application permission \"GroupMember.Read.All\" (or \"Directory.Read.All\") with admin consent in Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:312 +#: includes/class-m365-login-admin.php:363 msgid "Unknown operation." msgstr "" -#: includes/class-m365-login-admin.php:329 +#: includes/class-m365-login-admin.php:380 msgid "Certificate generated and stored. Download the .cer file and upload it in Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:346 -msgid "No certificate is stored." -msgstr "" - -#: includes/class-m365-login-admin.php:364 -msgid "You are not allowed to access this page." -msgstr "" - -#: includes/class-m365-login-admin.php:383 -msgid "Let existing users sign in with their Microsoft 365 / Entra ID account." -msgstr "" - -#: includes/class-m365-login-admin.php:388 -msgid "Connected" -msgstr "" - -#: includes/class-m365-login-admin.php:388 -msgid "Setup incomplete" -msgstr "" - -#: includes/class-m365-login-admin.php:410 -msgid "Microsoft Entra ID app registration" +#: includes/class-m365-login-admin.php:407 +msgid "The sync has not run yet." msgstr "" #: includes/class-m365-login-admin.php:411 -msgid "Enter the values from your app registration in the Microsoft Entra admin center." +msgid "Finished" +msgstr "" + +#: includes/class-m365-login-admin.php:412 +msgid "Failed" +msgstr "" + +#: includes/class-m365-login-admin.php:413 +msgid "Stopped by the safety limit" msgstr "" #: includes/class-m365-login-admin.php:414 -msgid "Directory (tenant) ID" +msgid "Not started" msgstr "" #: includes/class-m365-login-admin.php:417 -msgid "Test tenant" +msgid "started manually" +msgstr "" + +#: includes/class-m365-login-admin.php:418 +msgid "scheduled" msgstr "" #: includes/class-m365-login-admin.php:419 -msgid "Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. \"organizations\" allows any work or school account." +msgid "WP-CLI" msgstr "" -#: includes/class-m365-login-admin.php:421 -msgid "Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their \"email\" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID." +#: includes/class-m365-login-admin.php:422 +msgid "would be created" +msgstr "" + +#: includes/class-m365-login-admin.php:422 +msgid "created" +msgstr "" + +#: includes/class-m365-login-admin.php:423 +msgid "would be updated" +msgstr "" + +#: includes/class-m365-login-admin.php:423 +msgid "updated" +msgstr "" + +#: includes/class-m365-login-admin.php:424 +msgid "would be linked" +msgstr "" + +#: includes/class-m365-login-admin.php:424 +msgid "linked" +msgstr "" + +#: includes/class-m365-login-admin.php:425 +msgid "unchanged" +msgstr "" + +#: includes/class-m365-login-admin.php:426 +msgid "would be deactivated" +msgstr "" + +#: includes/class-m365-login-admin.php:426 +msgid "deactivated" msgstr "" #: includes/class-m365-login-admin.php:427 +msgid "would be reactivated" +msgstr "" + +#: includes/class-m365-login-admin.php:427 +msgid "reactivated" +msgstr "" + +#: includes/class-m365-login-admin.php:428 +msgid "would be deleted" +msgstr "" + +#: includes/class-m365-login-admin.php:428 +msgid "deleted" +msgstr "" + +#: includes/class-m365-login-admin.php:429 +msgid "photos" +msgstr "" + +#: includes/class-m365-login-admin.php:430 +msgid "skipped" +msgstr "" + +#: includes/class-m365-login-admin.php:431 +msgid "errors" +msgstr "" + +#: includes/class-m365-login-admin.php:444 +msgid "Dry run – nothing was changed" +msgstr "" + +#. translators: 1: date and time, 2: how the run was started, 3: duration in seconds +#: includes/class-m365-login-admin.php:449 +msgid "%1$s, %2$s, %3$d s" +msgstr "" + +#. translators: %d: number of log entries +#: includes/class-m365-login-admin.php:467 +msgid "Log (%d entry)" +msgid_plural "Log (%d entries)" +msgstr[0] "" +msgstr[1] "" + +#: includes/class-m365-login-admin.php:495 +msgid "Search groups" +msgstr "" + +#: includes/class-m365-login-admin.php:497 +msgid "Type a group name or paste an object ID…" +msgstr "" + +#: includes/class-m365-login-admin.php:498 +msgid "Search" +msgstr "" + +#: includes/class-m365-login-admin.php:503 +msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." +msgstr "" + +#: includes/class-m365-login-admin.php:509 +msgid "Selected groups" +msgstr "" + +#: includes/class-m365-login-admin.php:521 +msgid "WordPress role" +msgstr "" + +#: includes/class-m365-login-admin.php:573 +msgid "No certificate is stored." +msgstr "" + +#: includes/class-m365-login-admin.php:598 +msgid "Do nothing" +msgstr "" + +#: includes/class-m365-login-admin.php:599 +msgid "Deactivate the WordPress account" +msgstr "" + +#: includes/class-m365-login-admin.php:600 +msgid "Delete the WordPress account" +msgstr "" + +#: includes/class-m365-login-admin.php:603 +msgid "Account disabled in Microsoft 365 (sign-in blocked)" +msgstr "" + +#: includes/class-m365-login-admin.php:604 +msgid "Account deleted in Microsoft 365" +msgstr "" + +#: includes/class-m365-login-admin.php:605 +msgid "No longer a member of the sync groups" +msgstr "" + +#: includes/class-m365-login-admin.php:610 +msgid "Import users from Microsoft 365" +msgstr "" + +#: includes/class-m365-login-admin.php:611 +msgid "Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button." +msgstr "" + +#: includes/class-m365-login-admin.php:616 +msgid "Run the sync automatically" +msgstr "" + +#: includes/class-m365-login-admin.php:617 +msgid "Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run \"wp m365-login sync\"." +msgstr "" + +#: includes/class-m365-login-admin.php:623 +msgid "Interval" +msgstr "" + +#: includes/class-m365-login-admin.php:625 +msgid "Hourly" +msgstr "" + +#: includes/class-m365-login-admin.php:626 +msgid "Twice daily" +msgstr "" + +#: includes/class-m365-login-admin.php:627 +msgid "Daily" +msgstr "" + +#. translators: %s: date and time +#: includes/class-m365-login-admin.php:631 +msgid "Next run: %s" +msgstr "" + +#: includes/class-m365-login-admin.php:639 +msgid "Also import guest users (B2B)" +msgstr "" + +#: includes/class-m365-login-admin.php:640 +msgid "Guests are external people invited into your tenant. Off by default." +msgstr "" + +#: includes/class-m365-login-admin.php:644 +msgid "Which users? (optional)" +msgstr "" + +#: includes/class-m365-login-admin.php:645 +msgid "Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well." +msgstr "" + +#: includes/class-m365-login-admin.php:646 +msgid "No groups selected – all users of the tenant are imported." +msgstr "" + +#: includes/class-m365-login-admin.php:650 +msgid "Roles" +msgstr "" + +#: includes/class-m365-login-admin.php:653 +msgid "Default role" +msgstr "" + +#: includes/class-m365-login-admin.php:657 +msgid "Every imported user gets this role. The sync manages the roles of imported accounts – manual role changes are overwritten on the next run." +msgstr "" + +#: includes/class-m365-login-admin.php:660 +msgid "Additional roles from Microsoft 365 groups" +msgstr "" + +#: includes/class-m365-login-admin.php:661 +msgid "Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync." +msgstr "" + +#: includes/class-m365-login-admin.php:662 +msgid "Whoever can change a group's members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) – never public Microsoft 365 groups or Teams, which members can join themselves." +msgstr "" + +#: includes/class-m365-login-admin.php:663 +msgid "No group mapping – everybody gets the default role." +msgstr "" + +#: includes/class-m365-login-admin.php:666 +msgid "How are mapped roles applied?" +msgstr "" + +#: includes/class-m365-login-admin.php:669 +msgid "In addition to the default role (a user can have several roles)" +msgstr "" + +#: includes/class-m365-login-admin.php:673 +msgid "Instead of the default role – the first matching group in the list wins (use ↑ to reorder)" +msgstr "" + +#: includes/class-m365-login-admin.php:680 +msgid "Also manage the roles of accounts that existed before the sync" +msgstr "" + +#: includes/class-m365-login-admin.php:681 +msgid "Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed." +msgstr "" + +#: includes/class-m365-login-admin.php:687 +msgid "Profile fields" +msgstr "" + +#: includes/class-m365-login-admin.php:688 +msgid "Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with \"m365_\" – usable by themes and other plugins – and is shown on the profile screen." +msgstr "" + +#: includes/class-m365-login-admin.php:697 +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:701 +msgid "Disabled and deleted Microsoft 365 accounts" +msgstr "" + +#: includes/class-m365-login-admin.php:702 +msgid "Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all – not with Microsoft, a password or an application password – and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account." +msgstr "" + +#: includes/class-m365-login-admin.php:713 +msgid "Only relevant when the import is limited to groups." +msgstr "" + +#: includes/class-m365-login-admin.php:719 +msgid "Posts of deleted accounts go to" +msgstr "" + +#: includes/class-m365-login-admin.php:727 +msgid "— Select a user —" +msgstr "" + +#: includes/class-m365-login-admin.php:734 +msgid "Required for \"Delete\". Without a user, accounts are deactivated instead, so no content is ever lost." +msgstr "" + +#: includes/class-m365-login-admin.php:737 +msgid "Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated." +msgstr "" + +#: includes/class-m365-login-admin.php:741 +msgid "Run the sync" +msgstr "" + +#: includes/class-m365-login-admin.php:742 +msgid "The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything." +msgstr "" + +#: includes/class-m365-login-admin.php:744 +msgid "Dry run" +msgstr "" + +#: includes/class-m365-login-admin.php:745 +msgid "Sync now" +msgstr "" + +#: includes/class-m365-login-admin.php:747 +msgid "Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used." +msgstr "" + +#: includes/class-m365-login-admin.php:761 +msgid "You are not allowed to access this page." +msgstr "" + +#: includes/class-m365-login-admin.php:780 +msgid "Let existing users sign in with their Microsoft 365 / Entra ID account." +msgstr "" + +#: includes/class-m365-login-admin.php:785 +msgid "Connected" +msgstr "" + +#: includes/class-m365-login-admin.php:785 +msgid "Setup incomplete" +msgstr "" + +#: includes/class-m365-login-admin.php:807 +msgid "Microsoft Entra ID app registration" +msgstr "" + +#: includes/class-m365-login-admin.php:808 +msgid "Enter the values from your app registration in the Microsoft Entra admin center." +msgstr "" + +#: includes/class-m365-login-admin.php:811 +msgid "Directory (tenant) ID" +msgstr "" + +#: includes/class-m365-login-admin.php:814 +msgid "Test tenant" +msgstr "" + +#: includes/class-m365-login-admin.php:816 +msgid "Recommended: the GUID of your tenant. Only sign-ins from this tenant are accepted. \"organizations\" allows any work or school account." +msgstr "" + +#: includes/class-m365-login-admin.php:818 +msgid "Multi-tenant mode: accounts from any Microsoft tenant can sign in. Their \"email\" attribute is not verified, so the plugin matches on the user principal name (verified domain) only and ignores the e-mail claim unless Microsoft marks it as domain-verified. Use the e-mail domain allow-list on the Security tab, or better, pin your tenant GUID." +msgstr "" + +#: includes/class-m365-login-admin.php:824 msgid "Application (client) ID" msgstr "" -#: includes/class-m365-login-admin.php:432 +#: includes/class-m365-login-admin.php:829 msgid "How should WordPress authenticate to Microsoft?" msgstr "" -#: includes/class-m365-login-admin.php:437 includes/class-m365-login-admin.php:454 +#: includes/class-m365-login-admin.php:834 includes/class-m365-login-admin.php:851 msgid "Client secret" msgstr "" -#: includes/class-m365-login-admin.php:438 +#: includes/class-m365-login-admin.php:835 msgid "Quick to set up. A password-like value created in Entra ID that expires after 6–24 months and must be renewed." msgstr "" -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Certificate" msgstr "" -#: includes/class-m365-login-admin.php:444 +#: includes/class-m365-login-admin.php:841 msgid "Recommended" msgstr "" -#: includes/class-m365-login-admin.php:445 +#: includes/class-m365-login-admin.php:842 msgid "The private key never leaves this server; only the public certificate is uploaded to Entra ID. Generated here with one click, valid for 2 years." msgstr "" -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "•••••••••••• (stored, leave empty to keep)" msgstr "" -#: includes/class-m365-login-admin.php:456 +#: includes/class-m365-login-admin.php:853 msgid "Paste the secret value" msgstr "" -#: includes/class-m365-login-admin.php:457 +#: includes/class-m365-login-admin.php:854 msgid "Show secret" msgstr "" -#: includes/class-m365-login-admin.php:462 +#: includes/class-m365-login-admin.php:859 msgid "Remove the stored secret" msgstr "" -#: includes/class-m365-login-admin.php:465 +#: includes/class-m365-login-admin.php:862 msgid "Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire – note the expiry date in Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:469 +#: includes/class-m365-login-admin.php:864 +msgid "AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database – right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective." +msgstr "" + +#: includes/class-m365-login-admin.php:869 msgid "Step-by-step: create a client secret in Entra ID" msgstr "" -#: includes/class-m365-login-admin.php:472 +#: includes/class-m365-login-admin.php:872 msgid "Open entra.microsoft.com and sign in with an account that has the \"Application Administrator\" or \"Global Administrator\" role." msgstr "" -#: includes/class-m365-login-admin.php:473 +#: includes/class-m365-login-admin.php:873 msgid "Go to Identity → Applications → App registrations and open your app (or create it first, see the general guide in the sidebar)." msgstr "" -#: includes/class-m365-login-admin.php:474 +#: includes/class-m365-login-admin.php:874 msgid "In the left menu choose Certificates & secrets, then the tab Client secrets, and click New client secret." msgstr "" -#: includes/class-m365-login-admin.php:475 +#: includes/class-m365-login-admin.php:875 msgid "Enter a description such as \"WordPress login\" and pick an expiry. Microsoft allows at most 24 months; put a reminder in your calendar two weeks before." msgstr "" -#: includes/class-m365-login-admin.php:476 +#: includes/class-m365-login-admin.php:876 msgid "Click Add. Copy the Value column immediately – it is shown only once. The Secret ID column is NOT what you need." msgstr "" -#: includes/class-m365-login-admin.php:477 +#: includes/class-m365-login-admin.php:877 msgid "Paste the value into the Client secret field above and save this page." msgstr "" -#: includes/class-m365-login-admin.php:479 +#: includes/class-m365-login-admin.php:879 msgid "When the secret expires, sign-ins fail with \"Could not complete the sign-in with Microsoft\". Create a new secret, paste it here, save, then delete the old one in Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:492 +#: includes/class-m365-login-admin.php:892 msgid "Expired" msgstr "" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:496 +#: includes/class-m365-login-admin.php:896 msgid "Expires in %d days" msgstr "" #. translators: %d: number of days -#: includes/class-m365-login-admin.php:499 +#: includes/class-m365-login-admin.php:899 msgid "Valid" msgstr "" -#: includes/class-m365-login-admin.php:504 +#: includes/class-m365-login-admin.php:904 msgid "Thumbprint (SHA-1)" msgstr "" -#: includes/class-m365-login-admin.php:506 +#: includes/class-m365-login-admin.php:906 msgid "Subject" msgstr "" -#: includes/class-m365-login-admin.php:508 +#: includes/class-m365-login-admin.php:908 msgid "Key size" msgstr "" -#: includes/class-m365-login-admin.php:510 +#: includes/class-m365-login-admin.php:910 msgid "Valid until" msgstr "" -#: includes/class-m365-login-admin.php:514 +#: includes/class-m365-login-admin.php:914 msgid "Download certificate (.cer)" msgstr "" -#: includes/class-m365-login-admin.php:515 +#: includes/class-m365-login-admin.php:915 msgid "Generate new certificate" msgstr "" -#: includes/class-m365-login-admin.php:518 +#: includes/class-m365-login-admin.php:918 msgid "Remove certificate when saving" msgstr "" -#: includes/class-m365-login-admin.php:522 +#: includes/class-m365-login-admin.php:922 msgid "No certificate stored yet." msgstr "" -#: includes/class-m365-login-admin.php:524 +#: includes/class-m365-login-admin.php:924 msgid "Generate certificate" msgstr "" -#: includes/class-m365-login-admin.php:525 +#: includes/class-m365-login-admin.php:925 msgid "3072-bit RSA, self-signed, valid for 2 years. The private key is stored encrypted and never shown or downloadable." msgstr "" -#: includes/class-m365-login-admin.php:529 +#: includes/class-m365-login-admin.php:929 msgid "Use your own certificate instead (paste PEM)" msgstr "" -#: includes/class-m365-login-admin.php:532 +#: includes/class-m365-login-admin.php:932 msgid "Private key (PEM, unencrypted)" msgstr "" -#: includes/class-m365-login-admin.php:536 +#: includes/class-m365-login-admin.php:936 msgid "Certificate (PEM)" msgstr "" -#: includes/class-m365-login-admin.php:538 +#: includes/class-m365-login-admin.php:938 msgid "RSA, at least 2048 bits. The pair is validated and the key is encrypted when you save. Both fields stay empty afterwards." msgstr "" -#: includes/class-m365-login-admin.php:544 +#: includes/class-m365-login-admin.php:944 msgid "Step-by-step: register the certificate in Entra ID" msgstr "" -#: includes/class-m365-login-admin.php:547 +#: includes/class-m365-login-admin.php:947 msgid "Click Generate certificate above (or paste your own). Then click Download certificate (.cer) – the file contains only the public part." msgstr "" -#: includes/class-m365-login-admin.php:548 +#: includes/class-m365-login-admin.php:948 msgid "Open entra.microsoft.com → Identity → Applications → App registrations and open your app." msgstr "" -#: includes/class-m365-login-admin.php:549 +#: includes/class-m365-login-admin.php:949 msgid "Choose Certificates & secrets in the left menu, then the tab Certificates, and click Upload certificate." msgstr "" -#: includes/class-m365-login-admin.php:550 +#: includes/class-m365-login-admin.php:950 msgid "Select the downloaded .cer file, add a description such as \"WordPress login\" and click Add." msgstr "" -#: includes/class-m365-login-admin.php:551 +#: includes/class-m365-login-admin.php:951 msgid "Compare the thumbprint Entra ID shows with the thumbprint above – they must match exactly." msgstr "" -#: includes/class-m365-login-admin.php:552 +#: includes/class-m365-login-admin.php:952 msgid "Make sure Certificate is selected above and save this page. If a client secret was stored before, you may delete it in Entra ID now." msgstr "" -#: includes/class-m365-login-admin.php:554 +#: includes/class-m365-login-admin.php:954 msgid "How it works: for every token request WordPress signs a short-lived JWT (client assertion) with the private key; Microsoft verifies it with the uploaded certificate. Nothing secret is ever transmitted." msgstr "" -#: includes/class-m365-login-admin.php:555 +#: includes/class-m365-login-admin.php:955 msgid "Before the certificate expires: generate a new one here, upload it to Entra ID (both may be registered at the same time), save, then remove the old one from Entra ID. Sign-ins keep working during the switch." msgstr "" -#: includes/class-m365-login-admin.php:561 +#: includes/class-m365-login-admin.php:961 msgid "Account prompt" msgstr "" -#: includes/class-m365-login-admin.php:563 +#: includes/class-m365-login-admin.php:963 msgid "Always let the user pick an account (recommended)" msgstr "" -#: includes/class-m365-login-admin.php:564 +#: includes/class-m365-login-admin.php:964 msgid "Use the current Microsoft session if available" msgstr "" -#: includes/class-m365-login-admin.php:565 +#: includes/class-m365-login-admin.php:965 msgid "Always require re-entering credentials" msgstr "" -#: includes/class-m365-login-admin.php:574 +#: includes/class-m365-login-admin.php:974 msgid "Appearance" msgstr "" -#: includes/class-m365-login-admin.php:577 +#: includes/class-m365-login-admin.php:977 msgid "Live preview" msgstr "" -#: includes/class-m365-login-admin.php:591 +#: includes/class-m365-login-admin.php:991 msgid "Button text" msgstr "" -#: includes/class-m365-login-admin.php:595 +#: includes/class-m365-login-admin.php:995 msgid "Divider text" msgstr "" -#: includes/class-m365-login-admin.php:597 +#: includes/class-m365-login-admin.php:997 msgid "Leave empty to hide the divider line." msgstr "" -#: includes/class-m365-login-admin.php:602 +#: includes/class-m365-login-admin.php:1002 msgid "Icon" msgstr "" -#: includes/class-m365-login-admin.php:605 +#: includes/class-m365-login-admin.php:1005 msgid "Show an icon on the button" msgstr "" -#: includes/class-m365-login-admin.php:616 +#: includes/class-m365-login-admin.php:1016 msgid "Default: Microsoft logo" msgstr "" -#: includes/class-m365-login-admin.php:618 +#: includes/class-m365-login-admin.php:1018 msgid "Choose from media library" msgstr "" -#: includes/class-m365-login-admin.php:619 +#: includes/class-m365-login-admin.php:1019 msgid "Use Microsoft logo" msgstr "" -#: includes/class-m365-login-admin.php:621 +#: includes/class-m365-login-admin.php:1021 msgid "PNG, SVG, JPG or WebP. Square images (e.g. 64×64 px) work best." msgstr "" -#: includes/class-m365-login-admin.php:629 +#: includes/class-m365-login-admin.php:1029 msgid "Background" msgstr "" -#: includes/class-m365-login-admin.php:630 +#: includes/class-m365-login-admin.php:1030 msgid "Background (hover)" msgstr "" -#: includes/class-m365-login-admin.php:631 +#: includes/class-m365-login-admin.php:1031 msgid "Text colour" msgstr "" -#: includes/class-m365-login-admin.php:632 +#: includes/class-m365-login-admin.php:1032 msgid "Border" msgstr "" -#: includes/class-m365-login-admin.php:645 +#: includes/class-m365-login-admin.php:1045 msgid "Corner radius" msgstr "" -#: includes/class-m365-login-admin.php:649 +#: includes/class-m365-login-admin.php:1049 msgid "Position on the login page" msgstr "" -#: includes/class-m365-login-admin.php:651 +#: includes/class-m365-login-admin.php:1051 msgid "Below the login form" msgstr "" -#: includes/class-m365-login-admin.php:652 +#: includes/class-m365-login-admin.php:1052 msgid "Above the login form" msgstr "" -#: includes/class-m365-login-admin.php:658 +#: includes/class-m365-login-admin.php:1058 msgid "Quick presets" msgstr "" -#: includes/class-m365-login-admin.php:659 +#: includes/class-m365-login-admin.php:1059 msgid "Microsoft dark" msgstr "" -#: includes/class-m365-login-admin.php:660 +#: includes/class-m365-login-admin.php:1060 msgid "Microsoft light" msgstr "" -#: includes/class-m365-login-admin.php:661 +#: includes/class-m365-login-admin.php:1061 msgid "Azure blue" msgstr "" -#: includes/class-m365-login-admin.php:662 +#: includes/class-m365-login-admin.php:1062 msgid "WordPress blue" msgstr "" -#: includes/class-m365-login-admin.php:666 +#: includes/class-m365-login-admin.php:1066 msgid "Custom login page" msgstr "" -#: includes/class-m365-login-admin.php:667 +#: includes/class-m365-login-admin.php:1067 msgid "Using your own login page instead of wp-login.php? Tell the plugin where it is so error messages, the fallback link and the post-logout redirect point there." msgstr "" -#: includes/class-m365-login-admin.php:670 +#: includes/class-m365-login-admin.php:1070 msgid "URL of your login page" msgstr "" -#: includes/class-m365-login-admin.php:672 +#: includes/class-m365-login-admin.php:1072 msgid "Must be on this site. Leave empty to use wp-login.php." msgstr "" -#: includes/class-m365-login-admin.php:678 +#: includes/class-m365-login-admin.php:1078 msgid "Add the button to every wp_login_form() form automatically" msgstr "" -#: includes/class-m365-login-admin.php:679 +#: includes/class-m365-login-admin.php:1079 msgid "Covers themes and plugins that use the WordPress login form function. Page-builder widgets need the shortcode or the template function below." msgstr "" -#: includes/class-m365-login-admin.php:684 +#: includes/class-m365-login-admin.php:1084 msgid "Manual placement" msgstr "" -#: includes/class-m365-login-admin.php:685 +#: includes/class-m365-login-admin.php:1085 msgid "Shortcode (block editor, page builders):" msgstr "" -#: includes/class-m365-login-admin.php:687 +#: includes/class-m365-login-admin.php:1087 msgid "Template function (theme files):" msgstr "" -#: includes/class-m365-login-admin.php:689 +#: includes/class-m365-login-admin.php:1089 msgid "Both show the error messages of the last attempt; use m365_login_messages() to place them separately." msgstr "" -#: includes/class-m365-login-admin.php:697 +#: includes/class-m365-login-admin.php:1097 msgid "User matching & hardening" msgstr "" -#: includes/class-m365-login-admin.php:698 -msgid "Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists." +#: includes/class-m365-login-admin.php:1098 +msgid "Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists – created by hand or imported by the user sync." msgstr "" -#: includes/class-m365-login-admin.php:703 +#: includes/class-m365-login-admin.php:1103 msgid "Bind WordPress accounts to the Microsoft object ID" msgstr "" -#: includes/class-m365-login-admin.php:704 +#: includes/class-m365-login-admin.php:1104 msgid "On first sign-in the immutable Microsoft object ID is stored with the user. Later sign-ins with the same e-mail but a different Microsoft identity are rejected. Strongly recommended." msgstr "" -#: includes/class-m365-login-admin.php:711 +#: includes/class-m365-login-admin.php:1111 msgid "Fall back to the user principal name (UPN)" msgstr "" -#: includes/class-m365-login-admin.php:712 +#: includes/class-m365-login-admin.php:1112 msgid "If the token contains no \"email\" claim, use the UPN (e.g. jane@contoso.com) when it is a valid e-mail address. Usually required for work accounts." msgstr "" -#: includes/class-m365-login-admin.php:719 +#: includes/class-m365-login-admin.php:1119 msgid "Keep users signed in (\"Remember me\")" msgstr "" -#: includes/class-m365-login-admin.php:720 +#: includes/class-m365-login-admin.php:1120 msgid "Issues a 14-day WordPress session instead of a browser session." msgstr "" -#: includes/class-m365-login-admin.php:725 +#: includes/class-m365-login-admin.php:1125 msgid "Allowed e-mail domains (optional)" msgstr "" -#: includes/class-m365-login-admin.php:727 +#: includes/class-m365-login-admin.php:1127 msgid "One or more domains separated by commas or new lines. Leave empty to allow any domain of your tenant." msgstr "" -#: includes/class-m365-login-admin.php:732 +#: includes/class-m365-login-admin.php:1132 msgid "Allowed Entra groups (optional)" msgstr "" -#: includes/class-m365-login-admin.php:733 +#: includes/class-m365-login-admin.php:1133 msgid "Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count." msgstr "" -#: includes/class-m365-login-admin.php:736 -msgid "Search groups" -msgstr "" - -#: includes/class-m365-login-admin.php:738 -msgid "Type a group name or paste an object ID…" -msgstr "" - -#: includes/class-m365-login-admin.php:739 -msgid "Search" -msgstr "" - -#: includes/class-m365-login-admin.php:744 -msgid "Needs the application permission \"GroupMember.Read.All\" with admin consent. Without it you can still paste group object IDs." -msgstr "" - -#: includes/class-m365-login-admin.php:750 -msgid "Selected groups" -msgstr "" - -#: includes/class-m365-login-admin.php:751 +#: includes/class-m365-login-admin.php:1135 msgid "No groups selected – every matched user may sign in." msgstr "" -#: includes/class-m365-login-admin.php:761 +#: includes/class-m365-login-admin.php:1137 msgid "Membership is read from the \"groups\" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission \"User.Read.All\" or \"Directory.Read.All\"). If neither works, the sign-in is refused." msgstr "" -#: includes/class-m365-login-admin.php:766 +#: includes/class-m365-login-admin.php:1142 +msgid "Excluded Entra groups (optional)" +msgstr "" + +#: includes/class-m365-login-admin.php:1143 +msgid "Members of these groups can never sign in with Microsoft – even if they are in an allowed group. Nested memberships count." +msgstr "" + +#: includes/class-m365-login-admin.php:1146 +msgid "Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above." +msgstr "" + +#: includes/class-m365-login-admin.php:1148 +msgid "No groups excluded." +msgstr "" + +#: includes/class-m365-login-admin.php:1150 +msgid "The plugin asks Microsoft Graph on every sign-in (application permission \"User.Read.All\" or \"Directory.Read.All\"), because a \"groups\" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected – combine with button-only mode if needed." +msgstr "" + +#: includes/class-m365-login-admin.php:1155 msgid "Button-only mode" msgstr "" -#: includes/class-m365-login-admin.php:767 -msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected." +#: includes/class-m365-login-admin.php:1156 +msgid "Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site – custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie." msgstr "" -#: includes/class-m365-login-admin.php:772 +#: includes/class-m365-login-admin.php:1161 msgid "Show only the Microsoft button on the login page" msgstr "" -#: includes/class-m365-login-admin.php:773 +#: includes/class-m365-login-admin.php:1162 msgid "Becomes active once the connection is configured. Make sure your own account can sign in via Microsoft before enabling this." msgstr "" -#: includes/class-m365-login-admin.php:778 +#: includes/class-m365-login-admin.php:1167 msgid "Fallback link (keep it secret)" msgstr "" -#: includes/class-m365-login-admin.php:779 +#: includes/class-m365-login-admin.php:1168 msgid "Opening this link shows the password form again in that browser for 30 minutes and allows password sign-in there. Bookmark it somewhere safe – it is your way back in if Microsoft sign-in ever breaks." msgstr "" -#: includes/class-m365-login-admin.php:787 +#: includes/class-m365-login-admin.php:1176 msgid "Generate a new key when saving" msgstr "" -#: includes/class-m365-login-admin.php:790 +#: includes/class-m365-login-admin.php:1179 msgid "A key is generated automatically the first time you save these settings." msgstr "" #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:796 +#: includes/class-m365-login-admin.php:1185 msgid "Emergency switch: add %s to wp-config.php to disable button-only mode entirely." msgstr "" #. translators: %s: PHP constant -#: includes/class-m365-login-admin.php:805 +#: includes/class-m365-login-admin.php:1194 msgid "What the plugin does to keep sign-ins safe" msgstr "" -#: includes/class-m365-login-admin.php:807 +#: includes/class-m365-login-admin.php:1196 msgid "OpenID Connect authorization code flow with PKCE (S256) – no tokens ever pass through the browser." msgstr "" -#: includes/class-m365-login-admin.php:808 +#: includes/class-m365-login-admin.php:1197 msgid "Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection)." msgstr "" -#: includes/class-m365-login-admin.php:809 +#: includes/class-m365-login-admin.php:1198 msgid "ID token signature verified against Microsoft’s published signing keys; issuer, audience, tenant, expiry and nonce are checked." msgstr "" -#: includes/class-m365-login-admin.php:810 -msgid "Client secret encrypted at rest; no accounts are created, no passwords are changed." +#: includes/class-m365-login-admin.php:1199 +msgid "Client secret encrypted at rest; sign-in never creates accounts or changes passwords." msgstr "" -#: includes/class-m365-login-admin.php:816 +#: includes/class-m365-login-admin.php:1207 msgid "Save changes" msgstr "" -#: includes/class-m365-login-admin.php:822 +#: includes/class-m365-login-admin.php:1213 msgid "Redirect URI" msgstr "" -#: includes/class-m365-login-admin.php:823 +#: includes/class-m365-login-admin.php:1214 msgid "Register this URI in your app registration under Authentication → Web → Redirect URIs:" msgstr "" -#: includes/class-m365-login-admin.php:829 +#: includes/class-m365-login-admin.php:1220 msgid "Plain permalinks are active, so the callback uses a query string. If you enable pretty permalinks later, the redirect URI changes and must be updated in Entra ID." msgstr "" -#: includes/class-m365-login-admin.php:832 +#: includes/class-m365-login-admin.php:1223 msgid "Your site does not use HTTPS. Microsoft only accepts http:// redirect URIs for localhost; production sites must use HTTPS." msgstr "" -#: includes/class-m365-login-admin.php:837 +#: includes/class-m365-login-admin.php:1228 msgid "Setup guide: app registration" msgstr "" -#: includes/class-m365-login-admin.php:839 +#: includes/class-m365-login-admin.php:1230 msgid "Open entra.microsoft.com → Identity → Applications → App registrations → New registration." msgstr "" -#: includes/class-m365-login-admin.php:840 +#: includes/class-m365-login-admin.php:1231 msgid "Name: e.g. \"WordPress login\". Supported account types: \"Accounts in this organizational directory only\" (single tenant)." msgstr "" -#: includes/class-m365-login-admin.php:841 +#: includes/class-m365-login-admin.php:1232 msgid "Redirect URI: choose the platform Web and paste the URI shown above. Then click Register." msgstr "" -#: includes/class-m365-login-admin.php:842 +#: includes/class-m365-login-admin.php:1233 msgid "On the Overview page copy the Application (client) ID and the Directory (tenant) ID into the Connection tab." msgstr "" -#: includes/class-m365-login-admin.php:843 +#: includes/class-m365-login-admin.php:1234 msgid "Authentication: leave \"ID tokens\" unchecked (the plugin uses the authorization code flow) and \"Allow public client flows\" on No." msgstr "" -#: includes/class-m365-login-admin.php:844 +#: includes/class-m365-login-admin.php:1235 msgid "Token configuration → Add optional claim → ID → tick \"email\" → Add. Confirm the API permission prompt." msgstr "" -#: includes/class-m365-login-admin.php:845 +#: includes/class-m365-login-admin.php:1236 msgid "Pick the authentication method on the Connection tab and follow its step-by-step guide (client secret or certificate)." msgstr "" -#: includes/class-m365-login-admin.php:846 +#: includes/class-m365-login-admin.php:1237 msgid "Optional: restrict who may use the app under Enterprise applications → your app → Properties → \"Assignment required\" = Yes, then assign users/groups." msgstr "" -#: includes/class-m365-login-admin.php:848 +#: includes/class-m365-login-admin.php:1239 msgid "Required API permission: openid, profile, email (delegated) – granted by default." msgstr "" -#: includes/class-m365-login-admin.php:849 -msgid "Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." +#: includes/class-m365-login-admin.php:1240 +msgid "Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent." msgstr "" -#: includes/class-m365-login-admin.php:853 +#: includes/class-m365-login-admin.php:1244 msgid "Shortcode" msgstr "" -#: includes/class-m365-login-admin.php:854 +#: includes/class-m365-login-admin.php:1245 msgid "Place the button on a custom login page:" msgstr "" -#: includes/class-m365-login-admin.php:856 +#: includes/class-m365-login-admin.php:1247 msgid "More options on the Button tab under \"Custom login page\"." msgstr "" -#: includes/class-m365-login-auth.php:173 +#: includes/class-m365-login-auth.php:203 msgid "Password sign-in is disabled on this site. Please use the Microsoft button." msgstr "" -#: includes/class-m365-login-auth.php:823 +#: includes/class-m365-login-auth.php:235 includes/class-m365-login-auth.php:1336 +msgid "Passwords are not used on this site. Please sign in with the Microsoft button." +msgstr "" + +#: includes/class-m365-login-auth.php:1286 +msgid "Microsoft sign-in is temporarily unavailable. Please contact an administrator." +msgstr "" + +#: includes/class-m365-login-auth.php:1293 msgid "Password sign-in is temporarily enabled for this browser (30 minutes)." msgstr "" -#: includes/class-m365-login-auth.php:844 includes/class-m365-login-graph.php:63 +#: includes/class-m365-login-auth.php:1314 includes/class-m365-login-graph.php:63 msgid "Microsoft login is not configured yet." msgstr "" -#: includes/class-m365-login-auth.php:845 +#: includes/class-m365-login-auth.php:1315 msgid "The login request expired or was invalid. Please try again." msgstr "" -#: includes/class-m365-login-auth.php:846 +#: includes/class-m365-login-auth.php:1316 msgid "Microsoft sign-in was cancelled." msgstr "" -#: includes/class-m365-login-auth.php:847 +#: includes/class-m365-login-auth.php:1317 msgid "Microsoft returned an error. Please try again." msgstr "" -#: includes/class-m365-login-auth.php:848 +#: includes/class-m365-login-auth.php:1318 msgid "Could not complete the sign-in with Microsoft. Please try again or contact an administrator." msgstr "" -#: includes/class-m365-login-auth.php:849 +#: includes/class-m365-login-auth.php:1319 msgid "The Microsoft sign-in could not be verified." msgstr "" -#: includes/class-m365-login-auth.php:850 +#: includes/class-m365-login-auth.php:1320 msgid "Your Microsoft account did not provide an e-mail address." msgstr "" -#: includes/class-m365-login-auth.php:851 +#: includes/class-m365-login-auth.php:1321 msgid "Your e-mail domain is not allowed to sign in here." msgstr "" -#: includes/class-m365-login-auth.php:852 +#: includes/class-m365-login-auth.php:1322 msgid "No WordPress account exists for your Microsoft e-mail address." msgstr "" -#: includes/class-m365-login-auth.php:853 +#: includes/class-m365-login-auth.php:1323 msgid "This WordPress account is linked to a different Microsoft account. Please contact an administrator." msgstr "" -#: includes/class-m365-login-auth.php:854 +#: includes/class-m365-login-auth.php:1324 msgid "You are not allowed to sign in with this account." msgstr "" -#: includes/class-m365-login-auth.php:855 +#: includes/class-m365-login-auth.php:1325 msgid "Your Microsoft account is not a member of a group that is allowed to sign in here." msgstr "" -#: includes/class-m365-login-auth.php:856 +#: includes/class-m365-login-auth.php:1326 msgid "Your group membership could not be verified. Please contact an administrator." msgstr "" -#: includes/class-m365-login-auth.php:857 +#: includes/class-m365-login-auth.php:1327 +msgid "Your Microsoft account is a member of a group that is not allowed to sign in here." +msgstr "" + +#: includes/class-m365-login-auth.php:1328 msgid "The fallback key is not valid." msgstr "" -#: includes/class-m365-login-auth.php:858 +#: includes/class-m365-login-auth.php:1329 msgid "Too many attempts. Please wait 15 minutes." msgstr "" -#: includes/class-m365-login-auth.php:859 +#: includes/class-m365-login-auth.php:1330 msgid "Too many sign-in attempts from your connection. Please wait a few minutes and try again." msgstr "" +#: includes/class-m365-login-auth.php:1331 includes/class-m365-login-sync.php:2138 +msgid "This account has been deactivated." +msgstr "" + +#: includes/class-m365-login-auth.php:1332 +msgid "For security reasons this administrator account is not linked automatically. Sign in once with your password and click \"Link Microsoft account\" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile." +msgstr "" + +#: includes/class-m365-login-auth.php:1333 +msgid "The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again." +msgstr "" + +#: includes/class-m365-login-auth.php:1334 +msgid "Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile." +msgstr "" + +#: includes/class-m365-login-auth.php:1335 +msgid "Guest and external accounts cannot sign in here." +msgstr "" + #: includes/class-m365-login-certificate.php:29 msgid "The PHP OpenSSL extension is not available." msgstr "" @@ -832,87 +1221,490 @@ msgstr "" msgid "The RSA key must have at least 2048 bits." msgstr "" -#: includes/class-m365-login-certificate.php:102 +#: includes/class-m365-login-certificate.php:101 +msgid "The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there." +msgstr "" + +#: includes/class-m365-login-certificate.php:105 includes/class-m365-login-certificate.php:119 msgid "The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----)." msgstr "" -#: includes/class-m365-login-certificate.php:105 +#: includes/class-m365-login-certificate.php:108 msgid "The certificate does not belong to this private key." msgstr "" -#: includes/class-m365-login-certificate.php:110 +#: includes/class-m365-login-certificate.php:113 msgid "The certificate has already expired." msgstr "" -#: includes/class-m365-login-graph.php:201 +#: includes/class-m365-login-graph.php:440 msgid "Group" msgstr "" -#: includes/class-m365-login-graph.php:203 +#: includes/class-m365-login-graph.php:442 msgid "Security group" msgstr "" -#: includes/class-m365-login-graph.php:205 +#: includes/class-m365-login-graph.php:444 msgid "Microsoft 365 group" msgstr "" -#: includes/class-m365-login-settings.php:47 +#: includes/class-m365-login-graph.php:448 +msgid "Public Microsoft 365 group – anyone in the organisation can join" +msgstr "" + +#: includes/class-m365-login-settings.php:55 msgid "Sign in with Microsoft" msgstr "" -#: includes/class-m365-login-settings.php:56 +#: includes/class-m365-login-settings.php:64 msgid "or" msgstr "" -#: includes/class-m365-login-settings.php:197 includes/class-m365-login-settings.php:445 +#: includes/class-m365-login-settings.php:226 includes/class-m365-login-settings.php:559 msgid "The private key could not be encrypted. Is the OpenSSL extension available?" msgstr "" -#: includes/class-m365-login-settings.php:391 +#: includes/class-m365-login-settings.php:505 msgid "The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of \"organizations\", \"common\", \"consumers\"." msgstr "" -#: includes/class-m365-login-settings.php:399 +#: includes/class-m365-login-settings.php:513 msgid "The application (client) ID must be a GUID." msgstr "" -#: includes/class-m365-login-settings.php:411 +#: includes/class-m365-login-settings.php:525 msgid "The client secret contains invalid characters." msgstr "" -#: includes/class-m365-login-settings.php:415 +#: includes/class-m365-login-settings.php:529 msgid "The client secret could not be encrypted. Is the OpenSSL extension available?" msgstr "" -#: includes/class-m365-login-settings.php:435 +#: includes/class-m365-login-settings.php:549 msgid "Please paste both the private key and the certificate." msgstr "" -#: includes/class-m365-login-settings.php:437 +#: includes/class-m365-login-settings.php:551 msgid "The pasted key or certificate is too large." msgstr "" -#: includes/class-m365-login-settings.php:454 +#: includes/class-m365-login-settings.php:568 msgid "Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then." msgstr "" -#: includes/class-m365-login-settings.php:500 +#: includes/class-m365-login-settings.php:601 msgid "The custom login page must be a URL on this site." msgstr "" -#: includes/class-m365-login.php:102 +#: includes/class-m365-login-settings.php:711 +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:136 +msgid "Display name" +msgstr "" + +#: includes/class-m365-login-sync.php:140 +msgid "First name" +msgstr "" + +#: includes/class-m365-login-sync.php:144 +msgid "Last name" +msgstr "" + +#: includes/class-m365-login-sync.php:148 +msgid "Profile photo (used as avatar)" +msgstr "" + +#: includes/class-m365-login-sync.php:152 +msgid "Job title" +msgstr "" + +#: includes/class-m365-login-sync.php:156 +msgid "Department" +msgstr "" + +#: includes/class-m365-login-sync.php:160 +msgid "Company" +msgstr "" + +#: includes/class-m365-login-sync.php:164 +msgid "Office" +msgstr "" + +#: includes/class-m365-login-sync.php:168 +msgid "Employee ID" +msgstr "" + +#: includes/class-m365-login-sync.php:172 +msgid "Business phone" +msgstr "" + +#: includes/class-m365-login-sync.php:176 +msgid "Mobile phone" +msgstr "" + +#: includes/class-m365-login-sync.php:180 +msgid "Street address" +msgstr "" + +#: includes/class-m365-login-sync.php:184 +msgid "Postal code" +msgstr "" + +#: includes/class-m365-login-sync.php:188 +msgid "City" +msgstr "" + +#: includes/class-m365-login-sync.php:192 +msgid "State / province" +msgstr "" + +#: includes/class-m365-login-sync.php:196 +msgid "Country" +msgstr "" + +#: includes/class-m365-login-sync.php:200 +msgid "Language (sets the admin language if installed)" +msgstr "" + +#: includes/class-m365-login-sync.php:336 +msgid "Another sync is still running. Please try again in a few minutes." +msgstr "" + +#: includes/class-m365-login-sync.php:418 +msgid "The sync stopped unexpectedly (PHP error, memory or time limit). Accounts after this point were not processed and nothing was deactivated or deleted. For large directories use \"wp m365-login sync\"." +msgstr "" + +#: includes/class-m365-login-sync.php:480 +msgid "The connection to Microsoft Entra ID is not configured yet." +msgstr "" + +#: includes/class-m365-login-sync.php:484 +msgid "The user sync needs a pinned tenant ID (GUID) on the Connection tab." +msgstr "" + +#: includes/class-m365-login-sync.php:488 +msgid "The default role does not exist. Please check the sync settings." +msgstr "" + +#. translators: %d: number of users +#: includes/class-m365-login-sync.php:500 +msgid "%d user read from Microsoft 365." +msgid_plural "%d users read from Microsoft 365." +msgstr[0] "" +msgstr[1] "" + +#: includes/class-m365-login-sync.php:511 +msgid "Microsoft 365 returned no users at all while accounts are linked. Nothing was changed. Check the tenant and the sync groups." +msgstr "" + +#. translators: %d: number of accounts +#: includes/class-m365-login-sync.php:585 +msgid "%d linked account belongs to another (or an unknown) tenant and was not deactivated or deleted. Unlink it by hand if it is no longer needed." +msgid_plural "%d linked accounts belong to another (or an unknown) tenant and were not deactivated or deleted. Unlink them by hand if they are no longer needed." +msgstr[0] "" +msgstr[1] "" + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:596 +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 "" + +#: includes/class-m365-login-sync.php:722 includes/class-m365-login-sync.php:785 includes/class-m365-login-sync.php:1233 includes/class-m365-login-sync.php:2283 +msgid "disabled in Microsoft 365" +msgstr "" + +#. translators: %s: user principal name +#: includes/class-m365-login-sync.php:733 +msgid "%s: no usable e-mail address, skipped." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:740 +msgid "%s: e-mail domain is not on the allow-list, skipped." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:761 +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:766 +msgid "%s: privileged WordPress account – linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:770 +msgid "%s: existing account linked." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:796 +msgid "%s: added to this site." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:805 +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:816 +msgid "%1$s: updated (%2$s)." +msgstr "" + +#. translators: 1: e-mail address, 2: role names +#: includes/class-m365-login-sync.php:843 +msgid "%1$s: account created (%2$s)." +msgstr "" + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:870 +msgid "%1$s: account could not be created: %2$s" +msgstr "" + +#. translators: 1: current e-mail address, 2: e-mail address in Microsoft 365 +#: includes/class-m365-login-sync.php:929 +msgid "%1$s: the e-mail address in Microsoft 365 changed to %2$s. It is not changed automatically for privileged accounts – update it by hand if intended." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:932 +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:935 +msgid "e-mail" +msgstr "" + +#. translators: %s: profile field +#: includes/class-m365-login-sync.php:985 +msgid "%s removed" +msgstr "" + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:995 +msgid "%1$s: profile could not be updated: %2$s" +msgstr "" + +#. translators: %s: role names +#: includes/class-m365-login-sync.php:1104 +msgid "roles: %s" +msgstr "" + +#. translators: 1: number of accounts, 2: limit +#: includes/class-m365-login-sync.php:1174 +msgid "Safety stop: %1$d accounts would lose administrative rights, more than the limit of %2$d per run (or all of them). Nothing was demoted, deactivated or deleted. Check the group → role mapping, then run the sync again (filter m365_login_sync_demotion_limit)." +msgstr "" + +#: includes/class-m365-login-sync.php:1225 includes/class-m365-login-sync.php:2284 +msgid "deleted in Microsoft 365" +msgstr "" + +#: includes/class-m365-login-sync.php:1236 includes/class-m365-login-sync.php:2285 +msgid "no longer a member of the sync groups" +msgstr "" + +#. translators: 1: e-mail address, 2: reason +#: includes/class-m365-login-sync.php:1268 +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:1412 +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:1421 +msgid "%1$s: account deleted (%2$s)." +msgstr "" + +#. translators: 1: e-mail address, 2: reason +#: includes/class-m365-login-sync.php:1430 +msgid "%1$s: account deactivated (%2$s)." +msgstr "" + +#: includes/class-m365-login-sync.php:1509 +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:1512 +msgid "Microsoft Graph error: %s" +msgstr "" + +#: includes/class-m365-login-sync.php:1530 +msgid "Log truncated." +msgstr "" + +#. translators: 1: e-mail address, 2: error message +#: includes/class-m365-login-sync.php:1613 +msgid "%1$s: profile photo could not be read: %2$s" +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1637 includes/class-m365-login-sync.php:1680 +msgid "%s: profile photo updated." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1656 +msgid "%s: profile photo could not be downloaded." +msgstr "" + +#. translators: %s: e-mail address +#: includes/class-m365-login-sync.php:1663 +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:1686 +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:1701 +msgid "%s: profile photo removed." +msgstr "" + +#. translators: %d: number of photos +#: includes/class-m365-login-sync.php:1729 +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:1901 includes/class-m365-login-sync.php:1915 +msgid "Microsoft 365 (M365 Login)" +msgstr "" + +#: includes/class-m365-login-sync.php:1933 +msgid "Microsoft object ID" +msgstr "" + +#: includes/class-m365-login-sync.php:1934 +msgid "Microsoft tenant ID" +msgstr "" + +#: includes/class-m365-login-sync.php:1945 +msgid "Profile photo" +msgstr "" + +#: includes/class-m365-login-sync.php:1949 includes/class-m365-login-sync.php:2300 +msgid "Last sync" +msgstr "" + +#: includes/class-m365-login-sync.php:1963 includes/class-m365-login-sync.php:2167 includes/class-m365-login-sync.php:2314 +msgid "Microsoft 365" +msgstr "" + +#: includes/class-m365-login-sync.php:2004 +msgid "The link to the Microsoft account and a possible deactivation were kept because they secure the account. The next user sync copies selected profile fields again unless the person is excluded from the sync." +msgstr "" + +#: includes/class-m365-login-sync.php:2185 +msgid "Deactivated" +msgstr "" + +#: includes/class-m365-login-sync.php:2188 +msgid "Imported" +msgstr "" + +#: includes/class-m365-login-sync.php:2190 includes/class-m365-login-sync.php:2320 +msgid "Linked" +msgstr "" + +#: includes/class-m365-login-sync.php:2218 +msgid "Reactivate" +msgstr "" + +#: includes/class-m365-login-sync.php:2218 +msgid "Deactivate" +msgstr "" + +#: includes/class-m365-login-sync.php:2247 +msgid "Your Microsoft account is now linked. From now on you can sign in with the Microsoft button." +msgstr "" + +#: includes/class-m365-login-sync.php:2261 +msgid "The account has been deactivated and signed out everywhere." +msgstr "" + +#: includes/class-m365-login-sync.php:2262 +msgid "The account has been reactivated." +msgstr "" + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2289 +msgid "Deactivated since %1$s (%2$s)" +msgstr "" + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2291 +msgid "manually" +msgstr "" + +#. translators: 1: date, 2: reason +#: includes/class-m365-login-sync.php:2293 +msgid "Status" +msgstr "" + +#: includes/class-m365-login-sync.php:2296 +msgid "Object ID" +msgstr "" + +#: includes/class-m365-login-sync.php:2317 +msgid "Microsoft account" +msgstr "" + +#: includes/class-m365-login-sync.php:2322 +msgid "Not linked" +msgstr "" + +#: includes/class-m365-login-sync.php:2325 +msgid "Link Microsoft account" +msgstr "" + +#: includes/class-m365-login-sync.php:2326 +msgid "You sign in with Microsoft once; afterwards this WordPress account is bound to that Microsoft account, even if its user principal name differs from your e-mail address." +msgstr "" + +#: includes/class-m365-login-sync.php:2332 +msgid "Assigned Microsoft account (UPN)" +msgstr "" + +#: includes/class-m365-login-sync.php:2335 +msgid "Optional. The user principal name of the Microsoft account that belongs to this user. Sign-in and user sync link exactly this account – needed for administrators whose user principal name differs from their WordPress e-mail address." +msgstr "" + +#: includes/class-m365-login-sync.php:2337 +msgid "Remove the link to the Microsoft account" +msgstr "" + +#: includes/class-m365-login-sync.php:2350 +msgid "These values are managed by the Microsoft 365 user sync and overwritten on the next run." +msgstr "" + +#: includes/class-m365-login.php:127 msgid "Settings" msgstr "" -#: includes/class-m365-login.php:113 +#: includes/class-m365-login.php:138 msgid "M365 Login requires PHP 7.4 or newer." msgstr "" -#: includes/class-m365-login.php:114 includes/class-m365-login.php:123 +#: includes/class-m365-login.php:139 includes/class-m365-login.php:148 msgid "Plugin activation failed" msgstr "" -#: includes/class-m365-login.php:122 +#: includes/class-m365-login.php:147 msgid "M365 Login requires the PHP OpenSSL extension (needed to verify Microsoft token signatures and to encrypt the client secret)." msgstr "" diff --git a/m365-login.php b/m365-login.php index 3729395..5915bd5 100644 --- a/m365-login.php +++ b/m365-login.php @@ -2,8 +2,8 @@ /** * Plugin Name: M365 Login * Plugin URI: https://github.com/friloo/wp-m365-login - * Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Existing users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE). - * Version: 1.0.0 + * Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE); an optional user sync imports Microsoft 365 users with roles and profile fields. + * Version: 1.1.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: friloo @@ -16,7 +16,7 @@ defined( 'ABSPATH' ) || exit; -define( 'M365_LOGIN_VERSION', '1.0.0' ); +define( 'M365_LOGIN_VERSION', '1.1.0' ); define( 'M365_LOGIN_FILE', __FILE__ ); define( 'M365_LOGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'M365_LOGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -28,11 +28,13 @@ require_once M365_LOGIN_DIR . 'includes/class-m365-login-jwt.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-certificate.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-graph.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-auth.php'; +require_once M365_LOGIN_DIR . 'includes/class-m365-login-sync.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-button.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-admin.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login.php'; require_once M365_LOGIN_DIR . 'includes/functions.php'; register_activation_hook( __FILE__, array( 'M365_Login', 'activate' ) ); +register_deactivation_hook( __FILE__, array( 'M365_Login_Sync', 'unschedule' ) ); add_action( 'plugins_loaded', array( 'M365_Login', 'instance' ) ); diff --git a/readme.txt b/readme.txt index 138766a..fbcd134 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: microsoft, entra id, azure ad, sso, login Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 1.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -16,10 +16,12 @@ Adds a customisable "Sign in with Microsoft" button to the login page. Existing The plugin is deliberately small and strict: -* **No user provisioning.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login. +* **Sign-in never creates users.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login. +* **Optional user sync.** Import all Microsoft 365 users (or the members of selected groups) as WordPress accounts, assign a default role plus extra roles through a group → role mapping, copy selected profile attributes and the profile photo, and deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted. Runs on demand, on a WP-Cron schedule or with `wp m365-login sync`; a dry run shows every change first. * **Password login stays available.** The button is an additional option; the normal form is untouched. * **Fully customisable button.** Change the text, replace the Microsoft logo with your own icon from the media library, pick background, hover, text and border colours, adjust the corner radius, and choose whether the button appears above or below the login form – with a live preview. * **Entra group restriction.** Search and pick the groups whose members may sign in, right in the settings screen. Membership is checked via the ID token's `groups` claim or Microsoft Graph (nested groups included). +* **Excluded groups.** Members of the groups you exclude can never sign in with Microsoft, even if they are in an allowed group (checked with Microsoft Graph, fails closed). * **Button-only mode.** Hide the username/password form and refuse password sign-ins on the login page. A secret fallback link (and a `wp-config.php` constant) brings the form back when you need it. * **Clean settings screen** with a copy-and-paste redirect URI, a tenant connectivity test and a five-step setup guide. * **Custom login pages.** The button is added to every `wp_login_form()` form automatically; a shortcode and a template function cover page builders and theme templates. Point the plugin at your login page and error messages, the fallback link and the post-logout redirect go there instead of wp-login.php. @@ -32,11 +34,14 @@ The plugin is deliberately small and strict: * Optional **tenant pinning**: when a tenant GUID is configured, tokens from any other tenant are rejected. * **Account binding**: on first sign-in the immutable Microsoft object ID is stored with the user; later sign-ins with the same e-mail but a different Microsoft identity are refused. * Optional **e-mail domain allow-list** and **group allow-list** (fails closed when membership cannot be verified). -* **Button-only mode** blocks password sign-in server-side, not just visually; the fallback key is rate limited and never stored in a cookie. +* **Button-only mode** blocks password sign-in server-side, not just visually – everywhere, XML-RPC and other plugins' login handlers included (application passwords and WP-CLI keep working, API requests never get a login cookie). Right and wrong passwords get the same answer. The fallback cookie carries its issue time and expires on the server. +* Administrator accounts are only linked (by sign-in or sync) through a matching user principal name of a member account, never through the freely settable e-mail attribute; one Microsoft identity can only be bound to one WordPress account. * **Certificate authentication** (RFC 7523 client assertion) as an alternative to a client secret: generate a 3072-bit key pair in the settings, upload only the public certificate to Entra ID. Nothing secret is ever transmitted. * The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again. * In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only. * Login starts and fallback-key attempts are rate limited per client. +* The user sync stops before deactivating anything when a Microsoft Graph request fails, treats an account as deleted only when Graph returns 404 for its object ID, refuses to deactivate or delete more than 20 % of the linked accounts in one run, and never touches administrators that existed before the sync or your own account. +* Deactivated accounts lose every sign-in path (Microsoft, password, application passwords) and all sessions immediately. * Every setting is sanitised, every output escaped, every admin request nonce- and capability-checked. = Developer hooks = @@ -47,10 +52,12 @@ The plugin is deliberately small and strict: * `m365_login_allow_user` – filter, return `false` to block a matched user (e.g. group checks). * `m365_login_success` – action after a successful sign-in, receives the user and verified claims. * `m365_login_block_password_login` – filter, return `false` to exempt a password sign-in from button-only mode. +* `m365_login_sync_attributes`, `m365_login_sync_roles`, `m365_login_sync_new_user_data`, `m365_login_sync_email`, `m365_login_sync_protect_user`, `m365_login_sync_deprovision_limit`, `m365_login_sync_photo_limit`, `m365_login_sync_photo_interval` – filters for the user sync. +* `m365_login_sync_user_created`, `m365_login_sync_finished`, `m365_login_user_disabled`, `m365_login_user_enabled` – actions for the user sync. == External services == -This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's only purpose – signing users in with their Microsoft account – and is only contacted when a user clicks the Microsoft button or when an administrator uses the "Test tenant" button. +This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's main purpose – signing users in with their Microsoft account – and is only contacted when a user clicks the Microsoft button, when an administrator uses the "Test tenant" button, or when the optional user sync runs. Endpoints used (all under `https://login.microsoftonline.com/`): @@ -59,12 +66,14 @@ Endpoints used (all under `https://login.microsoftonline.com/`): * `/{tenant}/discovery/v2.0/keys` – the server downloads Microsoft's public signing keys to verify the ID token. No user data is sent. * `/{tenant}/v2.0/.well-known/openid-configuration` – fetched only when an administrator clicks "Test tenant". No user data is sent. -When the optional **group restriction** is configured, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret are sent): +When the optional **group restriction** or the optional **user sync** is used, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret or signed assertion are sent): * `/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/{id}/checkMemberGroups` – during sign-in when the ID token carries no usable `groups` claim, and on every sign-in when excluded groups are configured. 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. +* `/$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. -The plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID from Microsoft and uses them solely to find the matching WordPress account. Nothing else is stored. +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. Microsoft terms and privacy: [Microsoft Services Agreement](https://www.microsoft.com/servicesagreement), [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement), [Microsoft identity platform documentation](https://learn.microsoft.com/entra/identity-platform/). @@ -88,7 +97,19 @@ Both work. A certificate is recommended: the private key stays on your server (e = Does the plugin create users? = -No. Users must already exist in WordPress. The e-mail address is the only link between the Microsoft account and the WordPress account. This is intentional – it keeps the administrator in control of who can access the site. +Signing in never creates users: they must already exist in WordPress, linked by e-mail address. If you want accounts for your Microsoft 365 users, enable the **User sync** tab: it imports all users (or the members of selected groups) ahead of time, assigns roles and keeps profiles up to date. + += What happens to people who leave the organisation? = + +With the user sync, a WordPress account whose Microsoft 365 account is disabled or deleted can be deactivated (no sign-in of any kind, sessions ended) or deleted (content reassigned to a user you pick). If the Microsoft 365 account is enabled again, an account deactivated by the sync is reactivated automatically. Administrators that existed before the sync are never deactivated or deleted automatically. + += Our administrators' user principal name differs from their e-mail address. How do they sign in? = + +Administrator accounts are never linked through the Microsoft "mail" attribute, because any user or Exchange administrator of the tenant can set it. Either the person signs in once with the password and clicks "Link Microsoft account" on the profile page, or an administrator enters the person's user principal name under "Assigned Microsoft account (UPN)" in the WordPress profile. After that the account is found through the Microsoft object ID. + += Which Microsoft Graph permissions does the user sync need? = + +The application permission `User.Read.All` with admin consent, plus `GroupMember.Read.All` when you limit the sync to groups or map groups to roles. A tenant GUID must be configured on the Connection tab. = Which accounts can sign in? = @@ -116,11 +137,11 @@ Yes. Forms rendered with `wp_login_form()` get the button automatically. For pag = Does it support multisite? = -Yes. Settings are per site; a user must be a member of the site (or a super admin) to sign in. +Yes. Settings are per site but can only be changed by super admins, because they decide which Microsoft identity may sign in as which network-wide WordPress user. A user must be a member of the site (or a super admin) to sign in. = What happens on uninstall? = -The settings, cached data and the per-user Microsoft object ID are removed. +The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts stay without a role, with a random password and without application passwords. == Screenshots == @@ -132,10 +153,25 @@ The settings, cached data and the per-user Microsoft object ID are removed. == Changelog == += 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. +* New: excluded Entra groups – their members can never sign in with Microsoft. +* New: "Link Microsoft account" on the profile page and an administrator-assigned Microsoft account (UPN) per user; linked accounts are found by their Microsoft object ID. +* Fix: failed Microsoft sign-ins (e.g. expired secret, group not allowed) ended in a PHP fatal error instead of the error message. +* Security: fixes from a full security audit – see docs/security-audit.md (button-only bypasses via XML-RPC/REST, administrator linking, multisite settings restricted to super admins, deactivation hardening, and more). +* Fix: generating or removing the certificate in the settings did not keep the change and broke a stored client secret. + = 1.0.0 = * Initial release. == Upgrade Notice == += 1.1.0 = +Adds an optional Microsoft 365 user sync (import, roles, profile fields, deactivation). Nothing changes until you enable it on the new User sync tab. + = 1.0.0 = Initial release. diff --git a/uninstall.php b/uninstall.php index 1a2ecec..cb9a833 100644 --- a/uninstall.php +++ b/uninstall.php @@ -17,7 +17,30 @@ global $wpdb; function m365_login_uninstall_site() { global $wpdb; + // Cached Graph app token (may live in a persistent object cache instead of the options table). + $settings = get_option( 'm365_login_settings', array() ); + if ( is_array( $settings ) && ! empty( $settings['client_id'] ) ) { + $tenant = ! empty( $settings['tenant_id'] ) ? $settings['tenant_id'] : 'organizations'; + foreach ( array( 'secret', 'certificate' ) as $method ) { + delete_transient( 'm365_login_apptoken_' . md5( $tenant . '|' . $settings['client_id'] . '|' . $method ) ); + } + } + delete_option( 'm365_login_settings' ); + delete_option( 'm365_login_sync_lock' ); + delete_option( 'm365_login_version' ); + delete_option( 'm365_login_sync_report' ); + wp_clear_scheduled_hook( 'm365_login_sync' ); + + // Synced profile photos (uploads/m365-login-avatars/). + $uploads = wp_get_upload_dir(); + $dir = trailingslashit( $uploads['basedir'] ) . 'm365-login-avatars'; + if ( is_dir( $dir ) ) { + foreach ( (array) glob( $dir . '/m365-*' ) as $file ) { + wp_delete_file( $file ); + } + @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- best effort, may contain foreign files. + } // Transients: state records and JWKS cache. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -40,6 +63,7 @@ if ( is_multisite() ) { m365_login_uninstall_site(); } -// User meta is global. -delete_metadata( 'user', 0, '_m365_login_oid', '', true ); -delete_metadata( 'user', 0, '_m365_login_last_login', '', true ); +// User meta is global. Imported accounts stay; copied profile fields (m365_*) are kept as ordinary user data. +foreach ( array( '_m365_login_oid', '_m365_login_last_login', '_m365_login_synced', '_m365_login_disabled', '_m365_login_last_sync', '_m365_login_photo', '_m365_login_tid', '_m365_login_upn' ) as $m365_login_meta_key ) { + delete_metadata( 'user', 0, $m365_login_meta_key, '', true ); +}