Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
New "User sync" tab that imports Microsoft 365 / Entra ID users as WordPress accounts and keeps them up to date: - Scope: whole tenant or the (nested) members of selected groups, guests optional, e-mail domain allow-list respected. Existing accounts are linked by e-mail address. - Roles: selectable default role plus a group -> role mapping (in addition to or instead of the default role, first match wins). Roles of pre-existing accounts are only managed on request. - Profile: selectable Graph attributes (names, job title, department, phones, address, language, ...) and the profile photo as avatar. - Deprovisioning: accounts disabled or deleted in Microsoft 365 (or removed from the sync groups) are deactivated or deleted; accounts deactivated by the sync are reactivated automatically. Deactivated accounts lose every sign-in path and all sessions. - Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning, abort on any Graph error, "deleted" only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. - Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`. - Users screen column with deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen. The Graph client gains paging, retry on throttling and user, group member and photo endpoints. The group picker is now reusable. Version 1.1.0, German translations (du/Sie), docs and audit addendum. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
419 lines
15 KiB
JavaScript
419 lines
15 KiB
JavaScript
/* global jQuery, wp, m365LoginAdmin */
|
|
( function ( $ ) {
|
|
'use strict';
|
|
|
|
var cfg = window.m365LoginAdmin || {};
|
|
var i18n = cfg.i18n || {};
|
|
|
|
$( function () {
|
|
var $preview = $( '#m365-preview' );
|
|
|
|
/* ---------------- Tabs ---------------- */
|
|
var $tabs = $( '.m365-admin__tab' );
|
|
var $panels = $( '.m365-admin__panel' );
|
|
|
|
function activate( name ) {
|
|
$tabs.removeClass( 'is-active' ).attr( 'aria-selected', 'false' ).filter( '[data-tab="' + name + '"]' ).addClass( 'is-active' ).attr( 'aria-selected', 'true' );
|
|
$panels.removeClass( 'is-active' ).filter( '[data-panel="' + name + '"]' ).addClass( 'is-active' );
|
|
try {
|
|
window.localStorage.setItem( 'm365LoginTab', name );
|
|
} catch ( e ) {}
|
|
// Keep the URL (and the post-save redirect) on this tab.
|
|
var $referer = $( '#m365-referer' );
|
|
if ( $referer.length && window.history && window.history.replaceState ) {
|
|
var url = new URL( window.location.href );
|
|
if ( 'connection' === name ) {
|
|
url.searchParams.delete( 'tab' );
|
|
} else {
|
|
url.searchParams.set( 'tab', name );
|
|
}
|
|
url.searchParams.delete( 'settings-updated' );
|
|
window.history.replaceState( null, '', url.toString() );
|
|
$referer.val( url.pathname + url.search );
|
|
}
|
|
}
|
|
|
|
$tabs.on( 'click', function () {
|
|
activate( $( this ).data( 'tab' ) );
|
|
} );
|
|
|
|
var initial = $( '.m365-admin__tabs' ).data( 'initial-tab' );
|
|
if ( initial && $tabs.filter( '[data-tab="' + initial + '"]' ).length ) {
|
|
activate( initial );
|
|
} else {
|
|
try {
|
|
var saved = window.localStorage.getItem( 'm365LoginTab' );
|
|
if ( saved && $tabs.filter( '[data-tab="' + saved + '"]' ).length ) {
|
|
activate( saved );
|
|
}
|
|
} catch ( e ) {}
|
|
}
|
|
|
|
// Jump to the tab that contains a validation error.
|
|
var $error = $( '.settings-error' ).first();
|
|
if ( $error.length && $error.text().toLowerCase().indexOf( 'tenant' ) !== -1 ) {
|
|
activate( 'connection' );
|
|
}
|
|
|
|
/* ---------------- Live preview ---------------- */
|
|
function setVar( name, value ) {
|
|
$preview[ 0 ].style.setProperty( '--m365-' + name, value );
|
|
}
|
|
|
|
function updateIcon() {
|
|
var show = $( '[data-preview="show-icon"]' ).is( ':checked' );
|
|
var url = $.trim( $( '#m365-icon-url' ).val() );
|
|
var $icon = $( '#m365-preview-icon' );
|
|
var $thumb = $( '#m365-icon-thumb' );
|
|
|
|
$icon.toggleClass( 'is-hidden', ! show );
|
|
|
|
if ( url ) {
|
|
var $img = $( '<img>', { src: url, alt: '' } );
|
|
$icon.empty().append( $img );
|
|
$thumb.empty().append( $img.clone() );
|
|
} else {
|
|
$icon.html( cfg.defaultLogo || '' );
|
|
$thumb.html( cfg.defaultLogo || '' );
|
|
}
|
|
}
|
|
|
|
$( '[data-preview="text"]' ).on( 'input', function () {
|
|
$( '#m365-preview-text' ).text( $( this ).val() );
|
|
} );
|
|
|
|
$( '[data-preview="divider"]' ).on( 'input', function () {
|
|
var val = $.trim( $( this ).val() );
|
|
$( '#m365-preview-divider' ).text( val ).closest( '.m365-login__divider' ).toggleClass( 'is-hidden', ! val );
|
|
} ).trigger( 'input' );
|
|
|
|
$( '[data-preview="show-icon"], #m365-icon-url' ).on( 'change input', updateIcon );
|
|
|
|
$( '[data-preview="radius"]' ).on( 'input change', function () {
|
|
setVar( 'radius', $( this ).val() + 'px' );
|
|
$( '#m365-radius-value' ).text( $( this ).val() + ' px' );
|
|
} );
|
|
|
|
$( '.m365-color' ).wpColorPicker( {
|
|
change: function ( event, ui ) {
|
|
var key = $( event.target ).data( 'preview' );
|
|
var color = ui.color.toString();
|
|
setVar( key.replace( '_', '-' ), color );
|
|
},
|
|
clear: function ( event ) {
|
|
var $input = $( event.target ).closest( '.wp-picker-container' ).find( '.m365-color' );
|
|
setVar( $input.data( 'preview' ).replace( '_', '-' ), $input.data( 'default-color' ) );
|
|
}
|
|
} );
|
|
|
|
$( '.m365-preset' ).on( 'click', function () {
|
|
var preset = $( this ).data( 'preset' );
|
|
if ( ! preset ) {
|
|
return;
|
|
}
|
|
$.each( preset, function ( key, value ) {
|
|
$( '#m365-button_' + key ).wpColorPicker( 'color', value );
|
|
} );
|
|
} );
|
|
|
|
/* ---------------- Media library ---------------- */
|
|
var frame;
|
|
$( '#m365-icon-choose' ).on( 'click', function ( e ) {
|
|
e.preventDefault();
|
|
if ( ! window.wp || ! wp.media ) {
|
|
return;
|
|
}
|
|
if ( ! frame ) {
|
|
frame = wp.media( {
|
|
title: i18n.chooseIcon || '',
|
|
button: { text: i18n.useIcon || '' },
|
|
library: { type: 'image' },
|
|
multiple: false
|
|
} );
|
|
frame.on( 'select', function () {
|
|
var attachment = frame.state().get( 'selection' ).first().toJSON();
|
|
var url = attachment.url;
|
|
if ( attachment.sizes && attachment.sizes.thumbnail && attachment.mime !== 'image/svg+xml' ) {
|
|
url = attachment.sizes.thumbnail.url;
|
|
}
|
|
$( '#m365-icon-url' ).val( url ).trigger( 'input' );
|
|
} );
|
|
}
|
|
frame.open();
|
|
} );
|
|
|
|
$( '#m365-icon-reset' ).on( 'click', function ( e ) {
|
|
e.preventDefault();
|
|
$( '#m365-icon-url' ).val( '' ).trigger( 'input' );
|
|
} );
|
|
|
|
/* ---------------- Auth method switch ---------------- */
|
|
function applyMethod() {
|
|
var method = $( 'input[name$="[auth_method]"]:checked' ).val() || 'secret';
|
|
$( '.m365-method__option' ).removeClass( 'is-selected' ).filter( '[data-method="' + method + '"]' ).addClass( 'is-selected' );
|
|
$( '.m365-auth-panel' ).removeClass( 'is-active' ).filter( '[data-method="' + method + '"]' ).addClass( 'is-active' );
|
|
}
|
|
$( 'input[name$="[auth_method]"]' ).on( 'change', applyMethod );
|
|
applyMethod();
|
|
|
|
/* ---------------- Certificate generation ---------------- */
|
|
$( '#m365-cert-generate' ).on( 'click', function () {
|
|
var $btn = $( this );
|
|
var $out = $( '#m365-cert-result' );
|
|
if ( $btn.data( 'replace' ) && ! window.confirm( i18n.confirmCert ) ) {
|
|
return;
|
|
}
|
|
$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 ) {
|
|
if ( res && res.success ) {
|
|
$out.addClass( 'is-success' ).text( res.data.message );
|
|
window.setTimeout( function () { window.location.reload(); }, 800 );
|
|
} else {
|
|
$out.addClass( 'is-error' ).text( ( res && res.data && res.data.message ) || i18n.testFailed );
|
|
$btn.prop( 'disabled', false );
|
|
}
|
|
} ).fail( function () {
|
|
$out.addClass( 'is-error' ).text( i18n.testFailed );
|
|
$btn.prop( 'disabled', false );
|
|
} );
|
|
} );
|
|
|
|
$( '#m365-cert-remove' ).on( 'change', function () {
|
|
if ( this.checked && ! window.confirm( i18n.confirmCertRemove ) ) {
|
|
this.checked = false;
|
|
}
|
|
} );
|
|
|
|
$( '#m365-cert-paste-toggle' ).on( 'click', function ( e ) {
|
|
e.preventDefault();
|
|
$( '#m365-cert-paste' ).prop( 'hidden', function ( i, v ) { return ! v; } );
|
|
} );
|
|
|
|
/* ---------------- Secret visibility ---------------- */
|
|
$( '.m365-toggle-secret' ).on( 'click', function () {
|
|
var $input = $( '#m365-client-secret' );
|
|
var show = 'password' === $input.attr( 'type' );
|
|
$input.attr( 'type', show ? 'text' : 'password' );
|
|
$( this ).find( '.dashicons' ).toggleClass( 'dashicons-visibility', ! show ).toggleClass( 'dashicons-hidden', show );
|
|
} );
|
|
|
|
/* ---------------- Copy redirect URI ---------------- */
|
|
$( '.m365-copy__button' ).on( 'click', function () {
|
|
var $btn = $( this );
|
|
var text = $( '#' + $btn.data( 'copy' ) ).text();
|
|
var done = function () {
|
|
$btn.text( i18n.copied || 'Copied!' );
|
|
window.setTimeout( function () {
|
|
$btn.text( i18n.copy || 'Copy' );
|
|
}, 1500 );
|
|
};
|
|
if ( navigator.clipboard && navigator.clipboard.writeText ) {
|
|
navigator.clipboard.writeText( text ).then( done );
|
|
} else {
|
|
var $tmp = $( '<textarea>' ).val( text ).appendTo( 'body' ).select();
|
|
try {
|
|
document.execCommand( 'copy' );
|
|
} catch ( e ) {}
|
|
$tmp.remove();
|
|
done();
|
|
}
|
|
} );
|
|
|
|
/* ---------------- 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 $( '<div>' ).text( str || '' ).html();
|
|
}
|
|
|
|
$( '.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 refresh() {
|
|
$list.toggleClass( 'is-empty', 0 === $list.children( 'li' ).length );
|
|
}
|
|
|
|
function has( id ) {
|
|
return $list.find( 'li[data-id="' + id + '"]' ).length > 0;
|
|
}
|
|
|
|
function add( id, name ) {
|
|
id = ( id || '' ).toLowerCase();
|
|
if ( ! guidPattern.test( id ) || has( id ) ) {
|
|
return;
|
|
}
|
|
var $li = $( '<li class="m365-group-chip">' ).attr( 'data-id', id );
|
|
if ( 'roles' === mode ) {
|
|
$li.addClass( 'm365-group-chip--role' );
|
|
$li.append( $( '<button type="button" class="m365-group-chip__up">↑</button>' ).attr( 'aria-label', i18n.moveUp || '' ) );
|
|
}
|
|
$li.append( $( '<span class="m365-group-chip__name">' ).text( name || id ) );
|
|
$li.append( $( '<code class="m365-group-chip__id">' ).text( id ) );
|
|
if ( 'roles' === mode ) {
|
|
$li.append( '<span class="m365-group-chip__arrow" aria-hidden="true">→</span>' );
|
|
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( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + '][name]' ).val( name || id ) );
|
|
} else {
|
|
$li.append( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + ']' ).val( name || id ) );
|
|
}
|
|
$li.append( $( '<button type="button" class="m365-group-chip__remove">×</button>' ).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 = $( '<div class="m365-group-result">' );
|
|
var $meta = $( '<div class="m365-group-result__meta">' );
|
|
$meta.append( $( '<strong>' ).text( g.name ) );
|
|
if ( g.type ) {
|
|
$meta.append( $( '<span class="m365-group-result__type">' ).text( g.type ) );
|
|
}
|
|
$meta.append( $( '<code>' ).text( g.id ) );
|
|
if ( g.description ) {
|
|
$meta.append( $( '<em>' ).text( g.description ) );
|
|
}
|
|
var $btn = $( '<button type="button" class="button button-small">' ).text( i18n.add ).on( 'click', function () {
|
|
add( g.id, g.name );
|
|
$( this ).prop( 'disabled', true );
|
|
} );
|
|
if ( has( ( g.id || '' ).toLowerCase() ) ) {
|
|
$btn.prop( 'disabled', true );
|
|
}
|
|
return $row.append( $meta ).append( $btn );
|
|
}
|
|
|
|
function search() {
|
|
var query = $.trim( $query.val() );
|
|
$results.prop( 'hidden', false ).removeClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.searching ) + '</p>' );
|
|
|
|
$.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;
|
|
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( msg ) + '</p>' );
|
|
// Allow adding a pasted GUID even when Graph is unavailable.
|
|
if ( /^[0-9a-f-]{36}$/i.test( query ) ) {
|
|
$results.append( buildResult( { id: query, name: query, type: '', description: '' } ) );
|
|
}
|
|
return;
|
|
}
|
|
var groups = res.data.groups || [];
|
|
if ( ! groups.length ) {
|
|
$results.html( '<p class="m365-group-results__status">' + escapeHtml( i18n.noGroups ) + '</p>' );
|
|
return;
|
|
}
|
|
$results.empty();
|
|
$.each( groups, function ( i, g ) {
|
|
$results.append( buildResult( g ) );
|
|
} );
|
|
} ).fail( function () {
|
|
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.testFailed ) + '</p>' );
|
|
} );
|
|
}
|
|
|
|
$picker.find( '.m365-picker__search' ).on( 'click', search );
|
|
$query.on( 'keydown', function ( e ) {
|
|
if ( 'Enter' === e.key ) {
|
|
e.preventDefault();
|
|
search();
|
|
}
|
|
} );
|
|
} );
|
|
|
|
/* ---------------- User sync ---------------- */
|
|
var dirty = false;
|
|
function markDirty() {
|
|
dirty = true;
|
|
}
|
|
$( '.m365-admin__form' ).on( 'change input', ':input:not(.m365-picker__query)', markDirty );
|
|
|
|
$( '.m365-sync-run' ).on( 'click', function () {
|
|
var $btn = $( this );
|
|
var op = $btn.data( 'op' );
|
|
var $report = $( '#m365-sync-report' );
|
|
|
|
if ( dirty && ! window.confirm( i18n.unsaved ) ) {
|
|
return;
|
|
}
|
|
if ( 'live' === op && ! window.confirm( i18n.confirmSync ) ) {
|
|
return;
|
|
}
|
|
|
|
$( '.m365-sync-run' ).prop( 'disabled', true );
|
|
$report.html( $( '<p class="m365-inline-result">' ).text( i18n.syncRunning ) );
|
|
|
|
$.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonce, op: op } ).done( function ( res ) {
|
|
if ( res && res.success ) {
|
|
$report.html( res.data.html );
|
|
} else {
|
|
$report.html( $( '<p class="m365-inline-result is-error">' ).text( ( res && res.data && res.data.message ) || i18n.syncFailed ) );
|
|
}
|
|
} ).fail( function () {
|
|
$report.html( $( '<p class="m365-inline-result is-error">' ).text( i18n.syncFailed ) );
|
|
} ).always( function () {
|
|
$( '.m365-sync-run' ).prop( 'disabled', false );
|
|
} );
|
|
} );
|
|
|
|
/* ---------------- Fallback key ---------------- */
|
|
$( '#m365-fallback-regenerate' ).on( 'change', function () {
|
|
if ( this.checked && ! window.confirm( i18n.confirmKey ) ) {
|
|
this.checked = false;
|
|
}
|
|
} );
|
|
|
|
/* ---------------- Test tenant ---------------- */
|
|
$( '#m365-test' ).on( 'click', function () {
|
|
var $btn = $( this );
|
|
var $out = $( '#m365-test-result' );
|
|
var label = $btn.text();
|
|
|
|
$btn.prop( 'disabled', true ).text( i18n.testing || '…' );
|
|
$out.removeClass( 'is-success is-error' ).prop( 'hidden', true ).empty();
|
|
|
|
$.post( cfg.ajaxUrl, {
|
|
action: cfg.action,
|
|
nonce: cfg.nonce,
|
|
tenant: $( '#m365-tenant' ).val()
|
|
} ).done( function ( res ) {
|
|
if ( res && res.success ) {
|
|
$out.addClass( 'is-success' ).text( res.data.message ).append( $( '<code>' ).text( res.data.issuer ) );
|
|
} else {
|
|
$out.addClass( 'is-error' ).text( ( res && res.data && res.data.message ) || i18n.testFailed || '' );
|
|
}
|
|
} ).fail( function () {
|
|
$out.addClass( 'is-error' ).text( i18n.testFailed || '' );
|
|
} ).always( function () {
|
|
$btn.prop( 'disabled', false ).text( label );
|
|
$out.prop( 'hidden', false );
|
|
} );
|
|
} );
|
|
} );
|
|
}( jQuery ) );
|