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:
@@ -1,3 +1,5 @@
|
||||
import { Hash } from 'lucide-react';
|
||||
|
||||
type ChannelAccountIdFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -16,18 +18,23 @@ export default function ChannelAccountIdField({
|
||||
disabled,
|
||||
}: ChannelAccountIdFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground/80">
|
||||
<Hash className="h-3.5 w-3.5 text-foreground/55" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
className="h-[44px] w-full rounded-[14px] border border-black/10 bg-white px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
<div className="text-[12px] leading-[18px] text-foreground/55 dark:text-gray-500">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
import { Check, Loader2, ShieldCheck } from 'lucide-react';
|
||||
|
||||
type ChannelConfigActionsProps = {
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onClose: () => void;
|
||||
validateLabel?: string;
|
||||
validatingLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onValidate?: () => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
validating?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelConfigActions({
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onClose,
|
||||
validateLabel,
|
||||
validatingLabel,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
disabled,
|
||||
submitting,
|
||||
validating,
|
||||
}: ChannelConfigActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#EDECE4] px-5 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-gray-200"
|
||||
onClick={onClose}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<div className="flex flex-col gap-3 border-t border-black/10 pt-6 sm:flex-row sm:justify-end dark:border-white/10">
|
||||
{onValidate ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[54px] items-center justify-center rounded-full border border-black/10 bg-[#fbf8f1] px-6 text-[15px] font-semibold text-[#1f2937] transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:hover:bg-white/10"
|
||||
onClick={onValidate}
|
||||
disabled={disabled || submitting || validating}
|
||||
>
|
||||
{validating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{validatingLabel ?? validateLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{validateLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#2B7FFF] px-5 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-[54px] items-center justify-center rounded-full bg-[#8ea8ff] px-7 text-[15px] font-semibold text-white transition-colors hover:bg-[#7f9afb] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#8ea8ff] dark:text-white dark:hover:bg-[#7f9afb]"
|
||||
onClick={onConfirm}
|
||||
disabled={disabled || submitting}
|
||||
disabled={disabled || submitting || validating}
|
||||
>
|
||||
{submitting ? `${confirmLabel}...` : confirmLabel}
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{confirmLabel}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{confirmLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
import ChannelTokenField from './ChannelTokenField';
|
||||
|
||||
type ChannelConfigFieldsProps = {
|
||||
@@ -8,9 +7,12 @@ type ChannelConfigFieldsProps = {
|
||||
values: ChannelConfigFieldValueMap;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
showSecretMap?: Record<string, boolean>;
|
||||
onToggleSecret?: (key: string) => void;
|
||||
emptyStateText?: string;
|
||||
envVarLabel?: string;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
};
|
||||
|
||||
function renderField({
|
||||
@@ -18,66 +20,116 @@ function renderField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
}: {
|
||||
field: ChannelConfigFieldMeta;
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
showSecretMap?: Record<string, boolean>;
|
||||
onToggleSecret?: (key: string) => void;
|
||||
envVarLabel?: string;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
}): ReactNode {
|
||||
const commonProps = {
|
||||
label: field.label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder: field.placeholder,
|
||||
helpText: field.description,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (field.key === 'accountId') {
|
||||
return (
|
||||
<ChannelAccountIdField
|
||||
{...commonProps}
|
||||
label={field.label || accountIdLabel}
|
||||
helpText={field.description ?? accountIdHelpText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'token' || field.kind === 'password') {
|
||||
return <ChannelTokenField {...commonProps} helpText={field.description ?? tokenHelpText} />;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ChannelTokenField
|
||||
label={field.label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder}
|
||||
helpText={field.description}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
showSecret={Boolean(showSecretMap?.[field.key])}
|
||||
showSecretLabel={showSecretLabel}
|
||||
hideSecretLabel={hideSecretLabel}
|
||||
onToggleSecret={
|
||||
onToggleSecret ? () => onToggleSecret(field.key) : undefined
|
||||
}
|
||||
/>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'textarea') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{field.label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
rows={field.rows ?? 4}
|
||||
className="w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{field.description}</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{field.label}
|
||||
{field.required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
rows={field.rows ?? 4}
|
||||
className="min-h-[144px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 py-4 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelTokenField
|
||||
{...commonProps}
|
||||
label={field.label}
|
||||
helpText={field.description ?? tokenHelpText}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{field.label}
|
||||
{field.required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[56px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldMeta(
|
||||
field: ChannelConfigFieldMeta,
|
||||
envVarLabel?: string,
|
||||
): ReactNode {
|
||||
const envVars = Array.isArray(field.envVar)
|
||||
? field.envVar.filter(Boolean)
|
||||
: field.envVar
|
||||
? [field.envVar]
|
||||
: [];
|
||||
|
||||
if (envVars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px] leading-6 text-[#667085] dark:text-gray-400">
|
||||
{envVars.length > 0 ? (
|
||||
<span className="font-mono tracking-[0.02em]">
|
||||
{`${envVarLabel ?? 'Environment variable'}: ${envVars.join(', ')}`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,16 +138,23 @@ export default function ChannelConfigFields({
|
||||
values,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
emptyStateText,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
}: ChannelConfigFieldsProps) {
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<div className="rounded-[20px] border border-dashed border-black/10 bg-[#fbf8f1] px-5 py-4 text-[14px] leading-7 text-[#667085] dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-300">
|
||||
{emptyStateText ?? 'No additional fields are required for this channel.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{renderField({
|
||||
@@ -103,9 +162,11 @@ export default function ChannelConfigFields({
|
||||
value: values[field.key] ?? '',
|
||||
onChange: (nextValue) => onValueChange(field.key, nextValue),
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,157 +1,284 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { getChannelMeta, getChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import DialogSurface from '../../pages/Home/components/DialogSurface';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
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';
|
||||
import ChannelTypeSelector from './ChannelTypeSelector';
|
||||
|
||||
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;
|
||||
channelTypes?: ChannelMeta[];
|
||||
values: ChannelConfigFieldValueMap;
|
||||
accountId: string;
|
||||
disableChannelType?: boolean;
|
||||
disableAccountId?: boolean;
|
||||
onChannelTypeChange: (value: string) => void;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
onAccountIdChange: (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;
|
||||
typeLabel?: string;
|
||||
accountIdLabel?: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
instructionsTitle?: string;
|
||||
docsLabel?: string;
|
||||
diagnosticsNote?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
widthClassName?: 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,
|
||||
channelTypes,
|
||||
values,
|
||||
accountId,
|
||||
disableChannelType,
|
||||
disableAccountId,
|
||||
onChannelTypeChange,
|
||||
onValueChange,
|
||||
onAccountIdChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
error,
|
||||
submitting,
|
||||
validating,
|
||||
validationResult,
|
||||
title,
|
||||
description,
|
||||
typeLabel,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
instructionsTitle,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
widthClassName,
|
||||
validateLabel,
|
||||
validatingLabel,
|
||||
}: ChannelConfigModalProps) {
|
||||
const { t } = useI18n();
|
||||
const options = channelTypes ?? getChannelOptions();
|
||||
const activeMeta = options.find((item) => item.type === selectedChannelType) ?? getChannelMeta(selectedChannelType);
|
||||
const accountFieldMeta = activeMeta.configFields.find((field) => field.key === 'accountId');
|
||||
const accountLabel = accountIdLabel ?? accountFieldMeta?.label ?? t('channels.modal.accountIdLabel');
|
||||
const accountHelp = accountIdHelpText ?? accountFieldMeta?.description;
|
||||
const typeSelectLabel = typeLabel ?? t('channels.modal.typeLabel');
|
||||
const docsText = docsLabel ?? t('channels.modal.docsLabel');
|
||||
const instructionsHeading = instructionsTitle ?? t('channels.modal.instructionsTitle');
|
||||
const confirmText = confirmLabel ?? t('channels.modal.confirm');
|
||||
const cancelText = cancelLabel ?? t('dialog.cancel');
|
||||
const notesText = diagnosticsNote ?? t('channels.modal.diagnosticsNote');
|
||||
const descriptionText = description ?? t('channels.modal.description');
|
||||
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 (
|
||||
<DialogSurface
|
||||
open={open}
|
||||
title={title ?? t('channels.modal.title')}
|
||||
widthClassName={widthClassName ?? 'max-w-[920px]'}
|
||||
onClose={onClose}
|
||||
<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="space-y-5">
|
||||
<div className="text-[13px] leading-[20px] text-[#525866] dark:text-gray-300">
|
||||
{descriptionText}
|
||||
<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>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<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}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-4">
|
||||
<ChannelTypeSelector
|
||||
label={typeSelectLabel}
|
||||
value={activeMeta.type}
|
||||
options={options}
|
||||
onChange={onChannelTypeChange}
|
||||
disabled={disableChannelType || submitting}
|
||||
helpText={activeMeta.connectionType}
|
||||
<ChannelInstructionsPanel
|
||||
meta={activeMeta}
|
||||
title={instructionsHeading}
|
||||
viewDocsLabel={t('channels.modal.viewDocs')}
|
||||
/>
|
||||
|
||||
<ChannelAccountIdField
|
||||
label={accountLabel}
|
||||
value={accountId}
|
||||
onChange={onAccountIdChange}
|
||||
placeholder={accountFieldMeta?.placeholder}
|
||||
helpText={accountHelp}
|
||||
disabled={disableAccountId || submitting}
|
||||
/>
|
||||
|
||||
<ChannelConfigFields
|
||||
fields={supportedFields}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
disabled={submitting}
|
||||
accountIdLabel={accountLabel}
|
||||
accountIdHelpText={accountHelp}
|
||||
tokenHelpText={tokenHelpText ?? t('channels.modal.tokenHelpText')}
|
||||
/>
|
||||
|
||||
<div className="rounded-[16px] border border-dashed border-[#E5E8EE] bg-white px-4 py-3 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
{t('channels.modal.todoNote')}
|
||||
<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>
|
||||
|
||||
<ChannelInstructionsPanel
|
||||
meta={activeMeta}
|
||||
title={instructionsHeading}
|
||||
docsLabel={docsText}
|
||||
diagnosticsNote={notesText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelConfigActions
|
||||
cancelLabel={cancelText}
|
||||
confirmLabel={confirmText}
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={false}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
</DialogSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,68 @@
|
||||
import { BookOpen, ExternalLink } from 'lucide-react';
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelInstructionsPanelProps = {
|
||||
meta: ChannelMeta;
|
||||
title: string;
|
||||
docsLabel: string;
|
||||
diagnosticsNote: string;
|
||||
viewDocsLabel: string;
|
||||
};
|
||||
|
||||
export default function ChannelInstructionsPanel({
|
||||
meta,
|
||||
title,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
viewDocsLabel,
|
||||
}: ChannelInstructionsPanelProps) {
|
||||
return (
|
||||
<section className="rounded-[16px] border border-[#E5E8EE] bg-[#FAFBFC] p-4 dark:border-[#2a2a2d] dark:bg-[#17171a]">
|
||||
<div className="text-[13px] font-medium text-[#171717] dark:text-gray-100">{title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">
|
||||
{docsLabel}
|
||||
</div>
|
||||
const canOpenDocs = Boolean(meta.docsUrl);
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300">
|
||||
{meta.description}
|
||||
function openDocs(): void {
|
||||
if (!meta.docsUrl) return;
|
||||
|
||||
try {
|
||||
if (window.electron?.openExternal) {
|
||||
window.electron.openExternal(meta.docsUrl);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to window.open below.
|
||||
}
|
||||
|
||||
window.open(meta.docsUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-black/10 bg-[#f7f3eb] px-8 py-7 shadow-[0_10px_30px_rgba(15,23,42,0.06)] dark:border-white/10 dark:bg-[#232327]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[18px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-3 max-w-3xl text-[15px] leading-7 text-[#667085] dark:text-gray-300">
|
||||
{meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meta.instructions.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{meta.instructions.map((instruction) => (
|
||||
<li
|
||||
key={instruction}
|
||||
className="rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300"
|
||||
>
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{canOpenDocs ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-12 shrink-0 items-center gap-2 rounded-full border border-black/10 bg-[#fbf8f1] px-5 text-[15px] font-semibold text-[#1f2937] transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-[#2a2a2f] dark:text-gray-100 dark:hover:bg-white/10"
|
||||
onClick={openDocs}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{viewDocsLabel}</span>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[12px] bg-[#EFF6FF] px-3 py-2 text-[12px] leading-[18px] text-[#2B4E8C] dark:bg-[#1d2633] dark:text-[#93c5fd]">
|
||||
{diagnosticsNote}
|
||||
</div>
|
||||
{meta.instructions.length > 0 ? (
|
||||
<ol className="mt-7 space-y-3 pl-7 text-[15px] leading-8 text-[#667085] dark:text-gray-300">
|
||||
{meta.instructions.map((instruction) => (
|
||||
<li key={instruction} className="list-decimal marker:font-semibold marker:text-[#4b5563] dark:marker:text-gray-300">
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
type ChannelTokenFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -5,6 +7,11 @@ type ChannelTokenFieldProps = {
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
disabled?: boolean;
|
||||
showSecret?: boolean;
|
||||
onToggleSecret?: () => void;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelTokenField({
|
||||
@@ -14,20 +21,49 @@ export default function ChannelTokenField({
|
||||
placeholder,
|
||||
helpText,
|
||||
disabled,
|
||||
showSecret,
|
||||
onToggleSecret,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
required,
|
||||
}: ChannelTokenFieldProps) {
|
||||
const isSecretField = typeof showSecret === 'boolean' && typeof onToggleSecret === 'function';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
type={isSecretField && !showSecret ? 'password' : 'text'}
|
||||
className="h-[56px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSecretField ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSecret}
|
||||
disabled={disabled}
|
||||
className="inline-flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[18px] border border-black/10 bg-[#fbf8f1] text-[#667085] transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-100"
|
||||
aria-label={showSecret ? (hideSecretLabel ?? 'Hide secret') : (showSecretLabel ?? 'Show secret')}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadgeCheck, ChevronDown, LayoutGrid, Plug, QrCode, Webhook } from 'lucide-react';
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelTypeSelectorProps = {
|
||||
@@ -9,6 +10,19 @@ type ChannelTypeSelectorProps = {
|
||||
helpText?: string;
|
||||
};
|
||||
|
||||
function getConnectionIcon(connectionType?: string) {
|
||||
switch (connectionType) {
|
||||
case 'qr':
|
||||
return QrCode;
|
||||
case 'webhook':
|
||||
return Webhook;
|
||||
case 'plugin':
|
||||
return Plug;
|
||||
default:
|
||||
return LayoutGrid;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChannelTypeSelector({
|
||||
label,
|
||||
value,
|
||||
@@ -18,29 +32,46 @@ export default function ChannelTypeSelector({
|
||||
helpText,
|
||||
}: ChannelTypeSelectorProps) {
|
||||
const selected = options.find((item) => item.type === value);
|
||||
const ConnectionIcon = getConnectionIcon(selected?.connectionType ?? helpText);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<section className="rounded-[22px] border border-black/10 bg-[#faf9f3] p-4 shadow-sm dark:border-white/10 dark:bg-[#222228]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground/80">
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-foreground/55" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[36px] rounded-[12px] border border-dashed border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
<div className="font-medium text-[#171717] dark:text-gray-100">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-black/10 bg-white px-2.5 py-1 text-[11px] font-medium text-foreground/65 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||
<ConnectionIcon className="h-3 w-3" />
|
||||
<span>{selected?.connectionType ?? helpText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[44px] w-full appearance-none rounded-[14px] border border-black/10 bg-white px-3 pr-10 text-[13px] text-foreground outline-none transition-colors focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground/45" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[16px] border border-dashed border-black/10 bg-white/80 px-4 py-3 text-[12px] leading-[18px] text-foreground/65 dark:border-white/10 dark:bg-[#17171a] dark:text-gray-300">
|
||||
<div className="font-medium text-foreground dark:text-gray-100">
|
||||
{selected?.name ?? value}
|
||||
</div>
|
||||
<div className="mt-1">{selected?.description ?? helpText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user