Skip to content

Commit 99ac942

Browse files
arraypressclaude
andcommitted
Tell options.php the page's capability, and hand emails the shape it reads
The declared capability gated the menu, the render and the admin-post handlers, but options.php asks option_page_capability_{group} and falls back to manage_options, so a page registered for a shop manager rendered and then refused every save. The filter is registered now. The bridge to wp-register-emails read a key the email editor no longer stores and returned keys compose() never reads, so an owner's edited heading and body were discarded. It returns enabled, to, subject, content and context, and its docblock names the real API. Sections are scoped by slug rather than by field count, the four files without strict_types have it, and an unregistered read of an encrypted setting decrypts instead of returning the ciphertext. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Kwx2STJ4EaVaJofEJJ9Zo
1 parent 3c5f4d9 commit 99ac942

7 files changed

Lines changed: 261 additions & 34 deletions

File tree

‎src/Core.php‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
* @since 1.0.0
1111
*/
1212

13+
declare( strict_types=1 );
14+
15+
use ArrayPress\FieldKit\Context\EncryptedContext;
16+
use ArrayPress\FieldKit\Context\OptionContext;
17+
use ArrayPress\FieldKit\Field;
18+
use ArrayPress\FieldKit\Registry as Types;
1319
use ArrayPress\RegisterSettingFields\Registry;
1420
use ArrayPress\RegisterSettingFields\SettingFields;
1521

@@ -70,6 +76,12 @@ function set_setting_field_value( string $settings_id, string $field_key, mixed
7076
*
7177
* Use this instead of get_option() for registered settings.
7278
*
79+
* Before the page is registered — a read that runs earlier in the load
80+
* than the plugin's own registration — the option is read directly, under
81+
* its id. A constant cannot stand in for a value there, because the
82+
* prefix is the page's configuration; an encrypted value is still
83+
* decrypted, because the value itself says whether it needs to be.
84+
*
7385
* @param string $settings_id Settings ID.
7486
* @param string $field_key Field key.
7587
* @param mixed $fallback Default value if not set.
@@ -85,9 +97,28 @@ function get_setting_field_value( string $settings_id, string $field_key, $fallb
8597

8698
$options = get_option( $settings_id, [] );
8799

88-
return is_array( $options ) && isset( $options[ $field_key ] )
89-
? $options[ $field_key ]
90-
: $fallback;
100+
if ( ! is_array( $options ) || ! isset( $options[ $field_key ] ) ) {
101+
return $fallback;
102+
}
103+
104+
// Nothing here knows whether the field is encrypted, so the read
105+
// goes through the kit's decorator as the registered path's does,
106+
// with the field marked as though it were: ciphertext the decorator
107+
// wrote carries its marker and decrypts, and anything else comes back
108+
// as it is. What this used to do was hand the ciphertext back as
109+
// though it were the API key.
110+
$field = new Field(
111+
$field_key,
112+
( new Types() )->get( 'text' ),
113+
[
114+
'type' => 'text',
115+
'encrypted' => true,
116+
]
117+
);
118+
119+
$value = ( new EncryptedContext( new OptionContext( $settings_id ) ) )->read( 0, $field );
120+
121+
return null === $value || '' === $value ? $fallback : $value;
91122
}
92123
}
93124

‎src/Email.php‎

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,32 @@
1111
* @since 1.0.0
1212
*/
1313

14+
declare( strict_types=1 );
15+
1416
if ( ! function_exists( 'get_setting_fields_email' ) ) {
1517
/**
16-
* Get email editor settings for use by wp-register-emails settings_callback.
18+
* What the site owner typed into an email_editor field, in the shape
19+
* wp-register-emails reads.
20+
*
21+
* The field stores `recipient`, `subject`, `heading` and `body`, and an
22+
* `enabled` toggle if the consumer gave it one. `Emails::compose()` reads
23+
* `enabled`, `to`, `subject`, `content` and `context`. This is the seam
24+
* between the two, and it used to be stale on both sides — it read a
25+
* `message` the field never stored and returned a `title` nothing
26+
* consumed, so an owner's edited heading and body were quietly thrown
27+
* away and the plugin's defaults went out instead.
1728
*
18-
* Reads the stored email_editor field value (enabled, recipient, subject,
19-
* title, subtitle, message) and returns it in the format expected by
20-
* register_email_template's settings_callback.
29+
* register_email( 'my_plugin', 'purchase_receipt', [
30+
* 'settings' => fn() => get_setting_fields_email( 'my_plugin', 'email_purchase_receipt' ),
31+
* ] );
2132
*
22-
* Usage in register_email_template:
23-
* 'settings_callback' => fn() => get_setting_fields_email( 'my_plugin', 'email_purchase_receipt' ),
33+
* A part the owner left empty is left out, so the email's registered
34+
* default stands in for it.
2435
*
2536
* @param string $settings_id Settings ID (e.g., 'sugarcart').
2637
* @param string $field_key The email_editor field key (e.g., 'email_purchase_receipt').
2738
*
28-
* @return array Settings array with 'enabled', 'recipient', 'subject', 'title',
29-
* 'subtitle', 'message' keys. Returns empty array if not found.
39+
* @return array{enabled?: bool, to?: string[], subject?: string, content?: string, context?: array{title: string}}
3040
*/
3141
function get_setting_fields_email( string $settings_id, string $field_key ): array {
3242
$value = get_setting_field_value( $settings_id, $field_key );
@@ -41,25 +51,27 @@ function get_setting_fields_email( string $settings_id, string $field_key ): arr
4151
$result['enabled'] = (bool) $value['enabled'];
4252
}
4353

44-
if ( ! empty( $value['recipient'] ) ) {
45-
$result['recipient'] = sanitize_email( $value['recipient'] );
46-
}
54+
// Several addresses, separated by commas — which is what the field
55+
// says beside its recipient box.
56+
$to = array_filter( array_map( 'sanitize_email', explode( ',', (string) ( $value['recipient'] ?? '' ) ) ) );
4757

48-
if ( ! empty( $value['subject'] ) ) {
49-
$result['subject'] = $value['subject'];
58+
if ( [] !== $to ) {
59+
$result['to'] = array_values( $to );
5060
}
5161

52-
if ( ! empty( $value['title'] ) ) {
53-
$result['title'] = $value['title'];
62+
if ( ! empty( $value['subject'] ) ) {
63+
$result['subject'] = (string) $value['subject'];
5464
}
5565

56-
if ( ! empty( $value['subtitle'] ) ) {
57-
$result['subtitle'] = $value['subtitle'];
66+
// The editor stores the body without its <p> tags, as wp_editor
67+
// does; wpautop() puts them back.
68+
if ( ! empty( $value['body'] ) ) {
69+
$result['content'] = wpautop( (string) $value['body'] );
5870
}
5971

60-
// wp_editor stores content without <p> tags; wpautop restores them.
61-
if ( ! empty( $value['message'] ) ) {
62-
$result['message'] = wpautop( $value['message'] );
72+
// The heading is the template's title, which sits above the body.
73+
if ( ! empty( $value['heading'] ) ) {
74+
$result['context'] = [ 'title' => (string) $value['heading'] ];
6375
}
6476

6577
return $result;
@@ -82,10 +94,10 @@ function get_setting_fields_email_recipient( string $settings_id, string $field_
8294
$value = get_setting_field_value( $settings_id, $field_key );
8395

8496
if ( is_array( $value ) && ! empty( $value['recipient'] ) && is_email( $value['recipient'] ) ) {
85-
return $value['recipient'];
97+
return (string) $value['recipient'];
8698
}
8799

88-
return get_option( 'admin_email' );
100+
return (string) get_option( 'admin_email', '' );
89101
}
90102
}
91103

‎src/Page.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* @since 1.0.0
1111
*/
1212

13+
declare( strict_types=1 );
14+
1315
use ArrayPress\FieldKit\Value;
1416

1517
/** Page Field Helpers ********************************************************/

‎src/SettingFields.php‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,26 @@ public function register_settings(): void {
359359
'default' => [],
360360
]
361361
);
362+
363+
// options.php runs its own capability check before it saves, and it
364+
// does not know what the menu was registered with: it asks this
365+
// filter and falls back to manage_options. Without it a page
366+
// registered for a lower capability drew for its users and then
367+
// refused every save with "Sorry, you are not allowed to manage
368+
// options."
369+
add_filter( 'option_page_capability_' . (string) $this->config['option_group'], [ $this, 'option_page_capability' ] );
370+
}
371+
372+
/**
373+
* The capability options.php checks before it saves this page's option.
374+
*
375+
* The same one that gates the menu, the render and the admin-post
376+
* handlers, so the user who can see the page can save it.
377+
*
378+
* @return string
379+
*/
380+
public function option_page_capability(): string {
381+
return (string) $this->config['capability'];
362382
}
363383

364384
/**
@@ -660,15 +680,15 @@ private function render_sections( string $tab ): void {
660680
printf( '<p class="description">%s</p>', wp_kses_post( (string) $section['description'] ) );
661681
}
662682

663-
$this->render_table( $set, $in_section );
683+
$this->render_table( $set, $in_section, (string) $slug );
664684
}
665685

666686
// A field no section claimed still has to appear, or a typo in a
667687
// section name silently removes it from the page.
668688
$loose = array_diff_key( $fields, array_flip( $shown ) );
669689

670690
if ( [] !== $loose ) {
671-
$this->render_table( $set, $loose );
691+
$this->render_table( $set, $loose, '' );
672692
}
673693
}
674694

@@ -677,13 +697,19 @@ private function render_sections( string $tab ): void {
677697
*
678698
* @param FieldSet $set The field set.
679699
* @param Field[] $fields The fields to render.
700+
* @param string $scope The section these belong to, or empty for the
701+
* fields no section claimed.
680702
*
681703
* @return void
682704
*/
683-
private function render_table( FieldSet $set, array $fields ): void {
705+
private function render_table( FieldSet $set, array $fields, string $scope ): void {
684706
$layout = Sections::split( $fields );
685707

686708
if ( [] !== $layout ) {
709+
// Scoped by the section rather than by how many fields it has.
710+
// Two sections on one tab with the same number of fields got the
711+
// same ids for their tab strips, and the second strip drove the
712+
// first one's panels.
687713
$sections = Sections::render(
688714
$layout,
689715
function ( array $group ) use ( $set ): string {
@@ -696,7 +722,7 @@ function ( array $group ) use ( $set ): string {
696722

697723
return (string) ob_get_clean();
698724
},
699-
$this->id . '-' . count( $fields )
725+
$this->id . '-' . $scope
700726
);
701727

702728
echo $sections; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped as it is built.
@@ -1239,11 +1265,6 @@ public function get_value( string $field_key, mixed $fallback = null ): mixed {
12391265
return null === $value || '' === $value ? $fallback : $value;
12401266
}
12411267

1242-
/**
1243-
* Get the field configuration.
1244-
*
1245-
* @return array<string, array<string, mixed>>
1246-
*/
12471268
/**
12481269
* Write one value, through the same decorators a form save uses.
12491270
*
@@ -1295,6 +1316,14 @@ public function delete_value( string $field_key ): bool {
12951316
return true;
12961317
}
12971318

1319+
/**
1320+
* Get the field configuration, keyed by field key.
1321+
*
1322+
* As configured, with a badged section's badge copied onto each of its
1323+
* fields.
1324+
*
1325+
* @return array<string, array<string, mixed>>
1326+
*/
12981327
public function get_fields(): array {
12991328
return $this->fields;
13001329
}

‎src/TypeCheck.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* @since 1.0.0
1111
*/
1212

13+
declare( strict_types=1 );
14+
1315
use ArrayPress\FieldKit\Value;
1416

1517
/*

‎tests/SetValueTest.php‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,27 @@ public function test_writing_one_field_leaves_the_others_alone(): void {
9393
$this->assertSame( 'second', $page->get_value( 'other' ) );
9494
}
9595

96+
/**
97+
* Read before the page is registered, an encrypted value is still the
98+
* value.
99+
*
100+
* The helper falls back to the option when the page is not registered
101+
* yet, and it used to hand back what the option held — the ciphertext —
102+
* as though it were the key.
103+
*/
104+
public function test_an_encrypted_value_is_readable_before_the_page_is_registered(): void {
105+
$page = $this->page();
106+
107+
$page->set_value( 'secret', 'sk_test_example' );
108+
$page->set_value( 'plain', 'hello' );
109+
110+
Registry::instance()->unregister( 'sf_set' );
111+
112+
$this->assertSame( 'sk_test_example', get_setting_field_value( 'sf_set', 'secret' ) );
113+
$this->assertSame( 'hello', get_setting_field_value( 'sf_set', 'plain' ) );
114+
$this->assertSame( 'fallback', get_setting_field_value( 'sf_set', 'missing', 'fallback' ) );
115+
}
116+
96117
public function test_an_unknown_field_writes_nothing(): void {
97118
$this->assertFalse( $this->page()->set_value( 'nothing', 'x' ) );
98119
}

0 commit comments

Comments
 (0)