160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
export type ThemeFieldType = 'text' | 'textarea' | 'url' | 'number' | 'select' | 'color' | 'image'
|
|
|
|
export type ThemeFieldSchema = {
|
|
type: ThemeFieldType
|
|
name: string
|
|
label: string
|
|
required?: boolean
|
|
options?: string[]
|
|
}
|
|
|
|
export type ThemeSectionSchema = {
|
|
id: string
|
|
title: string
|
|
fields: ThemeFieldSchema[]
|
|
}
|
|
|
|
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
|
|
|
|
const UNSAFE_TOKENS = ['<script', '</script', 'javascript:', 'onerror=', 'onload=', '<iframe', 'srcdoc=', 'data:text/html', 'vbscript:']
|
|
|
|
export const DEFAULT_THEME_FORM_SCHEMA: ThemeSectionSchema[] = [
|
|
{
|
|
id: 'brand',
|
|
title: 'Brand',
|
|
fields: [
|
|
{ type: 'image', name: 'logoAssetId', label: 'Logo' },
|
|
{ type: 'image', name: 'faviconAssetId', label: 'Favicon' },
|
|
{ type: 'text', name: 'logoAssetMimeType', label: 'Logo MIME type' },
|
|
{ type: 'text', name: 'faviconAssetMimeType', label: 'Favicon MIME type' },
|
|
{ type: 'color', name: 'primaryColor', label: 'Primary colour', required: true },
|
|
{ type: 'color', name: 'secondaryColor', label: 'Secondary colour' },
|
|
{ type: 'color', name: 'backgroundColor', label: 'Background colour' },
|
|
{ type: 'color', name: 'textColor', label: 'Text colour' },
|
|
{ type: 'select', name: 'fontFamily', label: 'Font family', options: ['Inter', 'System UI', 'Roboto', 'Open Sans', 'Lato', 'Montserrat'] },
|
|
{ type: 'number', name: 'buttonRadius', label: 'Button radius' },
|
|
],
|
|
},
|
|
{
|
|
id: 'authCopy',
|
|
title: 'Authentication copy',
|
|
fields: [
|
|
{ type: 'text', name: 'loginHeading', label: 'Login heading' },
|
|
{ type: 'textarea', name: 'loginSubheading', label: 'Login subheading' },
|
|
{ type: 'textarea', name: 'loginHelpText', label: 'Login help text' },
|
|
{ type: 'text', name: 'registrationHeading', label: 'Registration heading' },
|
|
{ type: 'textarea', name: 'forgotPasswordHelpText', label: 'Forgot password help' },
|
|
],
|
|
},
|
|
{
|
|
id: 'accountAndEmail',
|
|
title: 'Account and email templates',
|
|
fields: [
|
|
{ type: 'text', name: 'accountConsoleHeading', label: 'Account console heading' },
|
|
{ type: 'textarea', name: 'emailTemplateFooter', label: 'Email footer text' },
|
|
],
|
|
},
|
|
{
|
|
id: 'linksAndLegal',
|
|
title: 'Links and legal',
|
|
fields: [
|
|
{ type: 'url', name: 'supportUrl', label: 'Support URL' },
|
|
{ type: 'url', name: 'privacyUrl', label: 'Privacy URL' },
|
|
{ type: 'url', name: 'termsUrl', label: 'Terms URL' },
|
|
{ type: 'textarea', name: 'footerText', label: 'Footer text' },
|
|
{ type: 'textarea', name: 'securityText', label: 'Security text' },
|
|
],
|
|
},
|
|
]
|
|
|
|
export const DEFAULT_THEME_FORM_VALUES: Record<string, string | number | boolean> = {
|
|
primaryColor: '#4f46e5',
|
|
backgroundColor: '#ffffff',
|
|
textColor: '#0f172a',
|
|
fontFamily: 'Inter',
|
|
buttonRadius: 8,
|
|
loginHeading: 'Welcome back',
|
|
registrationHeading: 'Create your account',
|
|
footerText: 'Powered by CMS Platform',
|
|
securityText: 'Never share your password with anyone.',
|
|
}
|
|
|
|
export type ThemeFormValidationResult = {
|
|
valid: boolean
|
|
errors: Record<string, string>
|
|
}
|
|
|
|
function hasUnsafeToken(value: string): boolean {
|
|
const normalized = value.toLowerCase()
|
|
return UNSAFE_TOKENS.some(token => normalized.includes(token))
|
|
}
|
|
|
|
function isValidUrl(value: string): boolean {
|
|
try {
|
|
const parsed = new URL(value)
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function validateThemeFormValues(
|
|
schema: ThemeSectionSchema[],
|
|
values: Record<string, unknown>,
|
|
): ThemeFormValidationResult {
|
|
const errors: Record<string, string> = {}
|
|
|
|
for (const section of schema) {
|
|
for (const field of section.fields) {
|
|
const value = values[field.name]
|
|
const textValue = typeof value === 'string' ? value.trim() : ''
|
|
|
|
if (field.required && (value === undefined || value === null || textValue === '')) {
|
|
errors[field.name] = `${field.label} is required`
|
|
continue
|
|
}
|
|
|
|
if (value === undefined || value === null || textValue === '') {
|
|
continue
|
|
}
|
|
|
|
if (typeof value === 'string' && hasUnsafeToken(value)) {
|
|
errors[field.name] = `${field.label} contains unsafe content`
|
|
continue
|
|
}
|
|
|
|
if (field.type === 'color' && typeof value === 'string' && !HEX_COLOR_PATTERN.test(value.trim())) {
|
|
errors[field.name] = `${field.label} must be a hex color`
|
|
continue
|
|
}
|
|
|
|
if (field.type === 'url' && typeof value === 'string' && !isValidUrl(value.trim())) {
|
|
errors[field.name] = `${field.label} must be a valid URL`
|
|
continue
|
|
}
|
|
|
|
if (field.type === 'number') {
|
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
if (Number.isNaN(numeric)) {
|
|
errors[field.name] = `${field.label} must be a number`
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (field.type === 'select' && field.options && !field.options.includes(String(value).trim())) {
|
|
errors[field.name] = `${field.label} has an unsupported value`
|
|
continue
|
|
}
|
|
|
|
if (field.type === 'image' && typeof value === 'string' && value.includes(' ')) {
|
|
errors[field.name] = `${field.label} must not contain spaces`
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: Object.keys(errors).length === 0,
|
|
errors,
|
|
}
|
|
}
|