Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dist/
index-legacy.html

# local env files
.env
.env.local
.env.*.local

Expand Down
20 changes: 10 additions & 10 deletions src/i18n/locales/en-US/systemSetting.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
{
"title": "System Settings",
"subtitle": "Login, registration policy, and email service configuration",
"subtitle": "System policies for login, registration, space, sidebar, and the email service",
"action.testEmail": "Test Email",
"action.save": "Save Settings",
"action.followDefault": "Reset to default",
"savedAt": "Saved · {{time}}",
"countItems": "{{count}} items",
"badge.db": "DB",
"badge.default": "Default",
"tooltip.saveBeforeTest": "Please save the settings before sending a test email",
"category.login": "Login Settings",
"category.register": "Registration Settings",
"category.space": "Space Settings",
"category.sidebar": "Sidebar Settings",
"category.support": "Email Service",
"category.generic": "{{category}} Settings",
"bool.yes": "Yes",
"bool.no": "No",
"bool.unset": "Not configured",
"source.db": "DB config",
"source.default": "Default config",
"input.boolDefault": "Follow default config",
"input.boolDefaultWithCurrent": "Follow default config (current: {{value}})",
"input.followDefault": "Follow default config",
"input.followDefaultWithCurrent": "Follow default config (current: {{value}})",
"input.encryptedKeep": "Leave blank to keep the current value",
"input.encryptedDefault": "Leave blank to follow the default config",
"extra.effectiveEncrypted": "{{identity}}. Currently effective: configured ({{source}})",
"extra.effectiveValue": "{{identity}}. Currently effective: {{value}} ({{source}})",
"toast.fetchFailed": "Failed to load system settings: {{message}}",
"toast.saved": "Settings saved",
"toast.saveFailed": "Failed to save: {{message}}",
Expand Down
20 changes: 10 additions & 10 deletions src/i18n/locales/zh-CN/systemSetting.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
{
"title": "系统配置",
"subtitle": "登录、注册策略与邮件服务配置",
"subtitle": "登录、注册、空间、侧边栏与邮件服务等系统策略配置",
"action.testEmail": "测试邮件",
"action.save": "保存配置",
"action.followDefault": "恢复默认",
"savedAt": "已保存 · {{time}}",
"countItems": "{{count}} 项",
"badge.db": "DB",
"badge.default": "默认",
"tooltip.saveBeforeTest": "请先保存配置后再发送测试邮件",
"category.login": "登录配置",
"category.register": "注册配置",
"category.space": "空间配置",
"category.sidebar": "侧边栏配置",
"category.support": "邮件服务",
"category.generic": "{{category}} 配置",
"bool.yes": "是",
"bool.no": "否",
"bool.unset": "未配置",
"source.db": "DB 配置",
"source.default": "默认配置",
"input.boolDefault": "跟随默认配置",
"input.boolDefaultWithCurrent": "跟随默认配置(当前:{{value}})",
"input.followDefault": "跟随默认配置",
"input.followDefaultWithCurrent": "跟随默认配置(当前:{{value}})",
"input.encryptedKeep": "留空保留现有值",
"input.encryptedDefault": "留空跟随默认配置",
"extra.effectiveEncrypted": "{{identity}}。当前生效:已配置({{source}})",
"extra.effectiveValue": "{{identity}}。当前生效:{{value}}({{source}})",
"toast.fetchFailed": "获取系统配置失败: {{message}}",
"toast.saved": "配置已保存",
"toast.saveFailed": "保存失败: {{message}}",
Expand Down
131 changes: 131 additions & 0 deletions src/pages/SystemSetting/SettingRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { TFunction } from 'i18next'
import { Button, Form, Input, InputNumber, Switch } from 'antd'
import type { SystemSettingItem } from '../../api/system-setting'
import {
normaliseBoolValue,
settingFormName,
settingLabel,
type BoolFormValue,
} from './helpers'

interface BoolSwitchProps {
item: SystemSettingItem
t: TFunction
// value / onChange are injected by the wrapping Form.Item
value?: BoolFormValue
onChange?: (value: BoolFormValue) => void
}

/**
* Tri-state boolean control. The Switch toggles yes/no; while no explicit
* value is set (form value is an empty string) the field follows the default.
* Once toggled explicitly, a "reset to default" link appears so the value can
* be cleared back to the empty string and keep following the default.
*/
function BoolSwitch({ item, t, value, onChange }: BoolSwitchProps) {
const isDefault = !value
const effectiveOn = normaliseBoolValue(item.effective_value) === '1'
const checked = isDefault ? effectiveOn : value === '1'

return (
<div className="setting-switch">
{!isDefault && (
<Button
type="link"
size="small"
className="setting-switch-reset"
onClick={() => onChange?.('')}
>
{t('action.followDefault')}
</Button>
)}
<Switch
// Muted while following the default, so it reads differently from an
// explicitly-set value at a glance.
className={isDefault ? 'setting-switch-toggle setting-switch-toggle--default' : 'setting-switch-toggle'}
checked={checked}
aria-label={settingLabel(item)}
onChange={(next) => onChange?.(next ? '1' : '0')}
/>
</div>
)
}

function renderControl(item: SystemSettingItem, t: TFunction) {
const name = settingFormName(item.category, item.key)
const ariaLabel = settingLabel(item)

if (item.value_type === 'bool') {
return (
<Form.Item name={name} noStyle>
<BoolSwitch item={item} t={t} />
</Form.Item>
)
}

if (item.value_type === 'encrypted') {
return (
<Form.Item name={name} noStyle>
<Input.Password
allowClear
autoComplete="new-password"
aria-label={ariaLabel}
placeholder={item.configured ? t('input.encryptedKeep') : t('input.encryptedDefault')}
/>
</Form.Item>
)
}

// Show the effective default as a placeholder only when the field is unset.
const placeholder = item.configured
? undefined
: item.effective_value
? t('input.followDefaultWithCurrent', { value: item.effective_value })
: t('input.followDefault')

if (item.value_type === 'int') {
return (
<Form.Item name={name} noStyle>
<InputNumber
className="setting-number"
controls
precision={0}
aria-label={ariaLabel}
placeholder={placeholder}
/>
</Form.Item>
)
}

return (
<Form.Item name={name} noStyle>
<Input allowClear aria-label={ariaLabel} placeholder={placeholder} />
</Form.Item>
)
}

interface SettingRowProps {
item: SystemSettingItem
t: TFunction
}

export default function SettingRow({ item, t }: SettingRowProps) {
// bool / int controls are compact and sit inline on the right; string /
// encrypted are wider, so the label stays on top and the control spans the row.
const inline = item.value_type === 'bool' || item.value_type === 'int'
const badgeKey = item.configured ? 'badge.db' : 'badge.default'
const badgeClass = item.configured ? 'setting-badge--db' : 'setting-badge--default'

return (
<div className={`setting-row ${inline ? 'setting-row--inline' : 'setting-row--block'}`}>
<div className="setting-row-text">
<div className="setting-row-head">
<span className="setting-row-label">{settingLabel(item)}</span>
<span className={`setting-badge ${badgeClass}`}>{t(badgeKey)}</span>
</div>
<code className="setting-row-key">{settingFormName(item.category, item.key)}</code>
</div>
<div className="setting-row-control">{renderControl(item, t)}</div>
</div>
)
}
116 changes: 116 additions & 0 deletions src/pages/SystemSetting/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { SECRET_MASK, type SystemSettingItem } from '../../api/system-setting'
import {
settingFormName,
valuesFromSettings,
valuesToPayload,
type SystemSettingFormValues,
} from './helpers'

function makeItem(partial: Partial<SystemSettingItem> & Pick<SystemSettingItem, 'category' | 'key' | 'value_type'>): SystemSettingItem {
return {
configured: false,
value: '',
effective_value: '',
description: '',
...partial,
}
}

const name = (item: SystemSettingItem) => settingFormName(item.category, item.key)

describe('valuesFromSettings', () => {
it('normalises bool values and never derives a value from effective_value', () => {
const configuredOn = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1', effective_value: '1' })
const followDefault = makeItem({ category: 'register', key: 'only_china', value_type: 'bool', configured: false, value: '', effective_value: '0' })
const legacyTrue = makeItem({ category: 'register', key: 'username_on', value_type: 'bool', configured: true, value: 'true', effective_value: '1' })

const values = valuesFromSettings([configuredOn, followDefault, legacyTrue])

expect(values[name(configuredOn)]).toBe('1')
// Not configured stays empty (follow default) — viewing must not pre-fill from effective_value.
expect(values[name(followDefault)]).toBe('')
expect(values[name(legacyTrue)]).toBe('1')
})

it('always blanks encrypted fields so the mask is never echoed back', () => {
const secret = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK, effective_value: SECRET_MASK })
const values = valuesFromSettings([secret])
expect(values[name(secret)]).toBe('')
})

it('keeps int/string values verbatim', () => {
const intItem = makeItem({ category: 'sidebar', key: 'recent_filter_group_days', value_type: 'int', configured: true, value: '3', effective_value: '3' })
const strItem = makeItem({ category: 'support', key: 'email', value_type: 'string', configured: true, value: 'a@b.com', effective_value: 'a@b.com' })
const values = valuesFromSettings([intItem, strItem])
expect(values[name(intItem)]).toBe('3')
expect(values[name(strItem)]).toBe('a@b.com')
})
})

describe('valuesToPayload', () => {
it('preserves the bool tri-state: follow-default vs explicit yes/no', () => {
const followDefault = makeItem({ category: 'register', key: 'only_china', value_type: 'bool', configured: false, effective_value: '0' })
const explicitOn = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1' })
const explicitOff = makeItem({ category: 'login', key: 'local_off', value_type: 'bool', configured: true, value: '0' })
const items = [followDefault, explicitOn, explicitOff]

const values: SystemSettingFormValues = {
[name(followDefault)]: '',
[name(explicitOn)]: '1',
[name(explicitOff)]: '0',
}

const payload = valuesToPayload(values, items)
expect(payload.find((p) => p.key === 'only_china')?.value).toBe('')
expect(payload.find((p) => p.key === 'off')?.value).toBe('1')
expect(payload.find((p) => p.key === 'local_off')?.value).toBe('0')
})

it('keeps the current encrypted value when left blank, but follows default when never configured', () => {
const keep = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK })
const neverSet = makeItem({ category: 'support', key: 'email_pwd2', value_type: 'encrypted', configured: false })
const items = [keep, neverSet]

const payload = valuesToPayload({ [name(keep)]: '', [name(neverSet)]: '' }, items)
expect(payload.find((p) => p.key === 'email_pwd')?.value).toBe(SECRET_MASK)
expect(payload.find((p) => p.key === 'email_pwd2')?.value).toBe('')
})

it('writes a new encrypted value when provided', () => {
const secret = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK })
const payload = valuesToPayload({ [name(secret)]: 'new-secret' }, [secret])
expect(payload[0].value).toBe('new-secret')
})

it('stringifies int values and treats empty as follow-default', () => {
const intItem = makeItem({ category: 'sidebar', key: 'recent_filter_group_days', value_type: 'int' })
const set = valuesToPayload({ [name(intItem)]: 5 }, [intItem])
expect(set[0].value).toBe('5')
const cleared = valuesToPayload({ [name(intItem)]: '' }, [intItem])
expect(cleared[0].value).toBe('')
})
})

describe('bool round-trip (load → toggle → reset)', () => {
const item = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1', effective_value: '1' })
const field = name(item)

it('round-trips an explicit value through load and save', () => {
const loaded = valuesFromSettings([item])
expect(loaded[field]).toBe('1')
expect(valuesToPayload(loaded, [item])[0].value).toBe('1')
})

it('a toggle (onChange) produces the opposite explicit value', () => {
// BoolSwitch.onChange(next ? '1' : '0') — toggling off
const toggledOff: SystemSettingFormValues = { [field]: '0' }
expect(valuesToPayload(toggledOff, [item])[0].value).toBe('0')
})

it('reset-to-default clears the override back to follow-default', () => {
// BoolSwitch reset link calls onChange('')
const reset: SystemSettingFormValues = { [field]: '' }
expect(valuesToPayload(reset, [item])[0].value).toBe('')
})
})
69 changes: 69 additions & 0 deletions src/pages/SystemSetting/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { TFunction } from 'i18next'
import {
SECRET_MASK,
type SystemSettingItem,
type SystemSettingUpdateItem,
} from '../../api/system-setting'

export type BoolFormValue = '' | '0' | '1'
export type SettingFormValue = string | number | null | undefined
export type SystemSettingFormValues = Record<string, SettingFormValue>

export const settingMapKey = (category: string, key: string) => `${category}.${key}`
export const settingFormName = (category: string, key: string) => settingMapKey(category, key)

const categoryTitleKeys: Record<string, string> = {
login: 'category.login',
register: 'category.register',
space: 'category.space',
sidebar: 'category.sidebar',
support: 'category.support',
}

export function normaliseBoolValue(value: string): BoolFormValue {
if (value === '1' || value === 'true' || value === 'TRUE') return '1'
if (value === '0' || value === 'false' || value === 'FALSE') return '0'
return ''
}

export function formValueToString(value: SettingFormValue) {
if (value === null || value === undefined) return ''
return String(value)
}

export function valuesToPayload(
values: SystemSettingFormValues,
items: SystemSettingItem[],
): SystemSettingUpdateItem[] {
return items.map((item) => {
const value = formValueToString(values[settingFormName(item.category, item.key)])
const keepEncryptedValue =
item.value_type === 'encrypted' && !value && (item.configured || item.value === SECRET_MASK)

return {
category: item.category,
key: item.key,
value: keepEncryptedValue ? SECRET_MASK : value,
}
})
}

export function valuesFromSettings(items: SystemSettingItem[]): SystemSettingFormValues {
return items.reduce<SystemSettingFormValues>((values, item) => {
const fieldName = settingFormName(item.category, item.key)
values[fieldName] = item.value_type === 'encrypted' ? '' : item.value ?? ''
if (item.value_type === 'bool') {
values[fieldName] = normaliseBoolValue(formValueToString(values[fieldName]))
}
return values
}, {})
}

export function categoryTitle(t: TFunction, category: string) {
const titleKey = categoryTitleKeys[category]
return titleKey ? t(titleKey) : t('category.generic', { category })
}

export function settingLabel(item: SystemSettingItem) {
return item.description || item.key
}
Loading
Loading