feat: enhance channel configuration UI and validation
- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance. - Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons. - Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience. - Added new messages for documentation links, validation feedback, and secret management in i18n. - Extended ChannelMeta to include optional documentation URLs for better context on configuration fields. - Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving. - Introduced ChannelLogo component to display channel icons in the UI. - Added tests for channel credential validation to ensure proper error handling and feedback.
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
listSelectedChannelAccountGroups,
|
||||
listSelectedChannelTargets,
|
||||
} from '../../utils/channels';
|
||||
import { getChannelMeta } from '@src/lib/channel-meta';
|
||||
|
||||
function getProviderSnapshot(ctx: HostApiContext) {
|
||||
const accounts = ctx.providerApiService
|
||||
@@ -30,6 +31,120 @@ function getProviderSnapshot(ctx: HostApiContext) {
|
||||
return { accounts, defaultAccountId };
|
||||
}
|
||||
|
||||
const VALIDATION_RESERVED_KEYS = new Set([
|
||||
'channelType',
|
||||
'accountId',
|
||||
'credentials',
|
||||
'values',
|
||||
'config',
|
||||
'fields',
|
||||
'success',
|
||||
'valid',
|
||||
'errors',
|
||||
'warnings',
|
||||
'details',
|
||||
]);
|
||||
|
||||
interface CredentialFieldValidationResult {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
required: boolean;
|
||||
provided: boolean;
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function collectValidationValues(body: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {};
|
||||
const sources = [
|
||||
isPlainRecord(body?.credentials) ? body?.credentials : null,
|
||||
isPlainRecord(body?.values) ? body?.values : null,
|
||||
isPlainRecord(body?.config) ? body?.config : null,
|
||||
isPlainRecord(body?.fields) ? body?.fields : null,
|
||||
body,
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
if (!isPlainRecord(source)) continue;
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (VALIDATION_RESERVED_KEYS.has(key)) continue;
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function normalizeCredentialValue(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function validateUrlField(label: string, value: string, result: CredentialFieldValidationResult): void {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol === 'http:') {
|
||||
result.warnings.push(`${label} 使用了不安全的 http URL,建议改用 https`);
|
||||
}
|
||||
} catch {
|
||||
result.errors.push(`${label} 必须是有效的 URL`);
|
||||
result.valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredentialField(
|
||||
field: { key: string; label: string; kind: string; required?: boolean },
|
||||
rawValue: unknown,
|
||||
): CredentialFieldValidationResult {
|
||||
const value = normalizeCredentialValue(rawValue);
|
||||
const result: CredentialFieldValidationResult = {
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
kind: field.kind,
|
||||
required: field.required === true,
|
||||
provided: value.length > 0,
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
if (field.required && !value) {
|
||||
result.errors.push(`${field.label} is required`);
|
||||
result.valid = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (field.kind === 'url') {
|
||||
validateUrlField(field.label, value, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (field.kind === 'token' || field.kind === 'password') {
|
||||
if (/\s/.test(value)) {
|
||||
result.errors.push(`${field.label} 不能包含空格`);
|
||||
result.valid = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (field.kind === 'text' || field.kind === 'textarea') {
|
||||
if (/[\r\n]/.test(value)) {
|
||||
result.warnings.push(`${field.label} 包含换行符,确认是否符合目标渠道格式`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function handleChannelRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
ctx: HostApiContext,
|
||||
@@ -67,6 +182,39 @@ export async function handleChannelRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/channels/credentials/validate' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<Record<string, unknown>>(request.body);
|
||||
const channelType = String(body?.channelType ?? '').trim();
|
||||
const accountId = String(body?.accountId ?? '').trim() || undefined;
|
||||
const meta = getChannelMeta(channelType);
|
||||
const validationValues = collectValidationValues(body);
|
||||
const fieldResults = meta.configFields.map((field) => validateCredentialField(field, validationValues[field.key]));
|
||||
const errors = fieldResults.flatMap((field) => field.errors);
|
||||
const warnings = fieldResults.flatMap((field) => field.warnings);
|
||||
|
||||
if (!channelType) {
|
||||
errors.unshift('channelType is required');
|
||||
}
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
details: {
|
||||
channelType,
|
||||
accountId,
|
||||
channelName: meta.name,
|
||||
connectionType: meta.connectionType,
|
||||
fields: fieldResults,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/channels/default-account' && method === 'PUT') {
|
||||
try {
|
||||
const body = parseJsonBody<{
|
||||
|
||||
Reference in New Issue
Block a user