Files
webmail/lib/config-validation.ts
T
Linus Rath 5597eadc28 fix: give a server its own sign-in button only when it has a button label #1135
A server entry's OAuth client ID already has a meaning while OAuth is
off globally: it is the registered client that password and TOTP logins
use with Stalwart's require_client_registration. The PR made every such
entry offer OAuth on its own, so existing installs would suddenly run
discovery and show SSO buttons on the login page, and multi-IdP setups
would get one extra button per server.

A server now gets its own button only with both a client ID and a button
label, which no existing config has. The rule lives in `offersOwnOAuth`
with a test. Also:
- the OAuth-only login form uses the selected server's label
- the public config validator checks `buttonLabel` is a string
- the admin hint and `.env.example` say the label enables the button
2026-10-01 11:01:17 +02:00

72 lines
2.8 KiB
TypeScript

import type { ConfigData } from '@/hooks/use-config';
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
type Validator<T> = (value: unknown) => value is T;
type Validators<T> = { [Key in keyof T]-?: Validator<T[Key]> };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
const isString: Validator<string> = (value): value is string => typeof value === 'string';
const isBoolean: Validator<boolean> = (value): value is boolean => typeof value === 'boolean';
function isJmapServers(value: unknown): value is PublicJmapServerEntry[] {
return Array.isArray(value) && value.every((entry: unknown) => {
if (!isRecord(entry)) return false;
if (!isString(entry.id) || !isString(entry.label) || !isString(entry.url)) return false;
if (!Array.isArray(entry.domains) || !entry.domains.every(isString)) return false;
if (entry.oauth === undefined) return true;
return isRecord(entry.oauth)
&& (entry.oauth.clientId === undefined || isString(entry.oauth.clientId))
&& (entry.oauth.issuerUrl === undefined || isString(entry.oauth.issuerUrl))
&& (entry.oauth.buttonLabel === undefined || isString(entry.oauth.buttonLabel));
});
}
// Keep this exhaustive: a new required config field must be validated before
// a response can replace the hook's complete initial state and enter its cache.
const configValidators = {
appName: isString,
jmapServerUrl: isString,
oauthEnabled: isBoolean,
oauthOnly: isBoolean,
oauthClientId: isString,
oauthIssuerUrl: isString,
oauthScopes: isString,
rememberMeEnabled: isBoolean,
settingsSyncEnabled: isBoolean,
stalwartFeaturesEnabled: isBoolean,
stalwartJmapPassthroughEnabled: isBoolean,
devMode: isBoolean,
faviconUrl: isString,
appLogoLightUrl: isString,
appLogoDarkUrl: isString,
loginLogoLightUrl: isString,
loginLogoDarkUrl: isString,
loginCompanyName: isString,
loginImprintUrl: isString,
loginPrivacyPolicyUrl: isString,
loginWebsiteUrl: isString,
loginLogoMaxHeight: isString,
loginLogoMaxWidth: isString,
loginShowHeading: isBoolean,
loginShowSubtitle: isBoolean,
loginShowTotp: isBoolean,
loginShowTokenLogin: isBoolean,
loginShowVersion: isBoolean,
demoMode: isBoolean,
autoSsoEnabled: isBoolean,
allowCustomJmapEndpoint: isBoolean,
jmapServers: isJmapServers,
jmapServerAutoPickByDomain: isBoolean,
embeddedMode: isBoolean,
parentOrigin: isString,
} satisfies Validators<ConfigData>;
/** Validate the server's complete response without rewriting configured values. */
export function isConfigData(value: unknown): value is ConfigData {
return isRecord(value)
&& Object.entries(configValidators).every(([key, validate]) => validate(value[key]));
}