Skip to content

Commit 7976e54

Browse files
committed
enhance: add re-sync from WorkOS action to admin Users list
Add per-row "Re-sync" action and bulk "Re-sync from WorkOS" for linked users, allowing admins to pull the latest profile data from WorkOS on demand. Also wire up org membership sync on login via workos_user_authenticated hook.
1 parent 1f6730d commit 7976e54

3 files changed

Lines changed: 222 additions & 12 deletions

File tree

‎src/WorkOS/Admin/UserList.php‎

Lines changed: 143 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function __construct() {
2828
add_filter( 'bulk_actions-users', [ $this, 'add_bulk_action' ] );
2929
add_filter( 'handle_bulk_actions-users', [ $this, 'handle_bulk_action' ], 10, 3 );
3030
add_action( 'admin_init', [ $this, 'handle_single_sync' ] );
31+
add_action( 'admin_init', [ $this, 'handle_single_resync' ] );
3132
add_action( 'admin_notices', [ $this, 'render_notices' ] );
3233
}
3334

@@ -61,7 +62,7 @@ public function render_column( string $output, string $column_name, int $user_id
6162
$environment_id = \WorkOS\Config::get_environment_id();
6263

6364
if ( $workos_id ) {
64-
return $this->render_linked_column( $workos_id, $environment_id );
65+
return $this->render_linked_column( $user_id, $workos_id, $environment_id );
6566
}
6667

6768
return $this->render_unlinked_column( $user_id );
@@ -70,12 +71,13 @@ public function render_column( string $output, string $column_name, int $user_id
7071
/**
7172
* Render column content for a user linked to WorkOS.
7273
*
74+
* @param int $user_id WordPress user ID.
7375
* @param string $workos_id WorkOS user ID.
7476
* @param string $environment_id WorkOS environment ID.
7577
*
7678
* @return string Column HTML.
7779
*/
78-
private function render_linked_column( string $workos_id, string $environment_id ): string {
80+
private function render_linked_column( int $user_id, string $workos_id, string $environment_id ): string {
7981
if ( ! $environment_id ) {
8082
return '<code>' . esc_html( $workos_id ) . '</code>';
8183
}
@@ -98,6 +100,26 @@ private function render_linked_column( string $workos_id, string $environment_id
98100
esc_url( $dashboard_url ),
99101
esc_html__( 'View in WorkOS', 'workos' )
100102
);
103+
104+
if ( workos()->is_enabled() && current_user_can( 'edit_users' ) ) {
105+
$resync_url = wp_nonce_url(
106+
add_query_arg(
107+
[
108+
'action' => 'workos_resync_user',
109+
'user_id' => $user_id,
110+
],
111+
admin_url( 'users.php' )
112+
),
113+
'workos_resync_user_' . $user_id
114+
);
115+
116+
$html .= sprintf(
117+
' | <span class="resync"><a href="%s">%s</a></span>',
118+
esc_url( $resync_url ),
119+
esc_html__( 'Re-sync', 'workos' )
120+
);
121+
}
122+
101123
$html .= '</div>';
102124

103125
return $html;
@@ -149,7 +171,8 @@ public function add_bulk_action( array $actions ): array {
149171
return $actions;
150172
}
151173

152-
$actions['workos_bulk_sync'] = __( 'Sync to WorkOS', 'workos' );
174+
$actions['workos_bulk_sync'] = __( 'Sync to WorkOS', 'workos' );
175+
$actions['workos_bulk_resync'] = __( 'Re-sync from WorkOS', 'workos' );
153176
return $actions;
154177
}
155178

@@ -163,14 +186,18 @@ public function add_bulk_action( array $actions ): array {
163186
* @return string Updated redirect URL with result query args.
164187
*/
165188
public function handle_bulk_action( string $redirect_url, string $action, array $user_ids ): string {
166-
if ( 'workos_bulk_sync' !== $action ) {
189+
if ( ! in_array( $action, [ 'workos_bulk_sync', 'workos_bulk_resync' ], true ) ) {
167190
return $redirect_url;
168191
}
169192

170193
if ( ! current_user_can( 'edit_users' ) ) {
171194
return $redirect_url;
172195
}
173196

197+
if ( 'workos_bulk_resync' === $action ) {
198+
return $this->handle_bulk_resync( $redirect_url, $user_ids );
199+
}
200+
174201
$synced = 0;
175202
$failed = 0;
176203
$skipped = 0;
@@ -211,6 +238,55 @@ public function handle_bulk_action( string $redirect_url, string $action, array
211238
);
212239
}
213240

241+
/**
242+
* Handle the bulk "Re-sync from WorkOS" action.
243+
*
244+
* @param string $redirect_url Redirect URL.
245+
* @param array $user_ids Selected user IDs.
246+
*
247+
* @return string Updated redirect URL with result query args.
248+
*/
249+
private function handle_bulk_resync( string $redirect_url, array $user_ids ): string {
250+
$resynced = 0;
251+
$failed = 0;
252+
$skipped = 0;
253+
$errors = [];
254+
255+
foreach ( $user_ids as $user_id ) {
256+
$user_id = absint( $user_id );
257+
$workos_id = get_user_meta( $user_id, '_workos_user_id', true );
258+
259+
if ( ! $workos_id ) {
260+
++$skipped;
261+
continue;
262+
}
263+
264+
$result = UserSync::resync_from_workos( $user_id );
265+
266+
if ( is_wp_error( $result ) ) {
267+
++$failed;
268+
$errors[] = sprintf(
269+
/* translators: 1: user ID, 2: error message */
270+
__( 'User #%1$d: %2$s', 'workos' ),
271+
$user_id,
272+
$result->get_error_message()
273+
);
274+
} else {
275+
++$resynced;
276+
}
277+
}
278+
279+
return add_query_arg(
280+
[
281+
'workos_resynced' => $resynced,
282+
'workos_failed' => $failed,
283+
'workos_skipped' => $skipped,
284+
'workos_errors' => rawurlencode( implode( ' | ', $errors ) ),
285+
],
286+
$redirect_url
287+
);
288+
}
289+
214290
/**
215291
* Handle a single row-action sync request.
216292
*/
@@ -246,6 +322,41 @@ public function handle_single_sync(): void {
246322
exit;
247323
}
248324

325+
/**
326+
* Handle a single row-action re-sync request.
327+
*/
328+
public function handle_single_resync(): void {
329+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below via check_admin_referer().
330+
if ( empty( $_GET['action'] ) || 'workos_resync_user' !== $_GET['action'] ) {
331+
return;
332+
}
333+
334+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below via check_admin_referer().
335+
$user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
336+
if ( ! $user_id ) {
337+
return;
338+
}
339+
340+
check_admin_referer( 'workos_resync_user_' . $user_id );
341+
342+
if ( ! current_user_can( 'edit_users' ) ) {
343+
wp_die( esc_html__( 'You do not have permission to perform this action.', 'workos' ) );
344+
}
345+
346+
$result = UserSync::resync_from_workos( $user_id );
347+
348+
$query_args = [];
349+
if ( is_wp_error( $result ) ) {
350+
$query_args['workos_failed'] = 1;
351+
$query_args['workos_errors'] = rawurlencode( $result->get_error_message() );
352+
} else {
353+
$query_args['workos_resynced'] = 1;
354+
}
355+
356+
wp_safe_redirect( add_query_arg( $query_args, admin_url( 'users.php' ) ) );
357+
exit;
358+
}
359+
249360
/**
250361
* Render admin notices from sync operations.
251362
*/
@@ -256,13 +367,15 @@ public function render_notices(): void {
256367
}
257368

258369
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only; data is sanitized below.
259-
$synced = isset( $_GET['workos_synced'] ) ? absint( $_GET['workos_synced'] ) : 0;
370+
$synced = isset( $_GET['workos_synced'] ) ? absint( $_GET['workos_synced'] ) : 0;
371+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only; data is sanitized below.
372+
$resynced = isset( $_GET['workos_resynced'] ) ? absint( $_GET['workos_resynced'] ) : 0;
260373
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only; data is sanitized below.
261-
$failed = isset( $_GET['workos_failed'] ) ? absint( $_GET['workos_failed'] ) : 0;
374+
$failed = isset( $_GET['workos_failed'] ) ? absint( $_GET['workos_failed'] ) : 0;
262375
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only; data is sanitized below.
263-
$skipped = isset( $_GET['workos_skipped'] ) ? absint( $_GET['workos_skipped'] ) : 0;
376+
$skipped = isset( $_GET['workos_skipped'] ) ? absint( $_GET['workos_skipped'] ) : 0;
264377
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only; data is sanitized below.
265-
$errors = isset( $_GET['workos_errors'] ) ? sanitize_text_field( wp_unslash( $_GET['workos_errors'] ) ) : '';
378+
$errors = isset( $_GET['workos_errors'] ) ? sanitize_text_field( wp_unslash( $_GET['workos_errors'] ) ) : '';
266379

267380
if ( $synced ) {
268381
printf(
@@ -282,12 +395,30 @@ public function render_notices(): void {
282395
);
283396
}
284397

398+
if ( $resynced ) {
399+
printf(
400+
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
401+
esc_html(
402+
sprintf(
403+
/* translators: %d: number of users re-synced */
404+
_n(
405+
'%d user re-synced from WorkOS.',
406+
'%d users re-synced from WorkOS.',
407+
$resynced,
408+
'workos'
409+
),
410+
$resynced
411+
)
412+
)
413+
);
414+
}
415+
285416
if ( $failed ) {
286417
$message = sprintf(
287418
/* translators: %d: number of users that failed to sync */
288419
_n(
289-
'%d user failed to sync to WorkOS.',
290-
'%d users failed to sync to WorkOS.',
420+
'%d user failed to sync.',
421+
'%d users failed to sync.',
291422
$failed,
292423
'workos'
293424
),
@@ -311,8 +442,8 @@ public function render_notices(): void {
311442
sprintf(
312443
/* translators: %d: number of users skipped */
313444
_n(
314-
'%d user skipped (already linked to WorkOS).',
315-
'%d users skipped (already linked to WorkOS).',
445+
'%d user skipped (not applicable).',
446+
'%d users skipped (not applicable).',
316447
$skipped,
317448
'workos'
318449
),

‎src/WorkOS/Organization/Manager.php‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function __construct() {
2222
add_action( 'workos_webhook_organization.updated', [ $this, 'handle_org_updated' ] );
2323
add_action( 'workos_webhook_organization_membership.created', [ $this, 'handle_membership_created' ] );
2424
add_action( 'workos_webhook_organization_membership.deleted', [ $this, 'handle_membership_deleted' ] );
25+
add_action( 'workos_user_authenticated', [ self::class, 'ensure_user_org_membership' ], 10, 2 );
2526
}
2627

2728
/**
@@ -107,6 +108,48 @@ public function handle_membership_deleted( array $event ): void {
107108
self::remove_membership( $local_org->id, $wp_user_id );
108109
}
109110

111+
// -------------------------------------------------------------------------
112+
// Login membership sync
113+
// -------------------------------------------------------------------------
114+
115+
/**
116+
* Ensure the authenticated user has a local org membership for the configured organization.
117+
*
118+
* Hooked to `workos_user_authenticated` so it fires on both redirect and headless login.
119+
*
120+
* @param int $wp_user_id WordPress user ID.
121+
* @param array $auth_result Full WorkOS auth response.
122+
*/
123+
public static function ensure_user_org_membership( int $wp_user_id, array $auth_result ): void {
124+
$workos_org_id = \WorkOS\Config::get_organization_id();
125+
if ( empty( $workos_org_id ) ) {
126+
return;
127+
}
128+
129+
$local_org = self::get_by_workos_id( $workos_org_id );
130+
131+
// If the local org doesn't exist yet, fetch from WorkOS API and upsert.
132+
if ( ! $local_org ) {
133+
$org_data = workos()->api()->get_organization( $workos_org_id );
134+
if ( ! is_wp_error( $org_data ) && ! empty( $org_data['id'] ) ) {
135+
self::upsert_organization( $org_data );
136+
$local_org = self::get_by_workos_id( $workos_org_id );
137+
}
138+
}
139+
140+
if ( ! $local_org ) {
141+
return;
142+
}
143+
144+
self::add_membership(
145+
(int) $local_org->id,
146+
$wp_user_id,
147+
[
148+
'workos_role' => 'member',
149+
]
150+
);
151+
}
152+
110153
// -------------------------------------------------------------------------
111154
// CRUD
112155
// -------------------------------------------------------------------------

‎src/WorkOS/Sync/UserSync.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,42 @@ public static function sync_existing_user( int $user_id ) {
271271
return $result;
272272
}
273273

274+
/**
275+
* Re-sync a linked WordPress user from WorkOS.
276+
*
277+
* Fetches the latest profile data from WorkOS and updates the local WP user.
278+
* Intended for manual re-sync when profile data drifts or webhooks are missed.
279+
*
280+
* @param int $user_id WordPress user ID.
281+
*
282+
* @return array|\WP_Error WorkOS user data on success, WP_Error on failure.
283+
*/
284+
public static function resync_from_workos( int $user_id ) {
285+
if ( self::$syncing ) {
286+
return new \WP_Error( 'workos_sync_in_progress', __( 'A sync operation is already in progress.', 'workos' ) );
287+
}
288+
289+
if ( ! workos()->is_enabled() ) {
290+
return new \WP_Error( 'workos_not_configured', __( 'WorkOS is not configured.', 'workos' ) );
291+
}
292+
293+
$workos_id = get_user_meta( $user_id, '_workos_user_id', true );
294+
if ( ! $workos_id ) {
295+
return new \WP_Error( 'workos_not_linked', __( 'User is not linked to WorkOS.', 'workos' ) );
296+
}
297+
298+
$result = workos()->api()->get_user( $workos_id );
299+
300+
if ( is_wp_error( $result ) ) {
301+
workos_log( 'Failed to re-sync user #' . $user_id . ' from WorkOS: ' . $result->get_error_message(), 'error' );
302+
return $result;
303+
}
304+
305+
self::update_wp_user_from_workos( $user_id, $result );
306+
307+
return $result;
308+
}
309+
274310
/**
275311
* Handle user.updated webhook: sync WorkOS -> WP.
276312
*

0 commit comments

Comments
 (0)