chore: standardize security and quality foundations
This commit is contained in:
@@ -1,64 +1,105 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
||||
type Option = { value: string; label: string }
|
||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean };
|
||||
type Option = { value: string; label: string };
|
||||
type Props = {
|
||||
setting: AdminSetting
|
||||
label: string
|
||||
value: string
|
||||
help?: string
|
||||
placeholder?: string
|
||||
boolean?: boolean
|
||||
numeric?: boolean
|
||||
multiline?: boolean
|
||||
options?: Option[]
|
||||
optionsUnavailable?: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
setting: AdminSetting;
|
||||
label: string;
|
||||
value: string;
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
boolean?: boolean;
|
||||
numeric?: boolean;
|
||||
multiline?: boolean;
|
||||
options?: Option[];
|
||||
optionsUnavailable?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const SELECTS: Record<string, Option[]> = {
|
||||
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
|
||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
|
||||
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
|
||||
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
|
||||
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
|
||||
}
|
||||
log_level: ["DEBUG", "INFO", "WARNING", "ERROR"].map((value) => ({ value, label: value })),
|
||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({
|
||||
value: String(index),
|
||||
label: index === 0 ? "None — close when fixed" : String(index),
|
||||
})),
|
||||
issue_confirmation_interval_unit: ["days", "weeks", "months"].map((value) => ({
|
||||
value,
|
||||
label: value[0].toUpperCase() + value.slice(1),
|
||||
})),
|
||||
artwork_cache_mode: [
|
||||
{ value: "remote", label: "Load from the internet" },
|
||||
{ value: "cache", label: "Store locally" },
|
||||
],
|
||||
site_banner_tone: ["info", "warning", "error", "maintenance"].map((value) => ({
|
||||
value,
|
||||
label: value[0].toUpperCase() + value.slice(1),
|
||||
})),
|
||||
magent_notify_push_provider: ["ntfy", "gotify", "pushover", "webhook", "telegram", "discord"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
})),
|
||||
requests_data_source: [
|
||||
{ value: "always_js", label: "Read directly from Seerr" },
|
||||
{ value: "prefer_cache", label: "Use saved requests" },
|
||||
],
|
||||
};
|
||||
|
||||
const COLOR_DEFAULTS: Record<string, string> = {
|
||||
site_banner_background_color: '#332814',
|
||||
site_banner_border_color: '#a27b32',
|
||||
}
|
||||
site_banner_background_color: "#332814",
|
||||
site_banner_border_color: "#a27b32",
|
||||
};
|
||||
|
||||
export default function SettingField(props: Props) {
|
||||
const { setting, label, value, help, placeholder, onChange } = props
|
||||
const id = `setting-${setting.key}`
|
||||
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
|
||||
const selectedOptions = options && value && !options.some((option) => option.value === value)
|
||||
? [{ value, label: `Current selection (${value})` }, ...options] : options
|
||||
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
|
||||
const zeroAllowed = setting.key === 'log_file_backup_count'
|
||||
const minimum = zeroAllowed ? 0 : 1
|
||||
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
|
||||
const colorDefault = COLOR_DEFAULTS[setting.key]
|
||||
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault
|
||||
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
|
||||
const { setting, label, value, help, placeholder, onChange } = props;
|
||||
const id = `setting-${setting.key}`;
|
||||
const options =
|
||||
props.options ??
|
||||
SELECTS[setting.key] ??
|
||||
(setting.key === "log_http_client_level" || setting.key === "log_background_sync_level"
|
||||
? SELECTS.log_level
|
||||
: undefined);
|
||||
const selectedOptions =
|
||||
options && value && !options.some((option) => option.value === value)
|
||||
? [{ value, label: `Current selection (${value})` }, ...options]
|
||||
: options;
|
||||
const isTime = setting.key === "requests_full_sync_time" || setting.key === "requests_cleanup_time";
|
||||
const zeroAllowed = setting.key === "log_file_backup_count";
|
||||
const minimum = zeroAllowed ? 0 : 1;
|
||||
const maximum =
|
||||
setting.key === "issue_confirmation_interval_value" ? 365 : setting.key.endsWith("_port") ? 65535 : undefined;
|
||||
const colorDefault = COLOR_DEFAULTS[setting.key];
|
||||
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault;
|
||||
const aria = { id, name: setting.key, "aria-describedby": help ? `${id}-help` : undefined };
|
||||
|
||||
if (props.boolean) {
|
||||
return (
|
||||
<div className="setting-field setting-switch">
|
||||
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
|
||||
<input {...aria} type="checkbox" role="switch" aria-checked={value.toLowerCase() === 'true'} checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{help && <p id={`${id}-help`}>{help}</p>}
|
||||
</div>
|
||||
<input
|
||||
{...aria}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={value.toLowerCase() === "true"}
|
||||
checked={value.toLowerCase() === "true"}
|
||||
onChange={(event) => onChange(String(event.target.checked))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
|
||||
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
|
||||
<div className={`setting-field ${props.multiline ? "field-span-full" : ""}`}>
|
||||
<label htmlFor={id}>
|
||||
{label}
|
||||
{setting.sensitive && setting.isSet && <small>Saved</small>}
|
||||
</label>
|
||||
{props.optionsUnavailable ? (
|
||||
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
|
||||
<select {...aria} disabled value={value}>
|
||||
<option value={value}>Save the connection, then reload available options</option>
|
||||
</select>
|
||||
) : colorDefault ? (
|
||||
<div className="setting-color-control">
|
||||
<input
|
||||
@@ -78,23 +119,44 @@ export default function SettingField(props: Props) {
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{value ? <button type="button" className="ghost-button" onClick={() => onChange('')}>Use tone default</button> : null}
|
||||
{value ? (
|
||||
<button type="button" className="ghost-button" onClick={() => onChange("")}>
|
||||
Use tone default
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : selectedOptions ? (
|
||||
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{!value && <option value="">Choose an option</option>}
|
||||
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
{selectedOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : props.multiline ? (
|
||||
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
|
||||
<textarea
|
||||
{...aria}
|
||||
rows={setting.key.includes("_pem") ? 6 : 3}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
|
||||
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
|
||||
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
|
||||
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
<input
|
||||
{...aria}
|
||||
type={setting.sensitive ? "password" : props.numeric ? "number" : isTime ? "time" : "text"}
|
||||
value={value}
|
||||
min={props.numeric ? minimum : undefined}
|
||||
max={props.numeric ? maximum : undefined}
|
||||
step={props.numeric ? 1 : undefined}
|
||||
autoComplete={setting.sensitive ? "new-password" : "off"}
|
||||
spellCheck={false}
|
||||
placeholder={setting.sensitive && setting.isSet ? "Leave blank to keep the saved value" : placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{help && <p id={`${id}-help`}>{help}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user