Skip to content

feat(admin): configurable per-setting defaults via policy - #1079

Draft
forain wants to merge 2 commits into
bulwarkmail:mainfrom
forain:feat/policy-setting-defaults
Draft

forain wants to merge 2 commits into
bulwarkmail:mainfrom
forain:feat/policy-setting-defaults

Conversation

@forain

@forain forain commented Sep 21, 2026

Copy link
Copy Markdown

What

Lets an operator choose the value users start with for governable settings, e.g. make signature position → above quoted text the default for everyone, without changing the build default or forcing it on people who chose otherwise.

SettingsPolicy.defaults has been in the policy schema (and usePolicyStore.getEffectiveDefault) for a while, but nothing wrote to it and nothing read it. This wires it up end to end.

How

  • lib/admin/policy-settings.ts – one shared catalogue of governable settings (key, type, allowed values). Replaces the RESTRICTABLE_SETTINGS list that lived only in the admin tab, and is now used by the admin UI, the server-side sanitizer and the client. Adds signaturePosition and signatureSeparatorEnabled.
  • Admin → Policy tab – every setting row gets a Default control (Built-in / value) next to Lock and Hide.
  • Server – policy.defaults is sanitized on load and save (same place as defaultSidebarApps), so a hand-edited policy.json can't inject unknown keys or wrong-shaped values; PUT /api/admin/policy rejects a non-object defaults.
  • Settings store – new explicitSettings: string[] (persisted, exported and synced with the rest) records which keys the user set themselves via updateSetting. applyPolicyDefaults() fills in only the other keys. It runs when the policy loads, is re-applied at the end of importSettings() (so a synced blob written before the operator set the default still picks it up), and is what "Reset to defaults" lands on.
  • Composing settings honour lock/hide for the two newly governable keys.

Semantics: an operator default applies to anyone who never touched that setting – including existing users – and a user's own choice is never overridden, even if the operator changes the default later.

Tests

  • lib/admin/__tests__/policy-settings.test.ts – sanitizer keeps valid defaults, drops unknown keys / wrong shapes / malformed sections.
  • stores/__tests__/settings-store-policy-defaults.test.ts – applied on policy load; explicit choice preserved; non-setting keys ignored; re-applied over an older synced blob; explicit choice inside an imported blob honoured; reset lands on the operator default; explicitSettings exported.

npm run typecheck, npm run lint, npx vitest run all green (the one failure locally is the pre-existing timezone-dependent birthday-calendar test, unrelated).

Notes for review

  • The Composing tab did not honour lock/hide for its pre-existing keys (sendConfirmation, plainTextMode, …) before this PR either; I only wired the two keys I added to keep the diff focused. Happy to do the rest in a follow-up.
  • Admin UI strings are English-only like the rest of the admin dashboard; no user-facing locale keys were added.

SettingsPolicy.defaults existed in the schema but nothing wrote or read
it. Wire it up end to end:

- lib/admin/policy-settings.ts: one shared catalogue of governable
  settings (type + allowed values) used by the admin UI, the policy
  sanitizer and the client. Adds signaturePosition and
  signatureSeparatorEnabled to the list.
- Admin Policy tab gets a Default control per setting (Built-in / value);
  policy.json defaults are sanitized on load and save like the sidebar
  apps, and the PUT route rejects a non-object defaults section.
- Settings store tracks which keys the user set themselves
  (explicitSettings, exported and synced with the rest). Policy defaults
  fill in only the others, are applied on policy load, re-applied over an
  imported/synced blob that predates them, and are what Reset to
  defaults lands on.
- Composing tab honours lock/hide for the two new keys.

Lets an operator make e.g. signature position 'above quoted text' the
default for everyone without changing the build default.
Copilot AI lite review requested due to automatic review settings September 21, 2026 15:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

It introduces a runtime safety issue in updateSetting (can overwrite store actions) and currently allows unconstrained numeric policy defaults that can permit invalid/pathological values.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
What changed in this PR

This PR wires up policy.defaults end-to-end so operators can define per-setting default values that apply only to users who have never explicitly changed those settings, without changing build defaults or overriding user choices.

Changes:

  • Adds a shared governable-settings catalog plus server-side sanitization for policy.defaults.
  • Extends the Admin → Policy tab UI to configure per-setting defaults (Built-in vs explicit value).
  • Introduces explicitSettings tracking and applies policy defaults in the settings store (on policy load, after imports, and on reset).
File Description
stores/​settings-store.ts Tracks user-explicit settings and applies operator defaults without overriding explicit user choices.
stores/​__tests__/​settings-store-policy-defaults.test.ts Adds tests for default-application semantics and explicit-choice preservation.
lib/​admin/​policy-settings.ts New central catalog + validator/sanitizer for governable settings defaults.
lib/​admin/​config-manager.ts Sanitizes policy.defaults on policy load/save.
lib/​admin/​__tests__/​policy-settings.test.ts Adds tests for defaults sanitization behavior.
FEATURES.md Documents the new admin policy defaults capability.
components/​settings/​composing-settings.tsx Applies lock/hide policy to newly governable composing settings.
app/​api/​admin/​policy/​route.ts Validates that defaults is an object on PUT.
app/​(main)/​admin/​_tabs/​policy.tsx Admin UI: adds “Default” control per setting and uses shared catalog/validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/admin/policy-settings.ts Outdated
Comment on lines +22 to +27
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
Comment thread stores/settings-store.ts
Comment on lines 768 to +774
updateSetting: (key, value) => {
set({ [key]: value });
const { explicitSettings } = get();
const trackAsExplicit = key !== 'explicitSettings' && !explicitSettings.includes(key);
set({
[key]: value,
...(trackAsExplicit ? { explicitSettings: [...explicitSettings, key] } : {}),
});
Review follow-ups:

- emailsPerPage and markAsReadDelay are numeric enums now (the settings
  UI only ever produces 10/25/50/100 and 0/3000/5000/-1), so policy.json
  and the admin UI can no longer hand users a 0 or negative page size.
  Free numbers (sessionTimeout) must be integers within an optional
  min/max; the admin input carries the same bounds.
- updateSetting refuses keys that are not in DEFAULT_SETTINGS: the type
  admits every key of the state, including the store actions, and a bad
  call would otherwise overwrite one and record it as explicit.
@juniorcsa2022

Copy link
Copy Markdown

Thanks for this PR — it's exactly what we need. We run Bulwark for small businesses in Brazil and would love two more settings covered by operator defaults:

  1. Color mode (theme: system | light | dark). We ship a light-first brand theme and want new users to start in light instead of system, while still respecting an explicit user choice — your explicitSettings approach fits perfectly.

  2. Default keywords/labels (DEFAULT_KEYWORDS). The default names ("Red", "Orange", …) are stored data, not i18n, so non-English deployments show English labels. Either let operators define the default keyword list via policy, or make the default names translatable while keeping the IDs stable (today renaming a label changes its keyword id and re-tags messages).

Related, maybe a separate issue: the policy's default/forced theme is only fetched after the first login, so on a first visit /login renders the stock theme. Loading the default theme on the login page would make branding consistent from the first visit.

Happy to test a build.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants