Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
16ba05e
fix: authenticate cross-site user deletion requests
Kallyan01 Sep 22, 2026
a4da9cd
fix: defer brand admin hooks until pluggable.php is loaded — Profile_…
Kallyan01 Sep 22, 2026
109d974
fix: preserve passwords and single-word names when creating users
Kallyan01 Sep 22, 2026
955992d
fix: toast notification postion
Kallyan01 Sep 22, 2026
e7eb9a2
chore: minor refactoring of comments
Kallyan01 Sep 22, 2026
8d63dbe
fix: removed unused parameter
Kallyan01 Sep 22, 2026
8af9a3f
chore: formatting fix
Kallyan01 Sep 22, 2026
dd9b9db
Potential fix for pull request finding 'Preserve existing filter resu…
Kallyan01 Sep 22, 2026
1a55d3b
fix: code quality
Kallyan01 Oct 1, 2026
e373179
Merge branch 'main' into fix/user-deletion
Kallyan01 Oct 7, 2026
24d995e
fix: enhance API token validation and add site URL header for requests
Kallyan01 Oct 8, 2026
3145b01
fix: improve error handling for user creation and password validation
Kallyan01 Oct 8, 2026
6e60d43
fix: update inc/Modules/Rest/Governing_Site_Controller.php
Kallyan01 Oct 8, 2026
73969bd
fix: add site URL header to user deletion request
Kallyan01 Oct 8, 2026
a31ab49
fix: sanitize password input when creating a user
Kallyan01 Oct 8, 2026
e9fa5be
fix: refactor name splitting logic in user creation and update methods
Kallyan01 Oct 8, 2026
2563ccb
fix: enhance user deletion error handling and improve site name retri…
Kallyan01 Oct 8, 2026
016fd69
fix: linting
Kallyan01 Oct 8, 2026
26996c4
fix: refactor Snackbar handling to improve notice display logic
Kallyan01 Oct 8, 2026
f063eda
fix: streamline user retrieval and enhance error logging during delet…
Kallyan01 Oct 8, 2026
05fed9d
fix: improve site URL handling and enhance error logging for unknown …
Kallyan01 Oct 8, 2026
0b604b7
fix: remove unnecessary allow_oneaccess_host filter and related code …
Kallyan01 Oct 8, 2026
5ad2275
fix: update site URL normalization and improve shared site retrieval …
Kallyan01 Oct 8, 2026
6c87017
fix: add site URL handling and improve CORS headers for OneAccess req…
Kallyan01 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 36 additions & 18 deletions assets/src/components/CreateUser.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
/**
* WordPress dependencies
*/
import { useState, useEffect, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the import reordering here is unrelated to the fix. If it isn't enforced by the linter, consider dropping it to keep the diff focused.

Button,
Card,

Check warning on line 6 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `Card.Root` from `@wordpress/ui` instead
CardHeader,
CardBody,

Check warning on line 7 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `Card.Content` from `@wordpress/ui` instead
TextControl,
SelectControl,
Button,
Modal,
CardHeader,

Check warning on line 8 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `Card.Header` (and optionally `Card.Title`) from `@wordpress/ui` instead
CheckboxControl,
Notice,
Dashicon,
__experimentalGrid as Grid,

Check warning on line 11 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

__experimentalGrid is planned for deprecation. Write your own CSS instead
__experimentalHStack as HStack,

Check warning on line 12 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `Stack` from `@wordpress/ui` instead
__experimentalVStack as VStack,
Dashicon,
Icon,
Modal,
Notice,
SelectControl,
Snackbar,
SnackbarList,
Icon,
TextControl,

Check warning on line 19 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `InputControl` from `@wordpress/ui` instead. See migration guide in the lint rule documentation
__experimentalVStack as VStack,

Check warning on line 20 in assets/src/components/CreateUser.tsx

View workflow job for this annotation

GitHub Actions / CSS/JS Lint / JS Lint & TypeScript

Use `Stack` from `@wordpress/ui` instead
} from '@wordpress/components';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import {
isValidEmail,
checkPasswordStrength,
strengthWidths,
getStrengthColor,
isValidEmail,
strengthWidths,
type StrengthLevel,
} from '../js/utils';

Expand Down Expand Up @@ -186,12 +186,19 @@
);

if ( ! response.ok ) {
const errorData = ( await response
.json()
.catch( () => null ) ) as {
message?: string;
} | null;
setNotice( {
type: 'error',
message: __(
'Failed to create user. Please try again later.',
'oneaccess'
),
message:
errorData?.message ||
__(
'Failed to create user. Please try again later.',
'oneaccess'
),
} );
throw new Error( 'Failed to create user' );
Comment thread
Kallyan01 marked this conversation as resolved.
Outdated
}
Expand All @@ -201,6 +208,7 @@
message?: string;
data?: {
response_data?: CreateUserResult[];
error_log?: { site_name?: string; message?: string }[];
};
};
if ( ! data.success ) {
Expand All @@ -216,7 +224,17 @@
return;
}

const results = data?.data?.response_data || [];
const results: CreateUserResult[] = [
...( data?.data?.response_data || [] ),
...( data?.data?.error_log || [] ).map( ( failure ) => ( {
status: 'error' as const,
site: failure.site_name ?? '',
message:
failure.message ??
__( 'Failed to create user.', 'oneaccess' ),
} ) ),
];
Comment on lines +224 to +233

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: per-site failures are visible now. Two follow-ups:

  • Lost server message: L188, the ! response.ok branch, never reads the body. Any 4xx/5xx shows "Failed to create user. Please try again later.", including the new 400 password message. Parse the JSON and prefer data.message.
  • Empty site name: when the site isn't found, site_name is '', so the notice reads "API key not found for site .". The server could fall back to the URL.

The same per-site treatment is still missing in the delete flow (SharedUsers.tsx), which is this PR's main fix. See the review summary.


const newNotices = results.map(
( result: CreateUserResult, index: number ) => ( {
id: `notice-${ Date.now() }-${ index }`,
Expand Down
37 changes: 16 additions & 21 deletions assets/src/css/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,26 @@
#oneaccess-settings-page,
#oneaccess-manage-user {

&:has(.components-snackbar-list) {

.components-snackbar-list {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000000;
align-items: flex-end;
justify-content: flex-end;
display: flex;
flex-direction: column;
}
.components-snackbar-list,
.components-snackbar {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000000;
width: auto;
}
Comment on lines +6 to 13

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping :has() simplifies this. But every standalone .components-snackbar is now position: fixed in the same corner as the list, so they overlap rather than stack when more than one is on screen:

  • CreateUser renders a standalone notice and a SnackbarList.
  • SharedUsers and ProfileRequests render their own snackbars.

The "several at once — toasts stack" test step only holds within a single SnackbarList.

A more robust approach:

  1. Render one SnackbarList at the app root, fed by the @wordpress/notices store (createSuccessNotice( message, { type: 'snackbar' } )).
  2. Remove these overrides.

Two smaller things:

  • Root cause in the description: the plugin's snackbar CSS hasn't changed since chore: refactor code base according to psr4 plus OneDesign #12, so I suspect a core wp-components change, but I couldn't confirm without running it.
  • Outside the diff: CreateUser.tsx L527's onRemove clears all notices 3s after the first one is dismissed. It should remove by id.


&:not(:has(.components-snackbar-list)) {
.components-snackbar-list {
align-items: flex-end;
justify-content: flex-end;
display: flex;
flex-direction: column;

/* Snackbars in a list are laid out by the list itself. */
.components-snackbar {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000000;
width: auto;
position: static;
bottom: auto;
right: auto;
}
}

Expand All @@ -41,8 +39,6 @@
background-color: #e11d1d;
color: #fff;
}


}

.toplevel_page_oneaccess {
Expand All @@ -52,7 +48,6 @@
}
}


body {

&.oneaccess-missing-brand-sites,
Expand Down
24 changes: 24 additions & 0 deletions inc/Modules/Core/Hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public function __construct() {
* {@inheritDoc}
*/
public function register_hooks(): void {
// Needed on both site types, so it must come before the consumer check below.
add_filter( 'http_request_host_is_external', [ $this, 'allow_oneaccess_host' ], 10, 2 );

// Early return if this is not a consumer site.
if ( ! Settings::is_consumer_site() ) {
return;
Expand All @@ -63,4 +66,25 @@ public function register_hooks(): void {
public function user_deduplication(): void {
$this->actions_controller->send_users_for_deduplication();
}

/**
* Allow outbound requests to the configured OneAccess sites.
*
* @internal Hook callback
*
* @param bool $is_external Whether the host is considered external.
* @param string $host Host name of the request.
*/
public function allow_oneaccess_host( $is_external, $host ): bool {
$urls = array_column( Settings::get_shared_sites(), 'url' );
$urls[] = (string) Settings::get_parent_site_url();

foreach ( $urls as $url ) {
if ( ! empty( $url ) && wp_parse_url( $url, PHP_URL_HOST ) === $host ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a global, production-wide change to work around a local-environment problem.

Context

  • Brand sites on public hosts already pass wp_safe_remote_*(). The filter only matters when a configured host resolves to a private or loopback IP, e.g. Local's *.local.
  • The repo already ships tests/_data/plugins/localhost-helper.php for the wp-env case.
  • As a global filter, it also relaxes the check for any other plugin's (or core's) safe requests to these hosts.

If private-network deployments need to be supported, I'd suggest one of these:

  • make it opt-in (a filter or constant), or
  • scope it to OneAccess's own requests: verify the URL belongs to a configured site, then pass reject_unsafe_urls => false from one shared request helper.

Smaller points:

  • Ports: it doesn't cover non-standard ports, which http_allowed_safe_ports rejects separately. So it won't help wp-env-style localhost:8889 setups.
  • Placement: the class docblock describes consumer-site hooks, so this would fit better next to the HTTP code.
  • Case: host comparison should be case-insensitive:
Suggested change
if ( ! empty( $url ) && wp_parse_url( $url, PHP_URL_HOST ) === $host ) {
if ( ! empty( $url ) && strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) === strtolower( $host ) ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(FWIW I don't get the purpose of this entire class. Assuming the existing coupling is a workaround for send_users_for_deduplication() being built-in to that class instead of the endpoint. But either way yah, seems like this specific diff is solved by localhost-helper.php and doesn't need to exist at all. )

return true;
}
}

return (bool) $is_external;
}
}
155 changes: 121 additions & 34 deletions inc/Modules/Rest/Abstract_REST_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,55 +65,142 @@ public static function permission_callback(): bool {
* @todo this should be on a hook.
*
* @param \WP_REST_Request<array{}> $request Request.
* @return bool
*/
public function check_api_permissions( $request ) {
// check if the request is from same site.
if ( Settings::is_governing_site() ) {
return current_user_can( 'manage_options' );
}
public function check_api_permissions( $request ): bool {
$origin = $this->parse_origin( $request->get_header( 'origin' ) );
$request_origin = $origin['origin'];
$parsed_origin = $origin['parsed'];
$request_url = $origin['url'];
$origin_port = $origin['port'];

// See if the `X_ONEACCESS_TOKEN` header is present.
$token = $request->get_header( 'X_ONEACCESS_TOKEN' );
/**
* Token-based auth takes priority over the Origin same-host check: cross-site
* requests from sub-directory multisite installs lose the path in Origin, so
* same-host detection can misfire on sibling sub-sites. Validating by key
* instead avoids that false match.
*/
$token = $request->get_header( 'X-OneAccess-Token' );
$token = ! empty( $token ) ? sanitize_text_field( wp_unslash( $token ) ) : '';

// Bail if the token is missing or invalid.
if ( ! hash_equals( Settings::get_api_key(), $token ) ) {
return false;
if ( ! empty( $token ) ) {
/**
* Origin is absent for server-side requests, so fall back to the
* explicitly-sent site URL header so token auth can still proceed.
*/
if ( empty( $request_url ) ) {
$site_url_header = $request->get_header( 'X-OneAccess-Site-URL' );
if ( ! empty( $site_url_header ) ) {
$origin = $this->parse_origin( $site_url_header );
$request_origin = $origin['origin'];
$parsed_origin = $origin['parsed'];
$request_url = $origin['url'];
$origin_port = $origin['port'];
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
}

if ( empty( $request_url ) ) {
return false;
Comment on lines +95 to +96
}

$stored_key = $this->get_stored_api_key( $request_url );
if ( empty( $stored_key ) || ! hash_equals( $stored_key, $token ) ) {
return false;
}

// Governing sites were checked by ::get_stored_api_key already.
if ( Settings::is_governing_site() ) {
return true;
}

// Non-healthcheck requests must match the site already recorded as governing.
$governing_site_url = Settings::get_parent_site_url();
if ( '/' . $this->namespace . '/health-check' !== $request->get_route() ) {
return ! empty( $governing_site_url ) ? $this->is_url_from_host( $governing_site_url, $parsed_origin['host'], $origin_port ) : false;
}

// Health-checks bootstrap the governing-site relationship since none is recorded yet.
Settings::set_parent_site_url( $request_origin );
return true;
Comment thread
Kallyan01 marked this conversation as resolved.
Outdated
}

$request_origin = $request->get_header( 'origin' );
$request_origin = ! empty( $request_origin ) ? esc_url_raw( wp_unslash( $request_origin ) ) : '';
$user_agent = $request->get_header( 'user-agent' );
$user_agent = ! empty( $user_agent ) ? sanitize_text_field( wp_unslash( $user_agent ) ) : '';
// No token: fall back to same-domain logged-in user check.
if ( empty( $request_url ) || $this->is_url_from_host( get_site_url(), $parsed_origin['host'], $origin_port ) ) {
return current_user_can( 'manage_options' );
}

/**
* If both origin and user-agent are missing, deny access.
*
* Here checking both because server side requests will not have origin header.
*/
if ( empty( $request_origin ) && empty( $user_agent ) ) {
return false;
}

/**
* Parses a raw Origin or X-OneAccess-Site-URL header value into its components.
*
* @param ?string $raw Raw header value.
*
* @return array{origin: string, parsed: array<string, mixed>, url: string, port: int|null}
*/
private function parse_origin( ?string $raw ): array {
$origin = ! empty( $raw ) ? esc_url_raw( wp_unslash( $raw ) ) : '';
$parsed = wp_parse_url( $origin );
$parsed = is_array( $parsed ) ? $parsed : [];
$url = ! empty( $parsed['scheme'] ) && ! empty( $parsed['host'] )
? untrailingslashit( trim( $origin ) )
: '';
$port = isset( $parsed['port'] ) ? (int) $parsed['port'] : null;

return [
'origin' => $origin,
'parsed' => $parsed,
'url' => $url,
'port' => $port,
];
}

/**
* Check if two URLs belong to the same host.
*
* @param string $url The URL to check.
* @param string $host The host to compare against.
* @param int|null $port Optional. The port to compare against.
*
* @return bool True if both URLs belong to the same host (and port if specified), false otherwise.
*/
protected function is_url_from_host( string $url, string $host, ?int $port = null ): bool {
$parsed_url = wp_parse_url( $url );

// Compare both host and port to properly handle localhost with different ports.
if ( ! isset( $parsed_url['host'] ) || $parsed_url['host'] !== $host ) {
return false;
}

// If it's the same domain, we're good.
if ( self::is_same_domain( get_site_url(), $request_origin ) ) {
return true;
// If a port was provided, also compare ports.
if ( null !== $port ) {
$url_port = $parsed_url['port'] ?? 80;
return $url_port === $port;
}

$governing_site_url = Settings::get_parent_site_url();
return true;
}

// If it's a healthcheck with no governing site, allow it and set the governing site.
if ( empty( $governing_site_url ) ) {
if ( '/' . $this->namespace . '/health-check' === $request->get_route() ) {
Settings::set_parent_site_url( $request_origin );
return true;
}
return false;
/**
* Gets the locally-stored API key for comparison.
*
* @param ?string $site_url Site URL. Only used for brand->governing site requests.
*
* @return string The stored API key. Empty string if not found.
*/
private function get_stored_api_key( ?string $site_url = null ): string {
if ( Settings::is_consumer_site() ) {
return Settings::get_api_key();
}

// if token is valid and request is from different domain then check if it matches governing site url.
return self::is_same_domain( $governing_site_url, $request_origin ) || false !== strpos( $user_agent, $governing_site_url );
// If there's no brand site URL we cannot match the API key.
if ( ! isset( $site_url ) ) {
return '';
}

$shared_sites = Settings::get_shared_sites();

return ! empty( $shared_sites[ $site_url ]['api_key'] ) ? $shared_sites[ $site_url ]['api_key'] : '';
}

/**
Expand Down
8 changes: 5 additions & 3 deletions inc/Modules/Rest/Actions_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,9 @@ private function make_brand_site_request( string $site_url, string $api_key, arr

$args = [
'headers' => [
'X-OneAccess-Token' => $api_key,
'Cache-Control' => 'no-cache',
'X-OneAccess-Token' => $api_key,
'X-OneAccess-Site-URL' => get_site_url(),
'Cache-Control' => 'no-cache',
],
'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- profile requests fetching take time.
];
Expand Down Expand Up @@ -1000,7 +1001,8 @@ public function rebuild_deduplicated_users_index(): WP_REST_Response {
trailingslashit( esc_url_raw( $site_url ) ) . 'wp-json/' . self::NAMESPACE . '/rebuild-brand-sites-index',
[
'headers' => [
'X-OneAccess-Token' => $api_key,
'X-OneAccess-Token' => $api_key,
'X-OneAccess-Site-URL' => get_site_url(),
],
'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- rebuilding index take time.
]
Expand Down
Loading
Loading