From e761261f5091d6a62ad780b80311c79efb44e832 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 12 May 2026 16:49:57 +0700 Subject: [PATCH 1/2] feat(branding): add border radius, secondary button, and heading color controls --- src/WorkOS/Auth/AuthKit/Profile.php | 80 ++++++++++--- src/WorkOS/Auth/AuthKit/Renderer.php | 123 ++++++++++++++++---- src/js/admin-profiles/index.tsx | 166 +++++++++++++++++++++++++-- src/js/admin-profiles/styles.css | 96 ++++++++++++++++ src/js/authkit/index.tsx | 7 +- src/js/authkit/styles.css | 44 ++++--- src/js/authkit/types.ts | 14 ++- tests/wpunit/AuthKitProfileTest.php | 22 +++- tests/wpunit/AuthKitRendererTest.php | 14 ++- 9 files changed, 488 insertions(+), 78 deletions(-) diff --git a/src/WorkOS/Auth/AuthKit/Profile.php b/src/WorkOS/Auth/AuthKit/Profile.php index d7a880a..7f88433 100644 --- a/src/WorkOS/Auth/AuthKit/Profile.php +++ b/src/WorkOS/Auth/AuthKit/Profile.php @@ -149,7 +149,7 @@ class Profile { /** * Branding config. * - * @var array{logo_mode: string, logo_attachment_id: int, primary_color: string, heading: string, subheading: string} + * @var array{logo_mode: string, logo_attachment_id: int, card_border_radius: string, button_border_radius: string, page_background: string, card_background: string, card_border: string, heading_color: string, subheading_color: string, button_background: string, button_text: string, secondary_button_background: string, secondary_button_text: string, secondary_button_border: string, links_color: string, heading: string, subheading: string} */ private array $branding; @@ -201,11 +201,23 @@ public function __construct( array $data ) { 'factors' => array_values( array_filter( (array) ( $data['mfa']['factors'] ?? [] ), 'is_string' ) ), ]; $this->branding = [ - 'logo_mode' => (string) ( $data['branding']['logo_mode'] ?? self::LOGO_MODE_DEFAULT ), - 'logo_attachment_id' => (int) ( $data['branding']['logo_attachment_id'] ?? 0 ), - 'primary_color' => (string) ( $data['branding']['primary_color'] ?? '' ), - 'heading' => (string) ( $data['branding']['heading'] ?? '' ), - 'subheading' => (string) ( $data['branding']['subheading'] ?? '' ), + 'logo_mode' => (string) ( $data['branding']['logo_mode'] ?? self::LOGO_MODE_DEFAULT ), + 'logo_attachment_id' => (int) ( $data['branding']['logo_attachment_id'] ?? 0 ), + 'card_border_radius' => (string) ( $data['branding']['card_border_radius'] ?? '' ), + 'button_border_radius' => (string) ( $data['branding']['button_border_radius'] ?? '' ), + 'page_background' => (string) ( $data['branding']['page_background'] ?? '' ), + 'card_background' => (string) ( $data['branding']['card_background'] ?? '' ), + 'card_border' => (string) ( $data['branding']['card_border'] ?? '' ), + 'heading_color' => (string) ( $data['branding']['heading_color'] ?? '' ), + 'subheading_color' => (string) ( $data['branding']['subheading_color'] ?? '' ), + 'button_background' => (string) ( $data['branding']['button_background'] ?? '' ), + 'button_text' => (string) ( $data['branding']['button_text'] ?? '' ), + 'secondary_button_background' => (string) ( $data['branding']['secondary_button_background'] ?? '' ), + 'secondary_button_text' => (string) ( $data['branding']['secondary_button_text'] ?? '' ), + 'secondary_button_border' => (string) ( $data['branding']['secondary_button_border'] ?? '' ), + 'links_color' => (string) ( $data['branding']['links_color'] ?? '' ), + 'heading' => (string) ( $data['branding']['heading'] ?? '' ), + 'subheading' => (string) ( $data['branding']['subheading'] ?? '' ), ]; $this->post_login_redirect = (string) ( $data['post_login_redirect'] ?? '' ); $this->forward_query_args = (bool) ( $data['forward_query_args'] ?? false ); @@ -297,10 +309,27 @@ public static function from_array( array $data ): self { $organization_id = ''; } - $primary_color = (string) ( $data['branding']['primary_color'] ?? '' ); - if ( '' !== $primary_color && ! preg_match( '/^#[0-9a-fA-F]{3,8}$/', $primary_color ) ) { - $primary_color = ''; - } + $sanitize_color = static function ( string $value ): string { + return ( '' !== $value && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $value ) ) ? $value : ''; + }; + + $sanitize_radius = static function ( string $v ): string { + $v = trim( $v ); + return ( '' !== $v && ctype_digit( $v ) ) ? $v : ''; + }; + $card_border_radius = $sanitize_radius( (string) ( $data['branding']['card_border_radius'] ?? '' ) ); + $button_border_radius = $sanitize_radius( (string) ( $data['branding']['button_border_radius'] ?? '' ) ); + $page_background = $sanitize_color( (string) ( $data['branding']['page_background'] ?? '' ) ); + $card_background = $sanitize_color( (string) ( $data['branding']['card_background'] ?? '' ) ); + $card_border = $sanitize_color( (string) ( $data['branding']['card_border'] ?? '' ) ); + $heading_color = $sanitize_color( (string) ( $data['branding']['heading_color'] ?? '' ) ); + $subheading_color = $sanitize_color( (string) ( $data['branding']['subheading_color'] ?? '' ) ); + $button_background = $sanitize_color( (string) ( $data['branding']['button_background'] ?? '' ) ); + $button_text = $sanitize_color( (string) ( $data['branding']['button_text'] ?? '' ) ); + $secondary_button_background = $sanitize_color( (string) ( $data['branding']['secondary_button_background'] ?? '' ) ); + $secondary_button_text = $sanitize_color( (string) ( $data['branding']['secondary_button_text'] ?? '' ) ); + $secondary_button_border = $sanitize_color( (string) ( $data['branding']['secondary_button_border'] ?? '' ) ); + $links_color = $sanitize_color( (string) ( $data['branding']['links_color'] ?? '' ) ); $logo_attachment_id = (int) ( $data['branding']['logo_attachment_id'] ?? 0 ); @@ -341,11 +370,23 @@ public static function from_array( array $data ): self { 'factors' => $mfa_factors, ], 'branding' => [ - 'logo_mode' => $logo_mode, - 'logo_attachment_id' => $logo_attachment_id, - 'primary_color' => $primary_color, - 'heading' => sanitize_text_field( (string) ( $data['branding']['heading'] ?? '' ) ), - 'subheading' => sanitize_text_field( (string) ( $data['branding']['subheading'] ?? '' ) ), + 'logo_mode' => $logo_mode, + 'logo_attachment_id' => $logo_attachment_id, + 'card_border_radius' => $card_border_radius, + 'button_border_radius' => $button_border_radius, + 'page_background' => $page_background, + 'card_background' => $card_background, + 'card_border' => $card_border, + 'heading_color' => $heading_color, + 'subheading_color' => $subheading_color, + 'button_background' => $button_background, + 'button_text' => $button_text, + 'secondary_button_background' => $secondary_button_background, + 'secondary_button_text' => $secondary_button_text, + 'secondary_button_border' => $secondary_button_border, + 'links_color' => $links_color, + 'heading' => sanitize_text_field( (string) ( $data['branding']['heading'] ?? '' ) ), + 'subheading' => sanitize_text_field( (string) ( $data['branding']['subheading'] ?? '' ) ), ], 'post_login_redirect' => sanitize_text_field( (string) ( $data['post_login_redirect'] ?? '' ) ), 'forward_query_args' => (bool) ( $data['forward_query_args'] ?? false ), @@ -384,7 +425,12 @@ public static function defaults(): self { 'branding' => [ 'logo_mode' => self::LOGO_MODE_DEFAULT, 'logo_attachment_id' => 0, - 'primary_color' => '', + 'page_background' => '', + 'card_background' => '', + 'card_border' => '', + 'button_background' => '', + 'button_text' => '', + 'links_color' => '', 'heading' => __( 'Sign in', 'integration-workos' ), 'subheading' => '', ], @@ -620,7 +666,7 @@ public function get_mfa(): array { /** * Branding configuration. * - * @return array{logo_attachment_id: int, primary_color: string, heading: string, subheading: string} + * @return array{logo_attachment_id: int, page_background: string, card_background: string, card_border: string, button_background: string, button_text: string, links_color: string, heading: string, subheading: string} */ public function get_branding(): array { return $this->branding; diff --git a/src/WorkOS/Auth/AuthKit/Renderer.php b/src/WorkOS/Auth/AuthKit/Renderer.php index 3c3c4f4..592c733 100644 --- a/src/WorkOS/Auth/AuthKit/Renderer.php +++ b/src/WorkOS/Auth/AuthKit/Renderer.php @@ -291,10 +291,22 @@ private function resolve_branding( Profile $profile ): array { } $resolved = [ - 'logo_url' => $logo_url, - 'primary_color' => (string) ( $branding['primary_color'] ?? '' ), - 'heading' => (string) ( $branding['heading'] ?? '' ), - 'subheading' => (string) ( $branding['subheading'] ?? '' ), + 'logo_url' => $logo_url, + 'card_border_radius' => (string) ( $branding['card_border_radius'] ?? '' ), + 'button_border_radius' => (string) ( $branding['button_border_radius'] ?? '' ), + 'page_background' => (string) ( $branding['page_background'] ?? '' ), + 'card_background' => (string) ( $branding['card_background'] ?? '' ), + 'card_border' => (string) ( $branding['card_border'] ?? '' ), + 'heading_color' => (string) ( $branding['heading_color'] ?? '' ), + 'subheading_color' => (string) ( $branding['subheading_color'] ?? '' ), + 'button_background' => (string) ( $branding['button_background'] ?? '' ), + 'button_text' => (string) ( $branding['button_text'] ?? '' ), + 'secondary_button_background' => (string) ( $branding['secondary_button_background'] ?? '' ), + 'secondary_button_text' => (string) ( $branding['secondary_button_text'] ?? '' ), + 'secondary_button_border' => (string) ( $branding['secondary_button_border'] ?? '' ), + 'links_color' => (string) ( $branding['links_color'] ?? '' ), + 'heading' => (string) ( $branding['heading'] ?? '' ), + 'subheading' => (string) ( $branding['subheading'] ?? '' ), ]; /** @@ -323,28 +335,95 @@ private function resolve_branding( Profile $profile ): array { private function branding_style_tag( array $branding ): string { $rules = []; - // Re-validate the primary color as defense-in-depth. Profile::from_array - // already regex-matches it against a hex pattern on save, but this - // renderer is the last line before raw emission into a CSS context - // where `esc_attr` is semantically wrong. Enforcing the same regex - // here guarantees that whatever arrives in $branding — now or from - // future call sites — cannot introduce a semicolon, closing brace, - // or `` sequence. - $primary = (string) ( $branding['primary_color'] ?? '' ); - if ( '' !== $primary && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $primary ) ) { - // A custom primary drops the matching WP-blue hover, so override - // hover to the same color. Deriving a darker shade would require - // a color-math utility and is not worth the added surface area; - // a flat hover reads cleanly enough for a branded palette. - $rules[] = '--wa-primary: ' . $primary . ';'; - $rules[] = '--wa-primary-hover: ' . $primary . ';'; + // Re-validate colors as defense-in-depth. Profile::from_array already + // regex-matches them on save, but this is the last line before raw + // emission into a CSS context. Enforcing the same regex here ensures + // no semicolon, closing brace, or can be injected regardless + // of call site. + $valid_hex = static function ( string $v ): bool { + return '' !== $v && (bool) preg_match( '/^#[0-9a-fA-F]{3,8}$/', $v ); + }; + + $card_radius = (string) ( $branding['card_border_radius'] ?? '' ); + $btn_radius = (string) ( $branding['button_border_radius'] ?? '' ); + $page_bg = (string) ( $branding['page_background'] ?? '' ); + $card_bg = (string) ( $branding['card_background'] ?? '' ); + $card_bdr = (string) ( $branding['card_border'] ?? '' ); + $heading_clr = (string) ( $branding['heading_color'] ?? '' ); + $sub_clr = (string) ( $branding['subheading_color'] ?? '' ); + $btn_bg = (string) ( $branding['button_background'] ?? '' ); + $btn_text = (string) ( $branding['button_text'] ?? '' ); + $sec_btn_bg = (string) ( $branding['secondary_button_background'] ?? '' ); + $sec_btn_text = (string) ( $branding['secondary_button_text'] ?? '' ); + $sec_btn_bdr = (string) ( $branding['secondary_button_border'] ?? '' ); + $links = (string) ( $branding['links_color'] ?? '' ); + + if ( '' !== $card_radius && ctype_digit( $card_radius ) ) { + $rules[] = '--wa-card-radius:' . (int) $card_radius . 'px;'; } - if ( empty( $rules ) ) { - return ''; + if ( '' !== $btn_radius && ctype_digit( $btn_radius ) ) { + $rules[] = '--wa-btn-radius:' . (int) $btn_radius . 'px;'; } - return ''; + // Page background targets the takeover body, not the root widget. + $body_rules = []; + if ( $valid_hex( $page_bg ) ) { + $body_rules[] = 'background:' . $page_bg . ';'; + } + + if ( $valid_hex( $card_bg ) ) { + $rules[] = '--wa-surface:' . $card_bg . ';'; + } + + if ( $valid_hex( $card_bdr ) ) { + $rules[] = '--wa-card-border:' . $card_bdr . ';'; + } + + if ( $valid_hex( $heading_clr ) ) { + $rules[] = '--wa-heading-color:' . $heading_clr . ';'; + } + + if ( $valid_hex( $sub_clr ) ) { + $rules[] = '--wa-subheading-color:' . $sub_clr . ';'; + } + + if ( $valid_hex( $btn_bg ) ) { + // A custom button color drops the matching WP-blue hover; use the + // same value for hover (flat) rather than deriving a darker shade. + $rules[] = '--wa-primary:' . $btn_bg . ';'; + $rules[] = '--wa-primary-hover:' . $btn_bg . ';'; + } + + if ( $valid_hex( $btn_text ) ) { + $rules[] = '--wa-primary-fg:' . $btn_text . ';'; + } + + if ( $valid_hex( $sec_btn_bg ) ) { + $rules[] = '--wa-secondary-bg:' . $sec_btn_bg . ';'; + } + + if ( $valid_hex( $sec_btn_text ) ) { + $rules[] = '--wa-secondary-fg:' . $sec_btn_text . ';'; + } + + if ( $valid_hex( $sec_btn_bdr ) ) { + $rules[] = '--wa-secondary-border:' . $sec_btn_bdr . ';'; + } + + if ( $valid_hex( $links ) ) { + $rules[] = '--wa-links:' . $links . ';'; + } + + $out = ''; + if ( ! empty( $body_rules ) ) { + $out .= ''; + } + if ( ! empty( $rules ) ) { + $out .= ''; + } + + return $out; } /** diff --git a/src/js/admin-profiles/index.tsx b/src/js/admin-profiles/index.tsx index 19b39f1..fb465f5 100644 --- a/src/js/admin-profiles/index.tsx +++ b/src/js/admin-profiles/index.tsx @@ -103,7 +103,19 @@ interface Profile { logo_mode: LogoMode; logo_attachment_id: number; logo_url?: string; - primary_color: string; + card_border_radius: string; + button_border_radius: string; + page_background: string; + card_background: string; + card_border: string; + heading_color: string; + subheading_color: string; + button_background: string; + button_text: string; + secondary_button_background: string; + secondary_button_text: string; + secondary_button_border: string; + links_color: string; heading: string; subheading: string; }; @@ -684,6 +696,48 @@ function LogoField( { mode, attachmentId, url, onChange }: LogoFieldProps ) { ); } +interface ColorRowProps { + label: string; + value: string; + onChange: ( next: string ) => void; +} + +function ColorRow( { label, value, onChange }: ColorRowProps ) { + const safeColor = /^#[0-9a-fA-F]{6}$/.test( value ) ? value : '#000000'; + return ( +
+ { label } + + ) => + onChange( e.target.value ) + } + placeholder="#000000" + maxLength={ 7 } + spellCheck={ false } + /> +
+ ); +} + function emptyProfile(): Profile { return { id: 0, @@ -700,7 +754,19 @@ function emptyProfile(): Profile { logo_mode: 'default', logo_attachment_id: 0, logo_url: '', - primary_color: '', + card_border_radius: '', + button_border_radius: '', + page_background: '', + card_background: '', + card_border: '', + heading_color: '', + subheading_color: '', + button_background: '', + button_text: '', + secondary_button_background: '', + secondary_button_text: '', + secondary_button_border: '', + links_color: '', heading: '', subheading: '', }, @@ -945,12 +1011,94 @@ function Editor( { value={ data.branding.subheading } onChange={ ( v ) => setBranding( { subheading: v } ) } /> - setBranding( { primary_color: v } ) } - placeholder={ __( '#2271b1', 'integration-workos' ) } - /> + + +
+
+ { __( 'Colors', 'integration-workos' ) } +
+ setBranding( { page_background: v } ) } + /> + setBranding( { card_background: v } ) } + /> + setBranding( { card_border: v } ) } + /> + setBranding( { heading_color: v } ) } + /> + setBranding( { subheading_color: v } ) } + /> + setBranding( { button_background: v } ) } + /> + setBranding( { button_text: v } ) } + /> + setBranding( { secondary_button_background: v } ) } + /> + setBranding( { secondary_button_text: v } ) } + /> + setBranding( { secondary_button_border: v } ) } + /> + setBranding( { links_color: v } ) } + /> +
'partner', - 'branding' => [ 'primary_color' => 'red' ], + 'branding' => [ + 'page_background' => 'red', + 'button_background' => 'blue', + 'links_color' => 'not-a-color', + ], ] ); - $this->assertSame( '', $profile->get_branding()['primary_color'] ); + $this->assertSame( '', $profile->get_branding()['page_background'] ); + $this->assertSame( '', $profile->get_branding()['button_background'] ); + $this->assertSame( '', $profile->get_branding()['links_color'] ); } /** @@ -200,7 +206,9 @@ public function test_to_array_round_trip(): void { 'branding' => [ 'logo_mode' => Profile::LOGO_MODE_CUSTOM, 'logo_attachment_id' => 99, - 'primary_color' => '#ff0066', + 'page_background' => '#1e1004', + 'button_background' => '#ff0066', + 'links_color' => '#ff0066', 'heading' => 'Welcome', 'subheading' => 'Back', ], @@ -219,7 +227,9 @@ public function test_to_array_round_trip(): void { $this->assertFalse( $serialized['signup']['require_invite'] ); $this->assertSame( Profile::MFA_ENFORCE_ALWAYS, $serialized['mfa']['enforce'] ); $this->assertSame( [ Profile::FACTOR_TOTP, Profile::FACTOR_SMS ], $serialized['mfa']['factors'] ); - $this->assertSame( '#ff0066', $serialized['branding']['primary_color'] ); + $this->assertSame( '#1e1004', $serialized['branding']['page_background'] ); + $this->assertSame( '#ff0066', $serialized['branding']['button_background'] ); + $this->assertSame( '#ff0066', $serialized['branding']['links_color'] ); $this->assertSame( '/dashboard', $serialized['post_login_redirect'] ); $this->assertSame( Profile::MODE_CUSTOM, $serialized['mode'] ); } diff --git a/tests/wpunit/AuthKitRendererTest.php b/tests/wpunit/AuthKitRendererTest.php index 1dc033c..4d30637 100644 --- a/tests/wpunit/AuthKitRendererTest.php +++ b/tests/wpunit/AuthKitRendererTest.php @@ -82,21 +82,27 @@ public function test_render_mount_emits_root_div_with_profile_data(): void { } /** - * Branding primary color appears as a scoped CSS variable. + * Branding colors appear as scoped CSS variables. */ public function test_render_mount_emits_branding_style_tag(): void { $profile = Profile::from_array( [ 'slug' => 'members', 'title' => 'Members', - 'branding' => [ 'primary_color' => '#ff3366' ], + 'branding' => [ + 'page_background' => '#1e1004', + 'button_background' => '#ff3366', + 'links_color' => '#3d7bf5', + ], ] ); $html = $this->renderer->render_mount( $profile ); - $this->assertStringContainsString( '--wa-primary: #ff3366', $html ); - $this->assertStringContainsString( '--wa-primary-hover: #ff3366', $html ); + $this->assertStringContainsString( 'background:#1e1004', $html ); + $this->assertStringContainsString( '--wa-primary:#ff3366', $html ); + $this->assertStringContainsString( '--wa-primary-hover:#ff3366', $html ); + $this->assertStringContainsString( '--wa-links:#3d7bf5', $html ); } /** From d9bea54a58bd947b00fd73147b1b8c7e6ccae392 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 12 May 2026 17:56:01 +0700 Subject: [PATCH 2/2] Chore: PHPCS fix --- src/WorkOS/Auth/AuthKit/Profile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WorkOS/Auth/AuthKit/Profile.php b/src/WorkOS/Auth/AuthKit/Profile.php index 7f88433..d7f51b1 100644 --- a/src/WorkOS/Auth/AuthKit/Profile.php +++ b/src/WorkOS/Auth/AuthKit/Profile.php @@ -313,7 +313,7 @@ public static function from_array( array $data ): self { return ( '' !== $value && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $value ) ) ? $value : ''; }; - $sanitize_radius = static function ( string $v ): string { + $sanitize_radius = static function ( string $v ): string { $v = trim( $v ); return ( '' !== $v && ctype_digit( $v ) ) ? $v : ''; };