Settings schema
Business view
Most integrations need some clinic-specific configuration — an API key for the reminders service, which hours before an appointment to notify, which channel to use. Rather than building a settings page per plugin, Dentolize lets a plugin declare its own settings form as data (a JSON schema), and Dentolize renders that form natively — in the clinic's own UI chrome, in both Arabic and English — without the plugin ever getting an iframe or any code running in the browser.
From the plugin author's side: they describe fields (toggle, number, text, dropdown, secret, URL), Dentolize renders and validates the form, and the plugin's backend reads the saved values through the API. From the clinic's side: it just looks like any other settings form in the product — see the Settings tab in Walkthrough for what this looks like with the demo plugin's five-field example.
Secret-typed fields (API keys, passwords) get special handling: once saved, the real value is never sent back to a browser again — the form shows a placeholder and a "Replace" button instead, so there's no way for someone with browser dev tools open to casually read out a stored credential from the settings page.
Technical view
The schema shape (v1)
{
"version": 1,
"sections": [{
"key": "reminders",
"title": { "en": "Reminder settings", "ar": "إعدادات التذكير" },
"fields": [{
"key": "hoursBefore", // ^[a-zA-Z][a-zA-Z0-9_]{0,63}$, unique across schema
"type": "number", // text | secret | number | boolean | select | multiselect | url
"label": { "en": "Hours before", "ar": "ساعات قبل الموعد" },
"required": true, "default": 24,
"validation": { "min": 1, "max": 168 },
"visibleIf": { "field": "enabled", "equals": true } // one flat condition, no chaining
}]
}]
}
The canonical implementation is packages/common/src/pluginSettingsSchema/ (types.ts, validateSchema.ts, validateValues.ts), re-exported through the SDK as defineSettingsSchema() so a plugin author can validate locally before ever submitting a listing.
Validation rules
Enforced identically server-side, in the SDK, and (as a hand-synced copy, see Plugin registry) in the admin editor: ≤10 sections, ≤50 fields total; field/section keys must match ^[a-zA-Z][a-zA-Z0-9_]{0,63}$ and be unique across the whole schema (not just within a section); select/multiselect require non-empty options and are the only types allowed to have options; secret fields cannot declare a default; visibleIf.field must reference a field key that actually exists elsewhere in the schema. Hidden fields (failing their visibleIf) skip validation entirely, including required — so a field only becomes mandatory once it's actually shown.
The secret-field UX in detail
The server stores real secret values but never returns them to a settings-form client — GET on the clinic-facing settings read returns the sentinel string "__SET__" in place of any stored secret. PluginSettingsForm.tsx's SecretField component special-cases this: if the current value is the sentinel, it renders a disabled input with placeholder •••••••• (set) and a Replace button; clicking Replace clears the field and swaps in a real password input with a Keep existing button to revert without touching the stored value. Submitting the sentinel unchanged is explicitly supported server-side — it means "leave this secret as-is."
Only the plugin's own backend ever sees a real secret value, via GET /api/v1/installation (scope settings:read) — the clinic-facing UI is the only thing that's masked.
Writing settings back via the API
With settings:write (implies settings:read), a plugin can update its own settings via PATCH /api/v1/installation/settings (SDK: client.installation.updateSettings(values)) — useful for storing things like a sync cursor or connection status that the plugin, not the clinic, needs to persist. The body is the full settings object, validated against the same schema as a UI save, and any change — whether made by the clinic or by the plugin itself calling this endpoint — fires a plugin.settings.updated webhook. The PATCH response masks secrets the same way the UI does; only the unauthenticated-by-a-browser GET /installation call ever returns real secret values.
Where it's actually authored and rendered
Authored (as raw JSON, with structural-only validation and no live preview) in the Xolize admin tool: SettingsSchemaEditor.js. Rendered as a real, interactive form in the clinic-facing Plugin Hub: packages/clinic-web-canary/src/features/PluginHub/schemaForm/PluginSettingsForm.tsx, driven by react-hook-form, with field-level copy taken directly from the schema's own inline {en, ar} label/help objects rather than the app's i18n bundles — a deliberate trust-boundary choice (a plugin author's copy never enters Dentolize's own translation files, per a comment in that file).