From de9cc58128d78f631118502206c0d31e71c75ce2 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 07:58:17 -0400 Subject: [PATCH] feat(admin): WorkOS Users list page (1.0.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "WorkOS → Users" admin submenu mounts a paginated, searchable React list of WorkOS users for the active environment. Each row exposes an "Open in WorkOS" deep-link to dashboard.workos.com/{env}/users/{id}/details so admins can re-enable a suppressed email (and otherwise triage the user) in the Dashboard's Emails tab. Backed by GET /wp-json/workos/v1/admin/users (manage_options) which proxies Api\Client::list_users() with sanitized limit (1..100), cursor (after/before, after wins), email substring and organization_id pass-through. Each user record is enriched server-side with a dashboard_url so the React client doesn't reconstruct it. No bulk re-enable in this release: WorkOS exposes the "Re-enable email" action only through the Dashboard. Verified against the API reference, the User schema, the Events catalogue, and the public workos-node / -python / -ruby / -go SDK sources — there is no email-suppression API, no email_disabled field, and no email.suppressed webhook event as of 2026-05-18. The page is built so a future bulk action wires in without a UI rewrite. Refs CONS-273. --- AGENTS.md | 7 +- CHANGELOG.md | 24 ++ integration-workos.php | 2 +- readme.txt | 9 +- src/WorkOS/Admin/Users/AdminPage.php | 126 ++++++++ src/WorkOS/Admin/Users/Controller.php | 41 +++ src/WorkOS/Admin/Users/RestApi.php | 274 ++++++++++++++++ src/WorkOS/Controller.php | 2 + src/WorkOS/Plugin.php | 2 +- src/js/admin-users/index.tsx | 426 +++++++++++++++++++++++++ src/js/admin-users/styles.css | 116 +++++++ tests/wpunit/AdminUsersRestApiTest.php | 313 ++++++++++++++++++ webpack.config.js | 1 + 13 files changed, 1339 insertions(+), 4 deletions(-) create mode 100644 src/WorkOS/Admin/Users/AdminPage.php create mode 100644 src/WorkOS/Admin/Users/Controller.php create mode 100644 src/WorkOS/Admin/Users/RestApi.php create mode 100644 src/js/admin-users/index.tsx create mode 100644 src/js/admin-users/styles.css create mode 100644 tests/wpunit/AdminUsersRestApiTest.php diff --git a/AGENTS.md b/AGENTS.md index 3f1a585..fbdb2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Enterprise identity management for WordPress powered by WorkOS. SSO, directory sync, MFA, and user management. -- **Version:** 1.0.4 +- **Version:** 1.0.5 - **Namespace:** `WorkOS\` - **PHP Requirement:** 7.4+ - **WordPress Requirement:** 5.9+ @@ -80,6 +80,11 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Admin/DiagnosticsPage.php` | System diagnostics page | | `src/WorkOS/Admin/OnboardingPage.php` | Onboarding wizard UI | | `src/WorkOS/Admin/OnboardingAjax.php` | Onboarding wizard AJAX handlers | +| **Admin — Users** | | +| `src/WorkOS/Admin/Users/Controller.php` | Wires the WorkOS Users admin submenu + REST endpoint | +| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list | +| `src/WorkOS/Admin/Users/RestApi.php` | `GET /wp-json/workos/v1/admin/users` — proxies `Api\Client::list_users()` with sanitized pagination + filters and a server-computed `dashboard_url` per row | +| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link) | | **Admin — Login Profiles (Custom AuthKit)** | | | `src/WorkOS/Admin/LoginProfiles/Controller.php` | Wires Login Profile admin page + CRUD REST | | `src/WorkOS/Admin/LoginProfiles/AdminPage.php` | Admin submenu that mounts the React editor | diff --git a/CHANGELOG.md b/CHANGELOG.md index 583f00f..73779d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.0.5] - 2026-05-18 + +### Added + +- **WorkOS → Users admin page** ([CONS-273](https://linear.app/nexcess/issue/CONS-273/re-enable-workos-emails-for-affected-portal-users)) + — new submenu under WorkOS that mounts a paginated, searchable React + list of WorkOS users for the active environment. Each row exposes an + "Open in WorkOS" deep-link that takes the admin straight to the user's + Dashboard page (`https://dashboard.workos.com/{env}/users/{id}/details`), + where the per-user "Re-enable email" action lives. Gated by + `manage_options`. Backed by `GET /wp-json/workos/v1/admin/users`, which + proxies `Api\Client::list_users()` with sanitized `limit` (1..100), + cursor (`after`/`before`), `email` substring, and `organization_id` + pass-through, and enriches each user record with a server-computed + `dashboard_url` so the React side doesn't reconstruct it. + + **Why list-only:** WorkOS exposes the "Re-enable email" action only + through the Dashboard — there is no public REST endpoint or webhook + event for email suppression / bounce state as of this release + (verified against the WorkOS API reference, the User schema, the + Events catalogue, and the public workos-node / -python / -ruby / -go + SDK sources). This page builds the foundation; once WorkOS ships an + API, a row + bulk action can be wired in without reworking the UI. + ## [1.0.4] - 2026-05-14 ### Fixed diff --git a/integration-workos.php b/integration-workos.php index d5a19f2..5c6e9b1 100644 --- a/integration-workos.php +++ b/integration-workos.php @@ -3,7 +3,7 @@ * Plugin Name: Integration with WorkOS * Plugin URI: https://github.com/bordoni/integration-workos * Description: Enterprise identity management for WordPress powered by WorkOS. SSO, directory sync, MFA, and user management. - * Version: 1.0.4 + * Version: 1.0.5 * Author: Gustavo Bordoni * Author URI: https://github.com/bordoni * License: GPL-2.0-or-later diff --git a/readme.txt b/readme.txt index 75a3bdc..cda6cdb 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: sso, identity, workos, authentication, directory-sync Requires at least: 6.2 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.4 +Stable tag: 1.0.5 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -175,6 +175,10 @@ WorkOS is provided by WorkOS, Inc. == Changelog == += 1.0.5 - 2026-05-18 = + +* New: WorkOS → Users admin page. Paginated, searchable React list of WorkOS users for the active environment, with a per-row "Open in WorkOS" deep-link straight to the user's Dashboard page. Lets admins triage WorkOS users (including re-enabling a suppressed email under the Dashboard's Emails tab) without bouncing through the Dashboard's own user picker. Requires `manage_options`. No bulk re-enable yet — WorkOS does not expose a public REST endpoint for the "Re-enable email" action. ([CONS-273](https://linear.app/nexcess/issue/CONS-273/re-enable-workos-emails-for-affected-portal-users)) + = 1.0.4 - 2026-05-14 = * Fix: `wp-login.php?loggedout=true` is now claimed by the AuthKit takeover instead of rendering native wp-login. The "you have been logged out" screen advertised the wp-login username/password field, which legacy customers misread as a still-working classic sign-in. The URL now 302s to `/login/?loggedout=true` (or the configured custom path) so the React form handles it. `?fallback=1`, `?workos=0`, and `action=logout|lostpassword|rp|...` bypasses are unchanged. (#18) @@ -245,6 +249,9 @@ Base platform: == Upgrade Notice == += 1.0.5 = +Adds a new WorkOS → Users admin page (read-only, paginated, searchable) with deep-links into the WorkOS Dashboard so admins can re-enable a user's suppressed email faster. No bulk re-enable yet — WorkOS does not expose a public API for that action. + = 1.0.4 = Fixes the "you have been logged out" screen leaking the native wp-login form, password-reset emails arriving with HTML-encoded `&` in the link, an infinite redirect loop caused by cached redirect responses, and a Login Profile editor bug where unchecking an auth method or MFA factor did not persist on save. diff --git a/src/WorkOS/Admin/Users/AdminPage.php b/src/WorkOS/Admin/Users/AdminPage.php new file mode 100644 index 0000000..ee7ad6e --- /dev/null +++ b/src/WorkOS/Admin/Users/AdminPage.php @@ -0,0 +1,126 @@ + +
+

+
+

+ +

+
+
+ [ 'wp-element', 'wp-i18n' ], + 'version' => WORKOS_VERSION, + ]; + + wp_enqueue_script( + self::SCRIPT_HANDLE, + $assets_url . 'admin-users.js', + $asset['dependencies'] ?? [ 'wp-element', 'wp-i18n' ], + $asset['version'] ?? WORKOS_VERSION, + true + ); + + wp_set_script_translations( self::SCRIPT_HANDLE, 'integration-workos' ); + + wp_enqueue_style( + self::STYLE_HANDLE, + $assets_url . 'admin-users.css', + [], + $asset['version'] ?? WORKOS_VERSION + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'workosUsersAdmin', + [ + 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . RestApi::BASE ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'environment' => Config::get_active_environment(), + 'environmentId' => Config::get_environment_id(), + 'dashboardBaseUrl' => 'https://dashboard.workos.com', + 'defaultLimit' => 25, + 'pluginEnabled' => workos()->is_enabled(), + ] + ); + } +} diff --git a/src/WorkOS/Admin/Users/Controller.php b/src/WorkOS/Admin/Users/Controller.php new file mode 100644 index 0000000..76f7501 --- /dev/null +++ b/src/WorkOS/Admin/Users/Controller.php @@ -0,0 +1,41 @@ +container->singleton( RestApi::class ); + $this->container->get( RestApi::class ); + + $this->container->singleton( AdminPage::class ); + $this->container->get( AdminPage::class )->register(); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + } +} diff --git a/src/WorkOS/Admin/Users/RestApi.php b/src/WorkOS/Admin/Users/RestApi.php new file mode 100644 index 0000000..85bffac --- /dev/null +++ b/src/WorkOS/Admin/Users/RestApi.php @@ -0,0 +1,274 @@ + 'GET', + 'callback' => [ $this, 'list_users' ], + 'permission_callback' => [ $this, 'permission_check' ], + 'args' => [ + 'limit' => [ + 'type' => 'integer', + 'required' => false, + 'default' => self::DEFAULT_LIMIT, + 'sanitize_callback' => [ $this, 'sanitize_limit' ], + ], + 'before' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'after' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'email' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'organization_id' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ], + ] + ); + } + + /** + * Permission check — admin capability required. + * + * @return true|WP_Error + */ + public function permission_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to manage WorkOS users.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * Clamp a `limit` value into [1, MAX_LIMIT]. + * + * @param mixed $value Raw value from the request. + * + * @return int + */ + public function sanitize_limit( $value ): int { + $limit = (int) $value; + if ( $limit < 1 ) { + return self::DEFAULT_LIMIT; + } + if ( $limit > self::MAX_LIMIT ) { + return self::MAX_LIMIT; + } + return $limit; + } + + /** + * GET /admin/users — proxy WorkOS user list with pagination + filters. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public function list_users( WP_REST_Request $request ): WP_REST_Response { + if ( ! workos()->is_enabled() ) { + return new WP_REST_Response( + [ + 'data' => [], + 'list_metadata' => [ + 'before' => null, + 'after' => null, + ], + 'error' => __( 'WorkOS is not configured. Save API credentials to enable user listing.', 'integration-workos' ), + ], + 200 + ); + } + + $params = $this->build_upstream_params( $request ); + + $result = workos()->api()->list_users( $params ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( + [ + 'data' => [], + 'list_metadata' => [ + 'before' => null, + 'after' => null, + ], + 'error' => $result->get_error_message(), + ], + 200 + ); + } + + $environment_id = Config::get_environment_id(); + $users = isset( $result['data'] ) && is_array( $result['data'] ) + ? array_map( + function ( $user ) use ( $environment_id ) { + return $this->shape_user( is_array( $user ) ? $user : [], $environment_id ); + }, + $result['data'] + ) + : []; + + $metadata = isset( $result['list_metadata'] ) && is_array( $result['list_metadata'] ) + ? $result['list_metadata'] + : []; + + return new WP_REST_Response( + [ + 'data' => $users, + 'list_metadata' => [ + 'before' => isset( $metadata['before'] ) && '' !== $metadata['before'] ? (string) $metadata['before'] : null, + 'after' => isset( $metadata['after'] ) && '' !== $metadata['after'] ? (string) $metadata['after'] : null, + ], + ], + 200 + ); + } + + /** + * Build the upstream WorkOS params from the validated request. + * + * Cursor handling: WorkOS rejects requests that include both `before` and + * `after`, so we only forward whichever is present (preferring `after`). + * Empty strings are dropped entirely — `sanitize_text_field` keeps empty + * defaults around and the upstream treats them as set. + * + * @param WP_REST_Request $request REST request. + * + * @return array + */ + private function build_upstream_params( WP_REST_Request $request ): array { + $params = [ + 'limit' => (int) $request->get_param( 'limit' ), + 'order' => 'desc', + ]; + + $after = (string) $request->get_param( 'after' ); + $before = (string) $request->get_param( 'before' ); + if ( '' !== $after ) { + $params['after'] = $after; + } elseif ( '' !== $before ) { + $params['before'] = $before; + } + + $email = (string) $request->get_param( 'email' ); + if ( '' !== $email ) { + $params['email'] = $email; + } + + $org = (string) $request->get_param( 'organization_id' ); + if ( '' !== $org ) { + $params['organization_id'] = $org; + } + + return $params; + } + + /** + * Shape an upstream WorkOS user object for REST output. + * + * Drops fields that aren't useful in the list view (raw metadata, + * profile picture URL — the list intentionally doesn't render avatars + * to keep the page lean) and adds a server-computed `dashboard_url`. + * + * @param array $user Upstream user object. + * @param string $environment_id Active WorkOS environment ID (for the deep link). + * + * @return array + */ + private function shape_user( array $user, string $environment_id ): array { + $id = isset( $user['id'] ) ? (string) $user['id'] : ''; + $email = isset( $user['email'] ) ? (string) $user['email'] : ''; + + $dashboard_url = ''; + if ( '' !== $id && '' !== $environment_id ) { + $dashboard_url = sprintf( + 'https://dashboard.workos.com/%s/users/%s/details', + rawurlencode( $environment_id ), + rawurlencode( $id ) + ); + } + + return [ + 'id' => $id, + 'email' => $email, + 'email_verified' => ! empty( $user['email_verified'] ), + 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', + 'last_name' => isset( $user['last_name'] ) ? (string) $user['last_name'] : '', + 'last_sign_in_at' => isset( $user['last_sign_in_at'] ) ? (string) $user['last_sign_in_at'] : '', + 'created_at' => isset( $user['created_at'] ) ? (string) $user['created_at'] : '', + 'updated_at' => isset( $user['updated_at'] ) ? (string) $user['updated_at'] : '', + 'dashboard_url' => $dashboard_url, + ]; + } +} diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index b622524..4fa58df 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -11,6 +11,7 @@ use WorkOS\Admin\AdminBar; use WorkOS\Admin\Controller as AdminController; use WorkOS\Admin\LoginProfiles\Controller as LoginProfilesAdminController; +use WorkOS\Admin\Users\Controller as UsersAdminController; use WorkOS\Auth\AuthKit\Controller as AuthKitController; use WorkOS\Auth\Controller as AuthController; use WorkOS\REST\Controller as RESTController; @@ -35,6 +36,7 @@ protected function doRegister(): void { $this->container->register( AdminController::class ); $this->container->register( AuthKitController::class ); $this->container->register( LoginProfilesAdminController::class ); + $this->container->register( UsersAdminController::class ); $this->container->register( AuthController::class ); $this->container->register( RESTController::class ); $this->container->register( WebhookController::class ); diff --git a/src/WorkOS/Plugin.php b/src/WorkOS/Plugin.php index 2fd15d4..08bcbdb 100644 --- a/src/WorkOS/Plugin.php +++ b/src/WorkOS/Plugin.php @@ -57,7 +57,7 @@ class Plugin { * * @var string */ - private string $version = '1.0.4'; + private string $version = '1.0.5'; /** * Container instance. diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx new file mode 100644 index 0000000..c799912 --- /dev/null +++ b/src/js/admin-users/index.tsx @@ -0,0 +1,426 @@ +/** + * WorkOS Users admin page. + * + * Mounts onto #workos-users-admin-root. Read-only list of WorkOS users + * with cursor pagination + email search; each row links to the WorkOS + * Dashboard where admins can re-enable a suppressed email (no public API + * for that action yet). + */ + +import { + createRoot, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import './styles.css'; + +interface AdminConfig { + restUrl: string; + nonce: string; + environment: string; + environmentId: string; + dashboardBaseUrl: string; + defaultLimit: number; + pluginEnabled: boolean; +} + +declare global { + interface Window { + workosUsersAdmin?: AdminConfig; + } +} + +interface WorkosUser { + id: string; + email: string; + email_verified: boolean; + first_name: string; + last_name: string; + last_sign_in_at: string; + created_at: string; + updated_at: string; + dashboard_url: string; +} + +interface ListMetadata { + before: string | null; + after: string | null; +} + +interface ListResponse { + data: WorkosUser[]; + list_metadata: ListMetadata; + error?: string; +} + +interface ApiResult< T > { + ok: boolean; + status: number; + data: T; +} + +const PAGE_SIZE_OPTIONS = [ 10, 25, 50, 100 ] as const; + +function getConfig(): AdminConfig { + return ( + window.workosUsersAdmin || { + restUrl: '/wp-json/workos/v1/admin/users', + nonce: '', + environment: '', + environmentId: '', + dashboardBaseUrl: 'https://dashboard.workos.com', + defaultLimit: 25, + pluginEnabled: false, + } + ); +} + +async function apiCall< T >( query: Record< string, string | number > ): Promise< ApiResult< T > > { + const cfg = getConfig(); + const search = new URLSearchParams(); + for ( const [ key, value ] of Object.entries( query ) ) { + if ( value === '' || value === null || value === undefined ) { + continue; + } + search.append( key, String( value ) ); + } + const url = `${ cfg.restUrl }${ search.toString() ? `?${ search.toString() }` : '' }`; + const res = await fetch( url, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': cfg.nonce, + }, + } ); + const data = await res.json().catch( () => ( {} ) ); + return { ok: res.ok, status: res.status, data: data as T }; +} + +function formatRelative( iso: string ): string { + if ( ! iso ) { + return '—'; + } + const date = new Date( iso ); + if ( Number.isNaN( date.getTime() ) ) { + return '—'; + } + const diffMs = Date.now() - date.getTime(); + const absSec = Math.abs( diffMs ) / 1000; + const future = diffMs < 0; + const units: Array< { limit: number; div: number; one: string; many: string } > = [ + { limit: 60, div: 1, one: __( '%d second', 'integration-workos' ), many: __( '%d seconds', 'integration-workos' ) }, + { limit: 3600, div: 60, one: __( '%d minute', 'integration-workos' ), many: __( '%d minutes', 'integration-workos' ) }, + { limit: 86400, div: 3600, one: __( '%d hour', 'integration-workos' ), many: __( '%d hours', 'integration-workos' ) }, + { limit: 2592000, div: 86400, one: __( '%d day', 'integration-workos' ), many: __( '%d days', 'integration-workos' ) }, + { limit: 31536000, div: 2592000, one: __( '%d month', 'integration-workos' ), many: __( '%d months', 'integration-workos' ) }, + ]; + for ( const u of units ) { + if ( absSec < u.limit ) { + const n = Math.max( 1, Math.floor( absSec / u.div ) ); + const tpl = n === 1 ? u.one : u.many; + return future + ? sprintf( __( 'in %s', 'integration-workos' ), sprintf( tpl, n ) ) + : sprintf( __( '%s ago', 'integration-workos' ), sprintf( tpl, n ) ); + } + } + const years = Math.max( 1, Math.floor( absSec / 31536000 ) ); + const tpl = years === 1 + ? __( '%d year', 'integration-workos' ) + : __( '%d years', 'integration-workos' ); + return future + ? sprintf( __( 'in %s', 'integration-workos' ), sprintf( tpl, years ) ) + : sprintf( __( '%s ago', 'integration-workos' ), sprintf( tpl, years ) ); +} + +function fullName( user: WorkosUser ): string { + const parts = [ user.first_name, user.last_name ].filter( Boolean ); + return parts.length ? parts.join( ' ' ) : '—'; +} + +function App(): JSX.Element { + const cfg = getConfig(); + const [ users, setUsers ] = useState< WorkosUser[] >( [] ); + const [ metadata, setMetadata ] = useState< ListMetadata >( { before: null, after: null } ); + const [ loading, setLoading ] = useState< boolean >( false ); + const [ error, setError ] = useState< string >( '' ); + const [ searchInput, setSearchInput ] = useState< string >( '' ); + const [ search, setSearch ] = useState< string >( '' ); + const [ limit, setLimit ] = useState< number >( cfg.defaultLimit || 25 ); + const [ cursor, setCursor ] = useState< { after?: string; before?: string } >( {} ); + // Stack of `after` cursors we've passed through, so Prev can rewind without + // the upstream `before` cursor (WorkOS pagination is one-way per direction). + const cursorStack = useRef< string[] >( [] ); + const fetchSeq = useRef< number >( 0 ); + + const load = useCallback( async () => { + setLoading( true ); + setError( '' ); + const seq = ++fetchSeq.current; + const query: Record< string, string | number > = { limit }; + if ( search ) { + query.email = search; + } + if ( cursor.after ) { + query.after = cursor.after; + } else if ( cursor.before ) { + query.before = cursor.before; + } + const res = await apiCall< ListResponse >( query ); + // Drop stale responses if a newer fetch has started. + if ( seq !== fetchSeq.current ) { + return; + } + if ( ! res.ok ) { + setLoading( false ); + setError( + res.data?.error || + sprintf( + __( 'Failed to load users (status %d).', 'integration-workos' ), + res.status + ) + ); + setUsers( [] ); + setMetadata( { before: null, after: null } ); + return; + } + if ( res.data.error ) { + setError( res.data.error ); + } + setUsers( Array.isArray( res.data.data ) ? res.data.data : [] ); + setMetadata( res.data.list_metadata || { before: null, after: null } ); + setLoading( false ); + }, [ limit, search, cursor ] ); + + useEffect( () => { + void load(); + }, [ load ] ); + + // Debounce the search input → committed search term (300ms). + useEffect( () => { + const handle = window.setTimeout( () => { + setSearch( ( current ) => { + if ( current === searchInput ) { + return current; + } + cursorStack.current = []; + setCursor( {} ); + return searchInput; + } ); + }, 300 ); + return () => window.clearTimeout( handle ); + }, [ searchInput ] ); + + const handleNext = (): void => { + if ( ! metadata.after ) { + return; + } + cursorStack.current.push( metadata.after ); + setCursor( { after: metadata.after } ); + }; + + const handlePrev = (): void => { + // Drop the cursor that took us to the current page, then use the + // previous one (or reset if we're at the start). + cursorStack.current.pop(); + const prev = cursorStack.current[ cursorStack.current.length - 1 ]; + setCursor( prev ? { after: prev } : {} ); + }; + + const handleLimitChange = ( value: number ): void => { + cursorStack.current = []; + setCursor( {} ); + setLimit( value ); + }; + + const hasPrev = cursorStack.current.length > 0; + const hasNext = Boolean( metadata.after ); + + const columns = useMemo( + () => [ + __( 'Email', 'integration-workos' ), + __( 'Name', 'integration-workos' ), + __( 'Last sign-in', 'integration-workos' ), + __( 'Created', 'integration-workos' ), + __( 'Actions', 'integration-workos' ), + ], + [] + ); + + if ( ! cfg.pluginEnabled ) { + return ( +
+

+ { __( + 'WorkOS is not configured. Save your API credentials on the Settings page to enable the user listing.', + 'integration-workos' + ) } +

+
+ ); + } + + return ( +
+
+ setSearchInput( event.target.value ) } + aria-label={ __( 'Search WorkOS users by email', 'integration-workos' ) } + /> + +
+ { sprintf( + __( 'Environment: %s', 'integration-workos' ), + cfg.environment || __( 'unknown', 'integration-workos' ) + ) } +
+
+ + { error && ( +
+

{ error }

+
+ ) } + +
+ + + + { columns.map( ( label ) => ( + + ) ) } + + + + { loading && users.length === 0 && ( + <> + { Array.from( { length: 5 } ).map( ( _, i ) => ( + + { columns.map( ( _label, j ) => ( + + ) ) } + + ) ) } + + ) } + + { ! loading && users.length === 0 && ! error && ( + + + + ) } + + { users.map( ( user ) => ( + + + + + + + + ) ) } + +
+ { label } +
+ +
+ { search + ? sprintf( + __( 'No users match "%s".', 'integration-workos' ), + search + ) + : __( + 'No WorkOS users found in this environment.', + 'integration-workos' + ) } +
+ { user.email || '—' } + { user.email_verified && ( + + { ' ' }✓ + + ) } +
+ { user.id } +
+
{ fullName( user ) } + { formatRelative( user.last_sign_in_at ) } + + { formatRelative( user.created_at ) } + + { user.dashboard_url ? ( + + { __( 'Open in WorkOS', 'integration-workos' ) } + + ) : ( + + { __( '—', 'integration-workos' ) } + + ) } +
+
+ +
+ + + { loading && ( + + { __( 'Loading…', 'integration-workos' ) } + + ) } +
+
+ ); +} + +const mount = document.getElementById( 'workos-users-admin-root' ); +if ( mount ) { + createRoot( mount ).render( ); +} diff --git a/src/js/admin-users/styles.css b/src/js/admin-users/styles.css new file mode 100644 index 0000000..a9544b4 --- /dev/null +++ b/src/js/admin-users/styles.css @@ -0,0 +1,116 @@ +/* Scoped to the admin-users React mount root. */ + +#workos-users-admin-root .workos-users-app { + margin-top: 1em; +} + +#workos-users-admin-root .workos-users-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +#workos-users-admin-root .workos-users-search { + flex: 1 1 320px; + max-width: 480px; + min-width: 220px; + padding: 6px 10px; + font-size: 14px; +} + +#workos-users-admin-root .workos-users-pagesize { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +#workos-users-admin-root .workos-users-env { + margin-left: auto; + color: #50575e; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +#workos-users-admin-root .workos-users-notice { + margin: 8px 0 12px; +} + +#workos-users-admin-root .workos-users-table-wrap { + position: relative; + min-height: 200px; +} + +#workos-users-admin-root .workos-users-table th, +#workos-users-admin-root .workos-users-table td { + vertical-align: middle; +} + +#workos-users-admin-root .workos-users-id { + font-size: 11px; + color: #646970; + margin-top: 2px; +} + +#workos-users-admin-root .workos-users-id code { + font-size: 11px; + background: transparent; + padding: 0; +} + +#workos-users-admin-root .workos-users-verified { + color: #00a32a; + font-weight: 600; + margin-left: 4px; +} + +#workos-users-admin-root .workos-users-no-link { + color: #8c8f94; +} + +#workos-users-admin-root .workos-users-empty-row { + text-align: center; + color: #50575e; + padding: 32px 12px; +} + +#workos-users-admin-root .workos-users-empty { + padding: 16px; + background: #f6f7f7; + border-left: 4px solid #72aee6; + margin-top: 12px; +} + +#workos-users-admin-root .workos-users-row-skeleton td { + height: 48px; +} + +#workos-users-admin-root .workos-users-skeleton-bar { + display: block; + height: 12px; + background: linear-gradient( 90deg, #f0f0f1 0%, #dcdcde 50%, #f0f0f1 100% ); + background-size: 200% 100%; + border-radius: 3px; + animation: workos-users-skeleton 1.4s ease-in-out infinite; +} + +@keyframes workos-users-skeleton { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +#workos-users-admin-root .workos-users-pagination { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +#workos-users-admin-root .workos-users-loading { + color: #50575e; + font-size: 12px; + margin-left: 8px; +} diff --git a/tests/wpunit/AdminUsersRestApiTest.php b/tests/wpunit/AdminUsersRestApiTest.php new file mode 100644 index 0000000..339bf8a --- /dev/null +++ b/tests/wpunit/AdminUsersRestApiTest.php @@ -0,0 +1,313 @@ + + */ + private array $captured = []; + + /** + * Queued canned responses, FIFO. Falls back to an empty `{}` 200 when + * empty. Each entry is the array shape WP expects from a + * `pre_http_request` short-circuit. + * + * @var array + */ + private array $responses = []; + + /** + * Set up — credentials, REST routes, HTTP interception. + */ + public function setUp(): void { + parent::setUp(); + + // Configure the active environment so workos()->is_enabled() passes. + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'api_key' => 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $this->captured = []; + $this->responses = []; + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + // Register REST routes against the live REST server. + new RestApi(); + $server = rest_get_server(); + do_action( 'rest_api_init', $server ); + } + + /** + * Tear down — detach interceptor, clear options, reset user. + */ + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + $this->captured = []; + $this->responses = []; + + wp_set_current_user( 0 ); + delete_option( 'workos_production' ); + // Clear the env selector outright rather than rewriting to 'staging' — + // other tests in the suite assume `workos_active_environment` is + // absent and `is_enabled()` defaults to false. + delete_option( 'workos_active_environment' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + /** + * Intercept any outbound HTTP and return queued responses in order. + * + * @param false|array $preempt Response override. + * @param array $args Request args. + * @param string $url Request URL. + */ + public function intercept_http( $preempt, array $args, string $url ): array { + $this->captured[] = [ + 'url' => $url, + 'method' => $args['method'] ?? 'GET', + 'body' => $args['body'] ?? '', + 'headers' => $args['headers'] ?? [], + ]; + + if ( ! empty( $this->responses ) ) { + return count( $this->responses ) > 1 + ? array_shift( $this->responses ) + : $this->responses[0]; + } + + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( + [ + 'data' => [], + 'list_metadata' => [ + 'before' => null, + 'after' => null, + ], + ] + ), + ]; + } + + private function queue_response( int $status, array $body ): void { + $this->responses[] = [ + 'response' => [ 'code' => $status, 'message' => 'OK' ], + 'body' => wp_json_encode( $body ), + ]; + } + + private function become_admin(): int { + $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $user_id ); + // Factory-driven user creation triggers WorkOS user-sync which fires + // outbound HTTP through `pre_http_request`. That noise pollutes + // assertions on what the endpoint forwarded, so reset the capture + // buffer after the fixture is in place. + $this->captured = []; + return $user_id; + } + + /** + * Dispatch a GET against the REST server. Query params must be passed + * via `set_query_params()` — `WP_REST_Request` does not parse `?` from + * the URL the way `wp_remote_get()` does. + * + * @param array $query Optional query parameters. + */ + private function dispatch( array $query = [] ): WP_REST_Response { + $request = new WP_REST_Request( 'GET', self::ROUTE_BASE ); + if ( $query ) { + $request->set_query_params( $query ); + } + return rest_get_server()->dispatch( $request ); + } + + private function last_request(): array { + $this->assertNotEmpty( $this->captured, 'No HTTP request was captured.' ); + return $this->captured[ count( $this->captured ) - 1 ]; + } + + // --------------------------------------------------------------------- + // Authorization. + // --------------------------------------------------------------------- + + public function test_anonymous_caller_is_forbidden(): void { + $response = $this->dispatch(); + + $this->assertSame( 403, $response->get_status() ); + // No upstream call should have been made. + $this->assertSame( [], $this->captured ); + } + + public function test_subscriber_is_forbidden(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + // Drop the user_register HTTP push UserSync makes for the fixture. + $this->captured = []; + + $response = $this->dispatch(); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( [], $this->captured ); + } + + // --------------------------------------------------------------------- + // Happy path: pagination + enrichment. + // --------------------------------------------------------------------- + + public function test_list_returns_shaped_users_with_dashboard_url(): void { + $this->become_admin(); + + $this->queue_response( + 200, + [ + 'data' => [ + [ + 'id' => 'user_01HXXX', + 'email' => 'jane@example.com', + 'email_verified' => true, + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'last_sign_in_at' => '2026-05-10T08:00:00Z', + 'created_at' => '2025-12-01T08:00:00Z', + 'updated_at' => '2026-05-10T08:00:00Z', + 'profile_picture_url' => 'https://example.com/jane.png', + ], + ], + 'list_metadata' => [ + 'before' => null, + 'after' => 'user_01HXXX', + ], + ] + ); + + $response = $this->dispatch(); + $this->assertSame( 200, $response->get_status() ); + + $body = $response->get_data(); + $this->assertCount( 1, $body['data'] ); + + $user = $body['data'][0]; + $this->assertSame( 'user_01HXXX', $user['id'] ); + $this->assertSame( 'jane@example.com', $user['email'] ); + $this->assertTrue( $user['email_verified'] ); + $this->assertSame( 'Jane', $user['first_name'] ); + $this->assertSame( + 'https://dashboard.workos.com/environment_test/users/user_01HXXX/details', + $user['dashboard_url'] + ); + // Profile picture was intentionally dropped from the shape. + $this->assertArrayNotHasKey( 'profile_picture_url', $user ); + + // list_metadata is forwarded as-is. + $this->assertNull( $body['list_metadata']['before'] ); + $this->assertSame( 'user_01HXXX', $body['list_metadata']['after'] ); + + // Upstream got the default limit. + $req = $this->last_request(); + $this->assertStringContainsString( '/user_management/users', $req['url'] ); + $this->assertStringContainsString( 'limit=25', $req['url'] ); + } + + // --------------------------------------------------------------------- + // Parameter forwarding. + // --------------------------------------------------------------------- + + public function test_limit_is_clamped_and_forwarded(): void { + $this->become_admin(); + + $response = $this->dispatch( [ 'limit' => 500 ] ); + $this->assertSame( 200, $response->get_status() ); + + $req = $this->last_request(); + $this->assertStringContainsString( 'limit=100', $req['url'] ); + } + + public function test_email_filter_is_forwarded(): void { + $this->become_admin(); + + $this->dispatch( [ 'email' => 'jane@example.com' ] ); + + $req = $this->last_request(); + $this->assertStringContainsString( 'email=jane', $req['url'] ); + $this->assertStringContainsString( '%40example.com', $req['url'] ); + } + + public function test_after_cursor_is_forwarded(): void { + $this->become_admin(); + + $this->dispatch( [ 'after' => 'user_cursor_xyz' ] ); + + $req = $this->last_request(); + $this->assertStringContainsString( 'after=user_cursor_xyz', $req['url'] ); + $this->assertStringNotContainsString( 'before=', $req['url'] ); + } + + public function test_after_wins_when_both_cursors_supplied(): void { + $this->become_admin(); + + $this->dispatch( [ 'after' => 'A', 'before' => 'B' ] ); + + $req = $this->last_request(); + $this->assertStringContainsString( 'after=A', $req['url'] ); + $this->assertStringNotContainsString( 'before=B', $req['url'] ); + } + + // --------------------------------------------------------------------- + // Error handling. + // --------------------------------------------------------------------- + + public function test_upstream_error_is_surfaced_in_envelope(): void { + $this->become_admin(); + + // 401 from upstream → Api\Client returns WP_Error → endpoint surfaces + // the message but still responds 200 so the React UI can render it + // inline without choking on its own envelope. + $this->queue_response( 401, [ 'message' => 'Unauthorized: bad API key' ] ); + + $response = $this->dispatch(); + $this->assertSame( 200, $response->get_status() ); + + $body = $response->get_data(); + $this->assertSame( [], $body['data'] ); + $this->assertArrayHasKey( 'error', $body ); + $this->assertNotEmpty( $body['error'] ); + } +} diff --git a/webpack.config.js b/webpack.config.js index f818657..6159d72 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,5 +14,6 @@ module.exports = { 'onboarding': path.resolve( __dirname, 'src/js/onboarding.js' ), 'authkit': path.resolve( __dirname, 'src/js/authkit/index.tsx' ), 'admin-profiles': path.resolve( __dirname, 'src/js/admin-profiles/index.tsx' ), + 'admin-users': path.resolve( __dirname, 'src/js/admin-users/index.tsx' ), }, };