From 33b242da9ffde4d572ba6afa4306a693fe3501bc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 14:00:51 -0700 Subject: [PATCH 1/3] improvement(emcn): share one emails/domains chip input across share and deploy modals Extracts the emails chip lifecycle out of ChipModalField type='emails' into a standalone ChipEmailsInput, and points both the file share modal and the deploy modal's chat tab at it instead of their hand-rolled TagInput wiring. - add ChipEmailsInput (dedupe, normalize, format gate, paste, per-chip errors) with an allowDomains opt-in for bare @domain.tld entries - share modal and deploy modal chat tab now use it; drop both hand-rolled add/remove/validate implementations and the dead emailError state - move the shared allowlist policy into validateAllowlistEntry - drop the "Add specific emails or whole domains" hint text - give OutputSelect a size prop; the deploy modal chat tab uses the 30px chip trigger so it lines up with the Title field above it - drop overflow-y-auto from the chat deploy form, which was promoting overflow-x to auto and rendering a stray horizontal scrollbar --- .../components/share-modal/share-modal.tsx | 49 ++--- .../output-select/output-select.tsx | 17 +- .../deploy-modal/components/chat/chat.tsx | 89 +------- apps/sim/lib/messaging/email/validation.ts | 14 ++ .../chip-emails-input/chip-emails-input.tsx | 194 ++++++++++++++++++ .../src/components/chip-modal/chip-modal.tsx | 161 +++------------ packages/emcn/src/components/index.ts | 4 + 7 files changed, 274 insertions(+), 254 deletions(-) create mode 100644 packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index b8d3f9de074..073ce695588 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -9,8 +9,6 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - TagInput, - type TagItem, } from '@sim/emcn' import { Send } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' @@ -18,7 +16,7 @@ import { GeneratedPasswordInput } from '@/components/ui' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { validateAllowlistEntry } from '@/lib/messaging/email/validation' import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -42,17 +40,14 @@ const ACCESS_LABELS: Record = { sso: 'SSO', } +/** Stable identity so the emails field's reconcile effect no-ops while unset. */ +const EMPTY_EMAILS: string[] = [] + function savedMode(share: ShareRecord | null): AccessMode { if (!share?.isActive) return 'private' return share.authType } -/** True when an entry is a valid email or an `@domain` pattern. */ -function isValidEmailEntry(value: string): boolean { - const normalized = value.trim().toLowerCase() - return normalized.startsWith('@') || quickValidateEmail(normalized).isValid -} - export function ShareModal({ open, onOpenChange, @@ -83,7 +78,7 @@ export function ShareModal({ const [draftEmails, setDraftEmails] = useState(null) const effectiveMode = draftMode ?? savedAccessMode const effectiveActive = effectiveMode !== 'private' - const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? [] + const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS // Org access-control may restrict which auth modes are allowed (`null` = all). // The route is the source of truth; this just hides disallowed options. @@ -167,19 +162,6 @@ export function ShareModal({ }) } - const addEmail = (value: string): boolean => { - const normalized = value.trim().toLowerCase() - if (!normalized || effectiveEmails.includes(normalized) || !isValidEmailEntry(normalized)) { - return false - } - setDraftEmails([...effectiveEmails, normalized]) - return true - } - - const removeEmail = (_value: string, index: number) => { - setDraftEmails(effectiveEmails.filter((_, i) => i !== index)) - } - const accessHint = (() => { if (modeDisallowed) return 'This sharing method is disabled by an administrator.' if (enableBlockedByPolicy) @@ -196,8 +178,6 @@ export function ShareModal({ : 'Anyone with the link can view and download this file.' })() - const emailItems: TagItem[] = effectiveEmails.map((value) => ({ value, isValid: true })) - return ( @@ -236,18 +216,15 @@ export function ShareModal({ ) : null} {effectiveMode === 'email' || effectiveMode === 'sso' ? ( - - + value={effectiveEmails} + onChange={setDraftEmails} + validate={validateAllowlistEntry} + allowDomains + placeholder='Enter emails or domains' + placeholderWithTags='Add email or domain' + /> ) : null} {effectiveMode !== 'private' && shareUrl ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index c6c9ee01ec6..c234898474c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useMemo } from 'react' -import { Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn' +import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn' import { RepeatIcon, SplitIcon } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { @@ -64,6 +64,12 @@ interface OutputSelectProps { align?: 'start' | 'end' | 'center' /** Maximum height of the dropdown content in pixels */ maxHeight?: number + /** + * Trigger chrome. `'sm'` is the compact pill used in inline toolbars; + * `'md'` is the 30px chip field, for stacking with `ChipInput` in a form. + * @default 'sm' + */ + size?: 'sm' | 'md' /** Additional class names to apply to the combobox trigger */ className?: string } @@ -87,6 +93,7 @@ export function OutputSelect({ valueMode = 'id', align = 'start', maxHeight = 200, + size = 'sm', className, }: OutputSelectProps) { const blocks = useWorkflowStore((state) => state.blocks) @@ -299,10 +306,12 @@ export function OutputSelect({ .filter((v): v is string => v !== null) }, [selectedOutputs, workflowOutputs, valueMode]) + const Trigger = size === 'md' ? ChipCombobox : Combobox + return ( - {errors.general && (
@@ -388,6 +386,7 @@ export function ChatDeploy({ onOutputSelect={(values) => updateField('selectedOutputBlocks', values)} placeholder='Select which block outputs to use' disabled={chatSubmitting} + size='md' className='w-full' /> {errors.outputBlocks && ( @@ -693,13 +692,8 @@ function AuthSelector({ hasExistingPassword = false, error, }: AuthSelectorProps) { - const [emailError, setEmailError] = useState('') - const [invalidEmailItems, setInvalidEmailItems] = useState([]) const revealPasswordMutation = useRevealChatPassword() - const emailsRef = useRef(emails) - const invalidEmailItemsRef = useRef(invalidEmailItems) - /** * Editing or regenerating the password clears a failed reveal. The mutation * only drops its error on the next attempt, so it would otherwise keep @@ -710,60 +704,6 @@ function AuthSelector({ onPasswordChange(value) } - useEffect(() => { - emailsRef.current = emails - }, [emails]) - - const addEmail = (email: string): boolean => { - if (!email.trim()) return false - - const normalized = normalizeEmail(email) - const isDomainPattern = normalized.startsWith('@') - const validation = quickValidateEmail(normalized) - const isValid = validation.isValid || isDomainPattern - - if ( - emailsRef.current.includes(normalized) || - invalidEmailItemsRef.current.some((item) => item.value === normalized) - ) { - return false - } - - if (isValid) { - setEmailError('') - emailsRef.current = [...emailsRef.current, normalized] - onEmailsChange(emailsRef.current) - } else { - invalidEmailItemsRef.current = [ - ...invalidEmailItemsRef.current, - { value: normalized, isValid, error: validation.reason ?? 'Invalid email format' }, - ] - setInvalidEmailItems(invalidEmailItemsRef.current) - } - - return isValid - } - - const emailItems = [ - ...emails.map((email) => ({ value: email, isValid: true })), - ...invalidEmailItems, - ] - - const handleRemoveEmailItem = (_value: string, index: number) => { - const itemToRemove = emailItems[index] - if (!itemToRemove) return - - if (itemToRemove.isValid) { - emailsRef.current = emailsRef.current.filter((e) => e !== itemToRemove.value) - onEmailsChange(emailsRef.current) - } else { - invalidEmailItemsRef.current = invalidEmailItemsRef.current.filter( - (item) => item.value !== itemToRemove.value - ) - setInvalidEmailItems(invalidEmailItemsRef.current) - } - } - const { config: permissionConfig } = usePermissionConfig() const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes @@ -835,22 +775,15 @@ function AuthSelector({ - addEmail(value)} - onRemove={handleRemoveEmailItem} - placeholder='Enter emails or domains (@example.com)' - placeholderWithTags='Add email' + - {emailError && ( -

{emailError}

- )} -

- {authType === 'email' - ? 'Add specific emails or entire domains (@example.com)' - : 'Add emails or domains that can access via SSO'} -

)} diff --git a/apps/sim/lib/messaging/email/validation.ts b/apps/sim/lib/messaging/email/validation.ts index fc49ae95d15..f488cdb4216 100644 --- a/apps/sim/lib/messaging/email/validation.ts +++ b/apps/sim/lib/messaging/email/validation.ts @@ -133,3 +133,17 @@ export function quickValidateEmail(email: string): EmailValidationResult { checks, } } + +/** + * App-level policy for a single access-allowlist entry, applied on top of the + * syntax gate in `ChipEmailsInput`. A bare `@domain` entry carries no local + * part, so the address-level checks (disposable providers, suspicious patterns) + * only apply to full addresses. + * + * @returns the rejection reason, or `null` when the entry is accepted. + */ +export function validateAllowlistEntry(entry: string): string | null { + if (entry.startsWith('@')) return null + const result = quickValidateEmail(entry) + return result.isValid ? null : (result.reason ?? 'Invalid email') +} diff --git a/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx b/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx new file mode 100644 index 00000000000..dffa50cecdb --- /dev/null +++ b/packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx @@ -0,0 +1,194 @@ +'use client' + +import * as React from 'react' +import { normalizeEmail } from '@sim/utils/string' +import { TagInput, type TagItem } from '../tag-input/tag-input' + +/** + * Generic RFC 5322 email syntax gate. This is deliberately format-only — + * app-specific policy (disposable domains, MX/DNS, membership rules) is the + * consumer's concern and flows through the `validate` prop, keeping that logic + * in the app rather than the design system. + */ +const EMAIL_SYNTAX_REGEX = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + +/** + * Bare `@domain.tld` pattern, accepted only when the consumer opts in via + * `allowDomains` — for allowlists that grant access to a whole domain. + */ +const EMAIL_DOMAIN_SYNTAX_REGEX = + /^@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/ + +function isValidEmailSyntax(email: string, allowDomains: boolean): boolean { + if (email.length > 254) return false + return EMAIL_SYNTAX_REGEX.test(email) || (allowDomains && EMAIL_DOMAIN_SYNTAX_REGEX.test(email)) +} + +/** + * Derives the post-first-chip placeholder from the initial placeholder so + * consumers don't have to spell both. Tries an `'Enter s'` → + * `'Add '` singularize; falls back to a generic `'Add another'`. + */ +function derivePlaceholderWithTags(placeholder: string): string { + const match = placeholder.match(/^Enter\s+(.+?)s?$/i) + if (match) return `Add ${match[1]}` + return 'Add another' +} + +export interface ChipEmailsInputProps { + /** Current list of accepted entries. */ + value: string[] + /** Called with the next list when valid items are added or removed. */ + onChange: (next: string[]) => void + /** + * Optional domain-level validator. Runs AFTER the internal format check + * passes. Return an error message to reject the entry (added as an invalid + * chip whose reason shows in a tooltip on hover); return `null` to accept. + */ + validate?: (email: string) => string | null + /** + * Also accept a bare `@domain.tld` entry alongside full addresses, for + * allowlists that grant access to an entire domain. + * @default false + */ + allowDomains?: boolean + /** Placeholder shown when no chips exist. Defaults to `'Enter emails'`. */ + placeholder?: string + /** + * Placeholder shown once at least one chip exists. Defaults to a singularized + * form of {@link ChipEmailsInputProps.placeholder}; pass this when that + * derivation reads awkwardly. + */ + placeholderWithTags?: string + /** + * Chip surface. `'block'` is the taller multi-row form variant. + * @default 'block' + */ + variant?: 'default' | 'block' + /** Disables the input and hides the per-chip remove buttons. */ + disabled?: boolean + /** Focus the input when the component mounts. */ + autoFocus?: boolean + /** HTML `id` for the inner input, for label association. */ + id?: string +} + +/** + * Canonical multi-email chip input. Owns the chip lifecycle (valid + invalid + * items, dedupe, lowercase normalization, format validation, paste, Backspace, + * per-chip error tooltips) and lifts only the accepted list up via `onChange`. + * Each rejected entry carries its rejection reason on the chip itself. + * + * Inside a `ChipModal`, prefer `ChipModalField type='emails'`, which wraps this + * with the canonical label/hint/error row. Use this component directly only in + * surfaces that own their own field chrome. + */ +export function ChipEmailsInput({ + value, + onChange, + validate, + allowDomains = false, + placeholder = 'Enter emails', + placeholderWithTags, + variant = 'block', + disabled, + autoFocus, + id, +}: ChipEmailsInputProps) { + const [items, setItems] = React.useState(() => + value.map((v) => ({ value: v, isValid: true })) + ) + + /** + * Synchronous mirror of `items`. Pasting multiple values calls `handleAdd` + * once per value within a single event, before React re-renders — reading + * the `items` state there would make every call see the same stale array + * and each add overwrite the previous one (only the last pasted email + * survives). All reads and writes go through the ref so consecutive adds + * compose; `commitItems` keeps state and ref in lockstep. + */ + const itemsRef = React.useRef(items) + + const commitItems = React.useCallback((next: TagItem[]) => { + itemsRef.current = next + setItems(next) + }, []) + + /** + * Reconcile internal `items` with the consumer's `value` when the latter + * changes externally (programmatic clear, partial-failure reseed, etc.). + * When our own `onChange` is the source of the update, the valid items in + * `items` already match `value` and this is a no-op. + */ + React.useEffect(() => { + const prevValid = itemsRef.current.filter((item) => item.isValid).map((item) => item.value) + if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) { + return + } + itemsRef.current = value.map((v) => ({ value: v, isValid: true })) + setItems(itemsRef.current) + }, [value]) + + const handleAdd = React.useCallback( + (raw: string): boolean => { + const email = normalizeEmail(raw) + if (!email) return false + const current = itemsRef.current + if (current.some((item) => item.value === email)) return false + + if (!isValidEmailSyntax(email, allowDomains)) { + commitItems([ + ...current, + { + value: email, + isValid: false, + error: allowDomains ? 'Invalid email or domain' : 'Invalid email format', + }, + ]) + return false + } + + const reason = validate?.(email) + if (reason) { + commitItems([...current, { value: email, isValid: false, error: reason }]) + return false + } + + const next = [...current, { value: email, isValid: true }] + commitItems(next) + onChange(next.filter((item) => item.isValid).map((item) => item.value)) + return true + }, + [validate, onChange, commitItems, allowDomains] + ) + + const handleRemove = React.useCallback( + (_removed: string, index: number) => { + const current = itemsRef.current + const wasValid = current[index]?.isValid ?? false + const next = current.filter((_, i) => i !== index) + commitItems(next) + if (wasValid) { + onChange(next.filter((item) => item.isValid).map((item) => item.value)) + } + }, + [onChange, commitItems] + ) + + return ( + + ) +} + +ChipEmailsInput.displayName = 'ChipEmailsInput' diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 586c7e1fbfb..704cd64e6a4 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -47,27 +47,14 @@ import { Chip, type ChipProps } from '../chip/chip' import { chipContentIconClass, chipContentLabelClass } from '../chip/chip-chrome' import { ChipCopyInput } from '../chip-copy-input/chip-copy-input' import { ChipDropdown, type ChipDropdownOption } from '../chip-dropdown/chip-dropdown' +import { ChipEmailsInput, type ChipEmailsInputProps } from '../chip-emails-input/chip-emails-input' import { ChipInput } from '../chip-input/chip-input' import { ChipSwitch } from '../chip-switch/chip-switch' import { ChipTextarea } from '../chip-textarea/chip-textarea' import { Label } from '../label/label' import { Modal, ModalContent } from '../modal/modal' -import { TagInput, type TagItem } from '../tag-input/tag-input' import { Tooltip } from '../tooltip/tooltip' -/** - * Generic RFC 5322 email syntax gate for the `type='emails'` field. This is - * deliberately format-only — app-specific policy (disposable domains, MX/DNS, - * membership rules) is the consumer's concern and flows through the field's - * `validate` prop, keeping that logic in the app rather than the design system. - */ -const EMAIL_SYNTAX_REGEX = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ - -function isValidEmailSyntax(email: string): boolean { - return EMAIL_SYNTAX_REGEX.test(email) && email.length <= 254 -} - /** * The modal's hairline divider — used by the header and footer edges, and * exported so body sections (e.g. a settings band below a prompt) can draw the @@ -524,29 +511,23 @@ interface ChipModalFileFieldProps extends ChipModalFieldBaseProps { loading?: boolean } -export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps { +/** + * The emails field is a thin row wrapper over {@link ChipEmailsInput} — the + * control's own props (`value`, `onChange`, `validate`, `allowDomains`, + * `placeholder`, …) pass straight through, so they are declared in one place. + * `variant` is not forwarded: the field always uses the tall `block` chip + * surface so it stacks as a peer with `textarea` fields. + */ +export interface ChipModalEmailsFieldProps + extends ChipModalFieldBaseProps, + Omit { type: 'emails' - /** Current list of valid email addresses. */ - value: string[] - /** Called with the next list when valid items are added or removed. */ - onChange: (next: string[]) => void - /** - * Optional domain-level validator. Runs AFTER the field's internal format - * check passes. Return an error message to reject the email (added as an - * invalid chip whose reason shows in a tooltip on hover); return `null` - * to accept. - */ - validate?: (email: string) => string | null /** * External error (e.g. server-side submit failure), rendered in the inline * banner below the field. Per-email rejection reasons are shown on the * invalid chips themselves, not here. */ error?: React.ReactNode - /** Auto-focus the input when the field mounts. */ - autoFocus?: boolean - /** Placeholder shown when no chips exist. Defaults to `'Enter emails'`. */ - placeholder?: string } /** @@ -742,119 +723,27 @@ function renderChipModalControl( } /** - * Derives the post-first-chip placeholder from the initial placeholder so - * consumers don't have to spell both. Tries an `'Enter s'` → - * `'Add '` singularize; falls back to a generic `'Add another'`. - */ -function derivePlaceholderWithTags(placeholder: string): string { - const match = placeholder.match(/^Enter\s+(.+?)s?$/i) - if (match) return `Add ${match[1]}` - return 'Add another' -} - -/** - * Internal renderer for {@link ChipModalField} `type='emails'`. Owns the - * chip lifecycle (valid + invalid items, dedupe, per-chip error tooltips) - * and lifts only the valid email list up to the consumer via `onChange`. - * Each rejected entry carries its rejection reason on the chip itself, - * surfaced as a tooltip; the inline banner is reserved for the consumer's - * `error` (e.g. server-side submit failures). + * Internal renderer for {@link ChipModalField} `type='emails'`. Delegates the + * chip lifecycle to {@link ChipEmailsInput} and adds only the field-level + * error banner — per-entry rejection reasons are shown on the chips + * themselves, so this banner is reserved for the consumer's `error` (e.g. a + * server-side submit failure). */ function ChipModalEmailsControl({ - value, - onChange, - validate, + type: _type, + title: _title, + required: _required, + hint: _hint, + flush: _flush, + className: _className, error, - autoFocus, - placeholder = 'Enter emails', - disabled, - id, errorId, + id, + ...emailsProps }: ChipModalEmailsFieldProps & { id: string; errorId: string }) { - const [items, setItems] = React.useState([]) - - /** - * Synchronous mirror of `items`. Pasting multiple values calls `handleAdd` - * once per value within a single event, before React re-renders — reading - * the `items` state there would make every call see the same stale array - * and each add overwrite the previous one (only the last pasted email - * survives). All reads and writes go through the ref so consecutive adds - * compose; `commitItems` keeps state and ref in lockstep. - */ - const itemsRef = React.useRef(items) - - const commitItems = React.useCallback((next: TagItem[]) => { - itemsRef.current = next - setItems(next) - }, []) - - /** - * Reconcile internal `items` with the consumer's `value` when the latter - * changes externally (programmatic clear, partial-failure reseed, etc.). - * When our own `onChange` is the source of the update, the valid items in - * `items` already match `value` and this is a no-op. - */ - React.useEffect(() => { - const prevValid = itemsRef.current.filter((item) => item.isValid).map((item) => item.value) - if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) { - return - } - itemsRef.current = value.map((v) => ({ value: v, isValid: true })) - setItems(itemsRef.current) - }, [value]) - - const handleAdd = React.useCallback( - (raw: string): boolean => { - const email = raw.trim().toLowerCase() - if (!email) return false - const current = itemsRef.current - if (current.some((item) => item.value === email)) return false - - if (!isValidEmailSyntax(email)) { - commitItems([...current, { value: email, isValid: false, error: 'Invalid email format' }]) - return false - } - - const reason = validate?.(email) - if (reason) { - commitItems([...current, { value: email, isValid: false, error: reason }]) - return false - } - - const next = [...current, { value: email, isValid: true }] - commitItems(next) - onChange(next.filter((item) => item.isValid).map((item) => item.value)) - return true - }, - [validate, onChange, commitItems] - ) - - const handleRemove = React.useCallback( - (_removed: string, index: number) => { - const current = itemsRef.current - const wasValid = current[index]?.isValid ?? false - const next = current.filter((_, i) => i !== index) - commitItems(next) - if (wasValid) { - onChange(next.filter((item) => item.isValid).map((item) => item.value)) - } - }, - [onChange, commitItems] - ) - return ( <> - + {error && (