stack/packages/stack-shared/src/config/schema.ts
BilalG1 43340f0eb7
default new email templates (#788)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> This PR adds default email templates and themes, updates schema and
components, and introduces a migration tool for email templates.
> 
>   - **Behavior**:
> - Adds default email templates and themes, including `Email
Verification`, `Password Reset`, `Magic Link/OTP`, `Team Invitation`,
and `Sign In Invitation`.
> - Updates `render-email` and `send-email` routes to use `themes` and
`templates` instead of `themeList` and `templateList`.
> - Introduces migration tool for email templates in `page-client.tsx`
and `page.tsx`.
>   - **Schema**:
> - Updates `branchConfigSchema` and `organizationConfigDefaults` to use
`selectedThemeId`, `themes`, and `templates`.
>     - Adds `templateThemeIdSchema` to `schema-fields.ts`.
>   - **Components**:
> - Replaces `ThemePreview` with `EmailPreview` in various components.
>     - Adds `ResizableContainer` for email preview resizing.
>   - **Tests**:
> - Updates e2e tests for email themes and templates to reflect new
schema and behavior.
>   - **Misc**:
> - Renames `themeList` to `themes` and `templateList` to `templates` in
multiple files.
>     - Adds new email template metadata in `utils.tsx`.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 49554ca03d. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>


<!-- ELLIPSIS_HIDDEN -->
2025-07-25 10:54:34 -07:00

333 lines
13 KiB
TypeScript

import * as yup from "yup";
import * as schemaFields from "../schema-fields";
import { yupBoolean, yupObject, yupRecord, yupString, yupUnion } from "../schema-fields";
import { allProviders } from "../utils/oauth";
import { DeepMerge, DeepPartial, get, has, isObjectLike, mapValues, set } from "../utils/objects";
import { Config, NormalizesTo } from "./format";
import { DEFAULT_EMAIL_THEME_ID, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_TEMPLATES } from "../helpers/emails";
// NOTE: The validation schemas in here are all schematic validators, not sanity-check validators.
// For more info, see ./README.md
export const configLevels = ['project', 'branch', 'environment', 'organization'] as const;
export type ConfigLevel = typeof configLevels[number];
const permissionRegex = /^\$?[a-z0-9_:]+$/;
const customPermissionRegex = /^[a-z0-9_:]+$/;
/**
* All fields that can be overridden at this level.
*/
export const projectConfigSchema = yupObject({
sourceOfTruth: yupUnion(
yupObject({
type: yupString().oneOf(['hosted']).optional(),
}).defined(),
yupObject({
type: yupString().oneOf(['neon']).optional(),
connectionStrings: yupRecord(
yupString().defined(),
yupString().defined(),
).defined(),
}).defined(),
yupObject({
type: yupString().oneOf(['postgres']).optional(),
connectionString: yupString().defined()
}).defined(),
).optional(),
});
// --- NEW RBAC Schema ---
const branchRbacDefaultPermissions = yupRecord(
yupString().optional().matches(permissionRegex),
yupBoolean().isTrue().optional(),
).optional();
const branchRbacSchema = yupObject({
permissions: yupRecord(
yupString().optional().matches(customPermissionRegex),
yupObject({
description: yupString().optional(),
scope: yupString().oneOf(['team', 'project']).optional(),
containedPermissionIds: yupRecord(
yupString().optional().matches(permissionRegex),
yupBoolean().isTrue().optional()
).optional(),
}).optional(),
).optional(),
defaultPermissions: yupObject({
teamCreator: branchRbacDefaultPermissions,
teamMember: branchRbacDefaultPermissions,
signUp: branchRbacDefaultPermissions,
}).optional(),
}).optional();
// --- END NEW RBAC Schema ---
// --- NEW API Keys Schema ---
const branchApiKeysSchema = yupObject({
enabled: yupObject({
team: yupBoolean().optional(),
user: yupBoolean().optional(),
}).optional(),
}).optional();
// --- END NEW API Keys Schema ---
const branchAuthSchema = yupObject({
allowSignUp: yupBoolean().optional(),
password: yupObject({
allowSignIn: yupBoolean().optional(),
}).optional(),
otp: yupObject({
allowSignIn: yupBoolean().optional(),
}).optional(),
passkey: yupObject({
allowSignIn: yupBoolean().optional(),
}).optional(),
oauth: yupObject({
accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).optional(),
providers: yupRecord(
yupString().optional().matches(permissionRegex),
yupObject({
type: yupString().oneOf(allProviders).optional(),
allowSignIn: yupBoolean().optional(),
allowConnectedAccounts: yupBoolean().optional(),
}).defined(),
).optional(),
}).optional(),
}).optional();
const branchDomain = yupObject({
allowLocalhost: yupBoolean().optional(),
}).optional();
export const branchConfigSchema = projectConfigSchema.omit(['sourceOfTruth']).concat(yupObject({
rbac: branchRbacSchema,
teams: yupObject({
createPersonalTeamOnSignUp: yupBoolean().optional(),
allowClientTeamCreation: yupBoolean().optional(),
}).optional(),
users: yupObject({
allowClientUserDeletion: yupBoolean().optional(),
}).optional(),
apiKeys: branchApiKeysSchema,
domains: branchDomain,
auth: branchAuthSchema,
emails: yupObject({
selectedThemeId: schemaFields.emailThemeSchema.optional(),
themes: schemaFields.emailThemeListSchema.optional(),
templates: schemaFields.emailTemplateListSchema.optional(),
}),
}));
export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
auth: branchConfigSchema.getNested("auth").concat(yupObject({
oauth: branchConfigSchema.getNested("auth").getNested("oauth").concat(yupObject({
providers: yupRecord(
yupString().optional().matches(permissionRegex),
yupObject({
type: yupString().oneOf(allProviders).optional(),
isShared: yupBoolean().optional(),
clientId: schemaFields.oauthClientIdSchema.optional(),
clientSecret: schemaFields.oauthClientSecretSchema.optional(),
facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(),
microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(),
allowSignIn: yupBoolean().optional(),
allowConnectedAccounts: yupBoolean().optional(),
}),
).optional(),
}).optional()),
})),
emails: branchConfigSchema.getNested("emails").concat(yupObject({
server: yupObject({
isShared: yupBoolean().optional(),
host: schemaFields.emailHostSchema.optional().nonEmpty(),
port: schemaFields.emailPortSchema.optional(),
username: schemaFields.emailUsernameSchema.optional().nonEmpty(),
password: schemaFields.emailPasswordSchema.optional().nonEmpty(),
senderName: schemaFields.emailSenderNameSchema.optional().nonEmpty(),
senderEmail: schemaFields.emailSenderEmailSchema.optional().nonEmpty(),
}),
}).optional()),
domains: branchConfigSchema.getNested("domains").concat(yupObject({
trustedDomains: yupRecord(
yupString().uuid().optional(),
yupObject({
baseUrl: schemaFields.urlSchema.optional(),
handlerPath: schemaFields.handlerPathSchema.optional(),
}),
).optional(),
})),
}));
export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({}));
// Defaults
// these are objects that are merged together to form the rendered config (see ./README.md)
// Wherever an object could be used as a value, a function can instead be used to generate the default values on a per-key basis
// NOTE: These values are the defaults of the schema, NOT the defaults for newly created projects. The values here signify what `null` means for each property. If you want new projects by default to have a certain value set to true, you should update the corresponding function in the backend instead.
export const projectConfigDefaults = {
sourceOfTruth: {
type: 'hosted',
},
} satisfies DeepReplaceAllowFunctionsForObjects<ProjectConfigStrippedNormalizedOverride>;
export const branchConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects<BranchConfigStrippedNormalizedOverride>;
export const environmentConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects<EnvironmentConfigStrippedNormalizedOverride>;
export const organizationConfigDefaults = {
rbac: {
permissions: (key: string) => ({}),
defaultPermissions: {
teamCreator: {},
teamMember: {},
signUp: {},
},
},
apiKeys: {
enabled: {
team: false,
user: false,
},
},
teams: {
createPersonalTeamOnSignUp: false,
allowClientTeamCreation: false,
},
users: {
allowClientUserDeletion: false,
},
domains: {
allowLocalhost: false,
trustedDomains: (key: string) => ({
handlerPath: '/handler',
}),
},
auth: {
allowSignUp: true,
password: {
allowSignIn: false,
},
otp: {
allowSignIn: false,
},
passkey: {
allowSignIn: false,
},
oauth: {
accountMergeStrategy: 'link_method',
providers: (key: string) => ({
isShared: true,
allowSignIn: false,
allowConnectedAccounts: false,
}),
},
},
emails: {
server: {
isShared: true,
},
selectedThemeId: DEFAULT_EMAIL_THEME_ID,
themes: DEFAULT_EMAIL_THEMES,
templates: DEFAULT_EMAIL_TEMPLATES,
},
} satisfies DeepReplaceAllowFunctionsForObjects<OrganizationConfigStrippedNormalizedOverride>;
export type DeepReplaceAllowFunctionsForObjects<T> = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects<T[K]> } | ((arg: keyof T) => DeepReplaceAllowFunctionsForObjects<T[keyof T]>) : T;
export type DeepReplaceFunctionsWithObjects<T> = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects<Record<K, R>> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects<T[K]> } : T);
export type ApplyDefaults<D extends object | ((key: string) => unknown), C extends object> = DeepMerge<DeepReplaceFunctionsWithObjects<D>, C>;
export function applyDefaults<D extends object | ((key: string) => unknown), C extends object>(defaults: D, config: C): ApplyDefaults<D, C> {
const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' ? applyDefaults(v as any, {}) : v));
for (const [key, mergeValue] of Object.entries(config)) {
const baseValue = typeof defaults === 'function' ? defaults(key) : (has(defaults, key as any) ? get(defaults, key as any) : undefined);
if (baseValue !== undefined) {
if (isObjectLike(baseValue) && isObjectLike(mergeValue)) {
set(res, key, applyDefaults(baseValue, mergeValue));
continue;
}
}
set(res, key, mergeValue);
}
return res as any;
}
import.meta.vitest?.test("applyDefaults", ({ expect }) => {
expect(applyDefaults({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
expect(applyDefaults({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } });
expect(applyDefaults((key: string) => ({ b: key }), { a: {} })).toEqual({ a: { b: "a" } });
expect(applyDefaults({ a: (key: string) => ({ b: key }) }, { a: { c: { d: 1 } } })).toEqual({ a: { c: { b: "c", d: 1 } } });
expect(applyDefaults({ a: (key: string) => ({ b: key }) }, {})).toEqual({ a: {} });
expect(applyDefaults({ a: { b: (key: string) => ({ b: key }) } }, {})).toEqual({ a: { b: {} } });
});
// Normalized overrides
export type ProjectConfigNormalizedOverride = yup.InferType<typeof projectConfigSchema>;
export type BranchConfigNormalizedOverride = yup.InferType<typeof branchConfigSchema>;
export type EnvironmentConfigNormalizedOverride = yup.InferType<typeof environmentConfigSchema>;
export type OrganizationConfigNormalizedOverride = yup.InferType<typeof organizationConfigSchema>;
// Normalized overrides, but only the fields that will NOT be overridden by a future level anymore
export type ProjectConfigStrippedNormalizedOverride = Omit<ProjectConfigNormalizedOverride,
| keyof BranchConfigNormalizedOverride
| keyof EnvironmentConfigNormalizedOverride
| keyof OrganizationConfigNormalizedOverride
>;
export type BranchConfigStrippedNormalizedOverride = Omit<BranchConfigNormalizedOverride,
| keyof EnvironmentConfigNormalizedOverride
| keyof OrganizationConfigNormalizedOverride
>;
export type EnvironmentConfigStrippedNormalizedOverride = Omit<EnvironmentConfigNormalizedOverride,
| keyof OrganizationConfigNormalizedOverride
>;
export type OrganizationConfigStrippedNormalizedOverride = OrganizationConfigNormalizedOverride;
// Overrides
export type ProjectConfigOverride = NormalizesTo<ProjectConfigNormalizedOverride>;
export type BranchConfigOverride = NormalizesTo<BranchConfigNormalizedOverride>;
export type EnvironmentConfigOverride = NormalizesTo<EnvironmentConfigNormalizedOverride>;
export type OrganizationConfigOverride = NormalizesTo<OrganizationConfigNormalizedOverride>;
// Override overrides (used to update the overrides)
export type ProjectConfigOverrideOverride = Config & DeepPartial<ProjectConfigOverride>;
export type BranchConfigOverrideOverride = Config & DeepPartial<BranchConfigOverride>;
export type EnvironmentConfigOverrideOverride = Config & DeepPartial<EnvironmentConfigOverride>;
export type OrganizationConfigOverrideOverride = Config & DeepPartial<OrganizationConfigOverride>;
// Incomplete configs
export type ProjectIncompleteConfig = ApplyDefaults<typeof projectConfigDefaults, ProjectConfigNormalizedOverride>;
export type BranchIncompleteConfig = ApplyDefaults<typeof branchConfigDefaults, ProjectIncompleteConfig & BranchConfigNormalizedOverride>;
export type EnvironmentIncompleteConfig = ApplyDefaults<typeof environmentConfigDefaults, BranchIncompleteConfig & EnvironmentConfigNormalizedOverride>;
export type OrganizationIncompleteConfig = ApplyDefaults<typeof organizationConfigDefaults, EnvironmentIncompleteConfig & OrganizationConfigNormalizedOverride>;
// Rendered configs
export type ProjectRenderedConfig = Omit<ProjectIncompleteConfig,
| keyof BranchConfigNormalizedOverride
| keyof EnvironmentConfigNormalizedOverride
| keyof OrganizationConfigNormalizedOverride
>;
export type BranchRenderedConfig = Omit<BranchIncompleteConfig,
| keyof EnvironmentConfigNormalizedOverride
| keyof OrganizationConfigNormalizedOverride
>;
export type EnvironmentRenderedConfig = Omit<EnvironmentIncompleteConfig,
| keyof OrganizationConfigNormalizedOverride
>;
export type OrganizationRenderedConfig = OrganizationIncompleteConfig;