- 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.
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
|
|
import { useI18n } from '../../i18n';
|
|
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
|
import { getChannelMeta, type ChannelMeta } from '../../lib/channel-meta';
|
|
import ChannelConfigActions from './ChannelConfigActions';
|
|
import ChannelConfigFields from './ChannelConfigFields';
|
|
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
|
|
|
|
export type ChannelFieldValidationResult = {
|
|
key: string;
|
|
label: string;
|
|
kind: string;
|
|
required: boolean;
|
|
provided: boolean;
|
|
valid: boolean;
|
|
errors: string[];
|
|
warnings: string[];
|
|
};
|
|
|
|
export type ChannelCredentialsValidationResult = {
|
|
valid: boolean;
|
|
errors: string[];
|
|
warnings: string[];
|
|
details?: {
|
|
channelType?: string;
|
|
accountId?: string;
|
|
channelName?: string;
|
|
connectionType?: string;
|
|
fields?: ChannelFieldValidationResult[];
|
|
[key: string]: unknown;
|
|
};
|
|
};
|
|
|
|
export type ChannelConfigModalProps = {
|
|
open: boolean;
|
|
selectedChannelType: string;
|
|
values: ChannelConfigFieldValueMap;
|
|
onValueChange: (key: string, value: string) => void;
|
|
onClose: () => void;
|
|
onConfirm: () => void | Promise<void>;
|
|
onValidate?: () => void | Promise<void>;
|
|
error?: string | null;
|
|
submitting?: boolean;
|
|
validating?: boolean;
|
|
validationResult?: ChannelCredentialsValidationResult | null;
|
|
title?: string;
|
|
description?: string;
|
|
confirmLabel?: string;
|
|
validateLabel?: string;
|
|
validatingLabel?: string;
|
|
};
|
|
|
|
function withTranslatedMeta(
|
|
meta: ChannelMeta,
|
|
translate: (path: string, fallback: string) => string,
|
|
): ChannelMeta {
|
|
return {
|
|
...meta,
|
|
name: translate(`channels.meta.${meta.type}.name`, meta.name),
|
|
description: translate(`channels.meta.${meta.type}.description`, meta.description),
|
|
docsUrl: meta.docsUrl
|
|
? translate(`channels.meta.${meta.type}.docsUrl`, meta.docsUrl)
|
|
: meta.docsUrl,
|
|
instructions: meta.instructions.map((instruction, index) => (
|
|
translate(`channels.meta.${meta.type}.instructions.${index}`, instruction)
|
|
)),
|
|
configFields: meta.configFields.map((field) => withTranslatedField(meta.type, field, translate)),
|
|
};
|
|
}
|
|
|
|
function withTranslatedField(
|
|
channelType: string,
|
|
field: ChannelConfigFieldMeta,
|
|
translate: (path: string, fallback: string) => string,
|
|
): ChannelConfigFieldMeta {
|
|
return {
|
|
...field,
|
|
label: translate(`channels.meta.${channelType}.fields.${field.key}.label`, field.label),
|
|
placeholder: field.placeholder
|
|
? translate(`channels.meta.${channelType}.fields.${field.key}.placeholder`, field.placeholder)
|
|
: field.placeholder,
|
|
description: field.description
|
|
? translate(`channels.meta.${channelType}.fields.${field.key}.description`, field.description)
|
|
: field.description,
|
|
docsUrl: field.docsUrl
|
|
? translate(`channels.meta.${channelType}.fields.${field.key}.docsUrl`, field.docsUrl)
|
|
: field.docsUrl,
|
|
};
|
|
}
|
|
|
|
export default function ChannelConfigModal({
|
|
open,
|
|
selectedChannelType,
|
|
values,
|
|
onValueChange,
|
|
onClose,
|
|
onConfirm,
|
|
onValidate,
|
|
error,
|
|
submitting,
|
|
validating,
|
|
validationResult,
|
|
title,
|
|
description,
|
|
confirmLabel,
|
|
validateLabel,
|
|
validatingLabel,
|
|
}: ChannelConfigModalProps) {
|
|
const { t, hasMessage } = useI18n();
|
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
|
const firstFieldRef = useRef<HTMLDivElement>(null);
|
|
|
|
const translate = useCallback((path: string, fallback: string) => (
|
|
hasMessage(path) ? t(path) : fallback
|
|
), [hasMessage, t]);
|
|
|
|
const activeMeta = useMemo(
|
|
() => withTranslatedMeta(getChannelMeta(selectedChannelType), translate),
|
|
[selectedChannelType, translate],
|
|
);
|
|
const supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');
|
|
const canValidate = Boolean(onValidate) && activeMeta.connectionType !== 'qr';
|
|
|
|
const resolvedTitle = title ?? t('channels.modal.configureTitle', { name: activeMeta.name });
|
|
const descriptionText = description ?? activeMeta.description;
|
|
const instructionsHeading = t('channels.modal.howTo');
|
|
const confirmText = confirmLabel ?? (canValidate ? t('channels.modal.saveAndConnect') : t('channels.modal.confirm'));
|
|
const validateText = validateLabel ?? t('channels.modal.validateConfig');
|
|
const validatingText = validatingLabel ?? t('channels.modal.validating');
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setShowSecrets({});
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
setShowSecrets({});
|
|
}, [selectedChannelType]);
|
|
|
|
useEffect(() => {
|
|
if (open && firstFieldRef.current) {
|
|
firstFieldRef.current.focus();
|
|
}
|
|
}, [open, selectedChannelType]);
|
|
|
|
const toggleSecretVisibility = useCallback((key: string) => {
|
|
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
}, []);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className="flex max-h-[92vh] w-full max-w-280 flex-col overflow-hidden rounded-[34px] border border-black/10 bg-white shadow-[0_32px_90px_-24px_rgba(15,23,42,0.35)] dark:border-white/10 dark:bg-[#1c1c20]"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-6 px-10 pb-2 pt-9">
|
|
<div className="min-w-0">
|
|
<h2
|
|
className="text-[54px] leading-[0.98] font-normal tracking-tight text-[#0f172a] dark:text-[#f3f4f6]"
|
|
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
|
>
|
|
{resolvedTitle}
|
|
</h2>
|
|
<p className="mt-5 max-w-4xl text-[16px] leading-8 text-[#667085] dark:text-gray-300">
|
|
{descriptionText}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="mt-1 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[#98a2b3] transition-colors hover:bg-black/5 hover:text-[#0f172a] dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-100"
|
|
onClick={onClose}
|
|
aria-label={t('window.close')}
|
|
>
|
|
<X className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-10 pb-8 pt-6">
|
|
<div className="space-y-8">
|
|
{error ? (
|
|
<div className="rounded-[22px] border border-[#efb3b3] bg-[#fff1f1] px-5 py-4 text-[14px] leading-7 text-[#b42318] dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<ChannelInstructionsPanel
|
|
meta={activeMeta}
|
|
title={instructionsHeading}
|
|
viewDocsLabel={t('channels.modal.viewDocs')}
|
|
/>
|
|
|
|
<div ref={firstFieldRef} tabIndex={-1} className="space-y-8 outline-none">
|
|
<ChannelConfigFields
|
|
fields={supportedFields}
|
|
values={values}
|
|
onValueChange={onValueChange}
|
|
disabled={submitting || validating}
|
|
showSecretMap={showSecrets}
|
|
onToggleSecret={toggleSecretVisibility}
|
|
emptyStateText={t('channels.modal.noAdditionalFields')}
|
|
envVarLabel={t('channels.modal.envVar')}
|
|
showSecretLabel={t('channels.modal.showSecret')}
|
|
hideSecretLabel={t('channels.modal.hideSecret')}
|
|
/>
|
|
</div>
|
|
|
|
{validationResult ? (
|
|
<div
|
|
className={[
|
|
'rounded-[22px] border px-5 py-4 text-[14px] leading-7',
|
|
validationResult.valid
|
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300'
|
|
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300',
|
|
].join(' ')}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{validationResult.valid ? (
|
|
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0" />
|
|
) : (
|
|
<AlertCircle className="mt-1 h-5 w-5 shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="font-semibold">
|
|
{validationResult.valid ? t('channels.modal.credentialsVerified') : t('channels.modal.validationFailed')}
|
|
</div>
|
|
|
|
{validationResult.errors.length > 0 ? (
|
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
|
{validationResult.errors.map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
{validationResult.warnings.length > 0 ? (
|
|
<div className="mt-3">
|
|
<div className="text-[12px] font-semibold uppercase tracking-[0.12em] opacity-80">
|
|
{t('channels.modal.warnings')}
|
|
</div>
|
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
|
{validationResult.warnings.map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<ChannelConfigActions
|
|
confirmLabel={confirmText}
|
|
validateLabel={validateText}
|
|
validatingLabel={validatingText}
|
|
onConfirm={() => {
|
|
void onConfirm();
|
|
}}
|
|
onValidate={canValidate ? () => {
|
|
void onValidate?.();
|
|
} : undefined}
|
|
disabled={false}
|
|
submitting={submitting}
|
|
validating={validating}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|