Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .changeset/30418-settings-toggle-confirm-off.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': minor
'@objectstack/service-settings': patch
---

feat: a settings toggle can declare the question asked before it is turned off (`confirmOffText`), and Setup's email and password sign-in toggle declares one

Clause-②: yes (widening)
Metadata-protocol: added

- `SpecifierSchema` (`@objectstack/spec/system`) gains an optional `confirmOffText: I18nLabel`: the confirm dialog text shown before a toggle's value changes to off. Omitted, nothing is asked, so every existing manifest reads as before. The name mirrors the `confirmText` an `action_button` handler already declares.
- Only a `toggle` may declare it. On any other specifier type the parse refuses it at `confirmOffText`, naming the type, because nothing would ever ask the question there.
- The platform translation bundle carries it at the toggle's own address, beside its label: `settings.<namespace>.keys.<key>.confirmOffText`.
- `@objectstack/service-settings`: the `auth` manifest's `email_password_enabled` toggle declares "People who sign in with a password will need another way in.", with zh-CN, ja-JP and es-ES text in the shipped bundles. The settings translation coverage test now holds a declared `confirmOffText` to the same per-locale coverage as the toggle's label.
- The console asking the question before save is a separate change; until it lands, the key is declared and parsed but not yet shown.
2 changes: 2 additions & 0 deletions content/docs/references/system/settings-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const result = ResolvedSettingValueSchema.parse(data);
| **pattern** | `string` | optional | Regex pattern (text only) |
| **rows** | `integer` | optional | |
| **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
| **confirmOffText** | `string \| Record<string, string>` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) |
| **childNamespace** | `string` | optional | Sub-namespace (child_pane) |
| **bannerText** | `string` | optional | Markdown body (info_banner) |
| **bannerSeverity** | `Enum<'info' \| 'success' \| 'warning' \| 'error'>` | optional | |
Expand Down Expand Up @@ -205,6 +206,7 @@ const result = ResolvedSettingValueSchema.parse(data);
| **pattern** | `string` | optional | Regex pattern (text only) |
| **rows** | `integer` | optional | |
| **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
| **confirmOffText** | `string \| Record<string, string>` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) |
| **childNamespace** | `string` | optional | Sub-namespace (child_pane) |
| **bannerText** | `string` | optional | Markdown body (info_banner) |
| **bannerSeverity** | `Enum<'info' \| 'success' \| 'warning' \| 'error'>` | optional | |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ Translation data for a single object
| **title** | `string` | optional | Translated settings manifest title |
| **description** | `string` | optional | Translated settings manifest description |
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string>; … }>` | optional | Per-setting field translations keyed by setting key |
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
| **banners** | `Record<string, { title?: string; text?: string }>` | optional | Info banner translations keyed by the info_banner specifier id |

Expand Down Expand Up @@ -509,7 +509,7 @@ Translation data for a single object
| **title** | `string` | optional | Translated settings manifest title |
| **description** | `string` | optional | Translated settings manifest description |
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string>; … }>` | optional | Per-setting field translations keyed by setting key |
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
| **banners** | `Record<string, { title?: string; text?: string }>` | optional | Info banner translations keyed by the info_banner specifier id |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,18 @@ describe('authSettingsManifest', () => {
expect(keys[key].help, `${key} help`).toBe(spec.description);
}
});

// #30418 (ruling 6657805240 (3)): turning email and password sign-in off is
// asked about first, in the #30299 sentence. The declaration parses, keeps
// its text, and the en bundle carries the same text at the toggle's address.
it('email_password_enabled asks before it goes off, and the en bundle carries the same text', () => {
const parsed = SettingsManifestSchema.parse(authSettingsManifest);
const toggle = parsed.specifiers.find((s) => s.key === 'email_password_enabled');
expect(toggle?.type).toBe('toggle');
expect(toggle?.confirmOffText).toBe('People who sign in with a password will need another way in.');
const keys = (en.settings as any).auth.keys;
expect(keys.email_password_enabled.confirmOffText).toBe(toggle?.confirmOffText);
});
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const manifest = {
label: 'Enable email/password login',
required: false,
default: true,
// Asked before the switch goes off (#30418). The server already refuses a
// save that leaves no sign-in method at all (#30202); this is the case
// where another method exists and the operator should still be told.
confirmOffText: 'People who sign in with a password will need another way in.',
},
// The email/password sign-up route's own switch (`emailAndPassword.
// disableSignUp`), a door of its own beside the audience posture below:
Expand Down
5 changes: 4 additions & 1 deletion packages/services/service-settings/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ export const en: PlatformTranslationData = {
},
},
keys: {
email_password_enabled: { label: 'Enable email/password login' },
email_password_enabled: {
label: 'Enable email/password login',
confirmOffText: 'People who sign in with a password will need another way in.',
},
signup_enabled: {
label: 'Allow email sign-up',
help: 'Opens the email-and-password sign-up form. Who it admits is decided by the Self-registration audience below: under Invitation only, only an address holding a pending invitation; under Allowlisted email domains, the allowlisted domains and invited addresses; under Open, anyone. Off closes the form under every audience, invitations included.',
Expand Down
5 changes: 4 additions & 1 deletion packages/services/service-settings/src/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ export const esES: PlatformTranslationData = {
},
},
keys: {
email_password_enabled: { label: 'Habilitar inicio de sesión con correo/contraseña' },
email_password_enabled: {
label: 'Habilitar inicio de sesión con correo/contraseña',
confirmOffText: 'Las personas que inician sesión con contraseña necesitarán otra forma de acceder.',
},
signup_enabled: {
label: 'Permitir el registro por correo',
help: 'Abre el formulario de registro con correo y contraseña. A quién admite lo decide la «Audiencia de autorregistro» inferior: con «Solo por invitación», solo a una dirección con una invitación pendiente; con «Dominios de correo», a los dominios de la lista y a las direcciones invitadas; con «Abierto», a cualquiera. Desactivado, cierra el formulario con cualquier audiencia, invitaciones incluidas.',
Expand Down
5 changes: 4 additions & 1 deletion packages/services/service-settings/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ export const jaJP: PlatformTranslationData = {
},
},
keys: {
email_password_enabled: { label: 'メール/パスワードログインを有効化' },
email_password_enabled: {
label: 'メール/パスワードログインを有効化',
confirmOffText: 'パスワードでサインインしているユーザーは、別のサインイン方法が必要になります。',
},
signup_enabled: {
label: 'メールでの新規登録を許可',
help: 'メールとパスワードによる新規登録フォームを開きます。誰を受け入れるかは下の「セルフ登録の対象」が決めます。「招待のみ」では保留中の招待を持つアドレスのみ、「メールドメイン」では許可リストのドメインと招待済みのアドレス、「オープン」では誰でも受け入れます。オフにすると、どの対象でもフォームが閉じ、招待済みのアドレスも登録できません。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ function missingFor(data: { settings?: Record<string, any> }, m: SettingsManifes
if (tr?.actions?.[s.id]?.label == null) missing.push(`action:${s.id}`);
} else if (s.key) {
if (tr?.keys?.[s.key]?.label == null) missing.push(`key:${s.key}`);
// A toggle's turn-off question is the toggle's own copy, so it is held to
// the label's coverage: an untranslated one asks in English (#30418).
if (s.confirmOffText !== undefined && tr?.keys?.[s.key]?.confirmOffText == null) {
missing.push(`key:${s.key}.confirmOffText`);
}
}
}
return missing;
Expand All @@ -103,6 +108,7 @@ function undeclaredIn(data: { settings?: Record<string, any> }): string[] {
}
const groups = new Set<string>();
const keys = new Set<string>();
const confirmsOff = new Set<string>();
const banners = new Set<string>();
const actions = new Set<string>();
for (const s of m.specifiers) {
Expand All @@ -114,10 +120,16 @@ function undeclaredIn(data: { settings?: Record<string, any> }): string[] {
if (s.id) actions.add(s.id);
} else if (s.key) {
keys.add(s.key);
if (s.confirmOffText !== undefined) confirmsOff.add(s.key);
}
}
for (const g of Object.keys(tr?.groups ?? {})) if (!groups.has(g)) extra.push(`${ns}.group:${g}`);
for (const k of Object.keys(tr?.keys ?? {})) if (!keys.has(k)) extra.push(`${ns}.key:${k}`);
for (const [k, v] of Object.entries(tr?.keys ?? {})) {
if (keys.has(k) && (v as { confirmOffText?: unknown })?.confirmOffText != null && !confirmsOff.has(k)) {
extra.push(`${ns}.key:${k}.confirmOffText`);
}
}
for (const b of Object.keys(tr?.banners ?? {})) if (!banners.has(b)) extra.push(`${ns}.banner:${b}`);
for (const a of Object.keys(tr?.actions ?? {})) if (!actions.has(a)) extra.push(`${ns}.action:${a}`);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/services/service-settings/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,10 @@ export const zhCN: PlatformTranslationData = {
},
},
keys: {
email_password_enabled: { label: '启用邮箱/密码登录' },
email_password_enabled: {
label: '启用邮箱/密码登录',
confirmOffText: '使用密码登录的用户将需要其他登录方式。',
},
signup_enabled: {
label: '允许邮箱注册',
help: '开启邮箱与密码注册表单。它接纳谁由下方的「自助注册受众」决定:「仅限邀请」下只接纳持有待处理邀请的地址;「邮箱域名」下接纳允许列表中的域名与受邀地址;「开放」下接纳任何人。关闭后,在任何受众下该表单都会关闭,受邀者也不例外。',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,7 @@
"system/Specifier:bannerSeverity",
"system/Specifier:bannerText",
"system/Specifier:childNamespace",
"system/Specifier:confirmOffText",
"system/Specifier:default",
"system/Specifier:deprecated",
"system/Specifier:description",
Expand Down
40 changes: 40 additions & 0 deletions packages/spec/src/system/settings-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,46 @@ describe('Specifier.valueDomain — a declared standard domain is the boundary,
});
});

describe('Specifier.confirmOffText — a toggle asks before it goes off (#30418, ruling 6657805240)', () => {
const toggle = { type: 'toggle', key: 'email_password_enabled', label: 'Enable email/password login' } as const;

it('is accepted on a toggle and keeps its text', () => {
const parsed = SpecifierSchema.parse({ ...toggle, confirmOffText: 'People will need another way in.' });
expect(parsed.confirmOffText).toBe('People will need another way in.');
});

it('accepts an inline locale map, as every other I18nLabel does', () => {
const text = { en: 'People will need another way in.', 'zh-CN': '用户将需要其他登录方式。' };
expect(SpecifierSchema.parse({ ...toggle, confirmOffText: text }).confirmOffText).toEqual(text);
});

it('absent still parses, and asks nothing', () => {
const parsed = SpecifierSchema.parse(toggle);
expect(parsed.confirmOffText).toBeUndefined();
});

// One value-bearing input type, one select, two layout types: each is refused
// at the key, by a message that names the key and the type it sits on.
for (const spec of [
{ type: 'text', key: 'smtp_host', label: 'Host' },
{ type: 'select', key: 'provider', label: 'Provider', options: [{ value: 'smtp', label: 'SMTP' }] },
{ type: 'group', label: 'Email and password' },
{ type: 'action_button', id: 'test', label: 'Send test email', handler: { kind: 'navigate', url: '/x' } },
] as const) {
it(`is refused on a '${spec.type}' specifier, at the key, naming the key and the type`, () => {
expect(SpecifierSchema.safeParse(spec).success, 'control: the bare specifier parses').toBe(true);
const result = SpecifierSchema.safeParse({ ...spec, confirmOffText: 'Sure?' });
expect(result.success).toBe(false);
const issues = result.success ? [] : result.error.issues;
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe('custom');
expect(issues[0].path).toEqual(['confirmOffText']);
expect(issues[0].message).toContain(`'${spec.type}'`);
expect(issues[0].message).toContain(`'confirmOffText'`);
});
}
});

describe('`visible` — the settings visibility grammar, narrowed to what the save-time evaluator runs', () => {
const withVisible = (visible: unknown) =>
SpecifierSchema.safeParse({ type: 'text', key: 'smtp_host', label: 'Host', visible });
Expand Down
23 changes: 23 additions & 0 deletions packages/spec/src/system/settings-manifest.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,19 @@ export const SpecifierSchema = lazySchema(() => z.object({
/** `action_button`: handler invoked on click. */
handler: SpecifierHandlerSchema.optional().describe('Action handler (action_button)'),

/**
* `toggle`: the confirm dialog text shown before the value changes to off
* (omit = no confirm). The name mirrors a handler's `confirmText`; it is
* asked only on the turn to OFF, the one direction a toggle such as
* `auth.email_password_enabled` takes away from people. Translated under
* `settings.<namespace>.keys.<key>.confirmOffText`, beside the toggle's
* `label`. The parse refuses it on every other specifier type, which would
* never ask it.
*/
confirmOffText: I18nLabelSchema
.optional()
.describe("Confirm dialog text shown before a toggle's value changes to off (omit = no confirm)"),

/** `child_pane`: namespace of the sub-manifest to navigate to. */
childNamespace: SnakeCaseIdentifierSchema.optional().describe('Sub-namespace (child_pane)'),

Expand Down Expand Up @@ -567,6 +580,16 @@ export const SpecifierSchema = lazySchema(() => z.object({
message: `Specifier of type '${spec.type}' carries no value, so it must not declare a 'valueDomain'.`,
});
}
// A turn to OFF is asked about only on a `toggle`, the one specifier with an
// off. On any other type the renderer never asks it, so the declaration is a
// silent no-op, the shape Prime Directive #10 refuses (ruling 6657805240).
if (spec.confirmOffText !== undefined && spec.type !== 'toggle') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmOffText'],
message: `Specifier of type '${spec.type}' has no off to confirm, so it must not declare a 'confirmOffText' (only a 'toggle' may).`,
});
}
// select/radio/multiselect require options.
if (['select', 'radio', 'multiselect'].includes(spec.type)) {
if (!spec.options || spec.options.length === 0) {
Expand Down
11 changes: 11 additions & 0 deletions packages/spec/src/system/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,6 +1834,17 @@ describe('translation unknown-key strictness', () => {
expect(() => PlatformTranslationDataSchema.parse({ settings })).not.toThrow();
expect(() => PlatformTranslationBundleSchema.parse({ 'zh-CN': { settings } })).not.toThrow();
});

it("carries a toggle's turn-off question beside its label on the platform face (#30418)", () => {
// The manifest key `confirmOffText` is translated at the toggle's own
// address, as its `label` is; a strict `keys` entry that did not declare
// the leaf would refuse that bundle here, and `tsc` would refuse the
// service's `PlatformTranslationData`-typed bundle literals.
const withConfirm = { auth: { keys: { email_password_enabled: { label: 'Login', confirmOffText: 'Sure?' } } } };
const result = PlatformTranslationDataSchema.safeParse({ settings: withConfirm });
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect(result.data?.settings?.auth?.keys?.email_password_enabled?.confirmOffText).toBe('Sure?');
});
});

// ──────────────────────────────────────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/system/translation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,7 @@ const platformSettingsShape = () => ({
* settings.<namespace>.keys.<setting_key>.help
* settings.<namespace>.keys.<setting_key>.placeholder
* settings.<namespace>.keys.<setting_key>.options.<option_value>
* settings.<namespace>.keys.<setting_key>.confirmOffText
* settings.<namespace>.actions.<action_id>.label
* settings.<namespace>.actions.<action_id>.confirmText
* settings.<namespace>.actions.<action_id>.successMessage
Expand Down Expand Up @@ -2318,6 +2319,8 @@ const platformSettingsShape = () => ({
placeholder: z.string().optional().describe('Translated input placeholder'),
options: z.record(z.string(), z.string()).optional()
.describe('Enum option value → translated label'),
confirmOffText: z.string().optional()
.describe("Translated confirm text shown before a toggle's value changes to off"),
})).optional().describe('Per-setting field translations keyed by setting key'),
actions: z.record(z.string(), strictObject({
surface: 'this settings action translation',
Expand Down
Loading