Skip to content

Commit 33b242d

Browse files
committed
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
1 parent c759a88 commit 33b242d

7 files changed

Lines changed: 274 additions & 254 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@ import {
99
ChipModalField,
1010
ChipModalFooter,
1111
ChipModalHeader,
12-
TagInput,
13-
type TagItem,
1412
} from '@sim/emcn'
1513
import { Send } from '@sim/emcn/icons'
1614
import { generateShortId } from '@sim/utils/id'
1715
import { GeneratedPasswordInput } from '@/components/ui'
1816
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
1917
import { isSsoEnabled } from '@/lib/core/config/env-flags'
2018
import { getBaseUrl } from '@/lib/core/utils/urls'
21-
import { quickValidateEmail } from '@/lib/messaging/email/validation'
19+
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
2220
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
2321
import { usePermissionConfig } from '@/hooks/use-permission-config'
2422

@@ -42,17 +40,14 @@ const ACCESS_LABELS: Record<AccessMode, string> = {
4240
sso: 'SSO',
4341
}
4442

43+
/** Stable identity so the emails field's reconcile effect no-ops while unset. */
44+
const EMPTY_EMAILS: string[] = []
45+
4546
function savedMode(share: ShareRecord | null): AccessMode {
4647
if (!share?.isActive) return 'private'
4748
return share.authType
4849
}
4950

50-
/** True when an entry is a valid email or an `@domain` pattern. */
51-
function isValidEmailEntry(value: string): boolean {
52-
const normalized = value.trim().toLowerCase()
53-
return normalized.startsWith('@') || quickValidateEmail(normalized).isValid
54-
}
55-
5651
export function ShareModal({
5752
open,
5853
onOpenChange,
@@ -83,7 +78,7 @@ export function ShareModal({
8378
const [draftEmails, setDraftEmails] = useState<string[] | null>(null)
8479
const effectiveMode = draftMode ?? savedAccessMode
8580
const effectiveActive = effectiveMode !== 'private'
86-
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? []
81+
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS
8782

8883
// Org access-control may restrict which auth modes are allowed (`null` = all).
8984
// The route is the source of truth; this just hides disallowed options.
@@ -167,19 +162,6 @@ export function ShareModal({
167162
})
168163
}
169164

170-
const addEmail = (value: string): boolean => {
171-
const normalized = value.trim().toLowerCase()
172-
if (!normalized || effectiveEmails.includes(normalized) || !isValidEmailEntry(normalized)) {
173-
return false
174-
}
175-
setDraftEmails([...effectiveEmails, normalized])
176-
return true
177-
}
178-
179-
const removeEmail = (_value: string, index: number) => {
180-
setDraftEmails(effectiveEmails.filter((_, i) => i !== index))
181-
}
182-
183165
const accessHint = (() => {
184166
if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
185167
if (enableBlockedByPolicy)
@@ -196,8 +178,6 @@ export function ShareModal({
196178
: 'Anyone with the link can view and download this file.'
197179
})()
198180

199-
const emailItems: TagItem[] = effectiveEmails.map((value) => ({ value, isValid: true }))
200-
201181
return (
202182
<ChipModal open={open} onOpenChange={handleClose} size='sm' srTitle={`Share ${fileName}`}>
203183
<ChipModalHeader icon={Send} onClose={handleClose}>
@@ -236,18 +216,15 @@ export function ShareModal({
236216
) : null}
237217
{effectiveMode === 'email' || effectiveMode === 'sso' ? (
238218
<ChipModalField
239-
type='custom'
219+
type='emails'
240220
title='Allowed emails'
241-
hint='Add specific emails or whole domains (@example.com).'
242-
>
243-
<TagInput
244-
items={emailItems}
245-
onAdd={addEmail}
246-
onRemove={removeEmail}
247-
placeholder='Enter emails or domains'
248-
placeholderWithTags='Add email'
249-
/>
250-
</ChipModalField>
221+
value={effectiveEmails}
222+
onChange={setDraftEmails}
223+
validate={validateAllowlistEntry}
224+
allowDomains
225+
placeholder='Enter emails or domains'
226+
placeholderWithTags='Add email or domain'
227+
/>
251228
) : null}
252229
{effectiveMode !== 'private' && shareUrl ? (
253230
<ChipModalField type='copy' title='Link' value={shareUrl} copyLabel='Copy link' />

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type React from 'react'
44
import { useMemo } from 'react'
5-
import { Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
5+
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
66
import { RepeatIcon, SplitIcon } from 'lucide-react'
77
import { useShallow } from 'zustand/react/shallow'
88
import {
@@ -64,6 +64,12 @@ interface OutputSelectProps {
6464
align?: 'start' | 'end' | 'center'
6565
/** Maximum height of the dropdown content in pixels */
6666
maxHeight?: number
67+
/**
68+
* Trigger chrome. `'sm'` is the compact pill used in inline toolbars;
69+
* `'md'` is the 30px chip field, for stacking with `ChipInput` in a form.
70+
* @default 'sm'
71+
*/
72+
size?: 'sm' | 'md'
6773
/** Additional class names to apply to the combobox trigger */
6874
className?: string
6975
}
@@ -87,6 +93,7 @@ export function OutputSelect({
8793
valueMode = 'id',
8894
align = 'start',
8995
maxHeight = 200,
96+
size = 'sm',
9097
className,
9198
}: OutputSelectProps) {
9299
const blocks = useWorkflowStore((state) => state.blocks)
@@ -299,10 +306,12 @@ export function OutputSelect({
299306
.filter((v): v is string => v !== null)
300307
}, [selectedOutputs, workflowOutputs, valueMode])
301308

309+
const Trigger = size === 'md' ? ChipCombobox : Combobox
310+
302311
return (
303-
<Combobox
304-
size='sm'
305-
className={cn('!py-0.5 w-fit min-w-[100px] rounded-md px-2.5', className)}
312+
<Trigger
313+
size={size}
314+
className={cn('min-w-[100px]', size === 'sm' && '!py-0.5 w-fit rounded-md px-2.5', className)}
306315
groups={comboboxGroups}
307316
options={[]}
308317
multiSelect

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx

Lines changed: 11 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,24 @@ import {
55
ButtonGroup,
66
ButtonGroupItem,
77
ChipConfirmModal,
8+
ChipEmailsInput,
89
ChipInput,
910
cn,
1011
Input,
1112
Label,
1213
Loader,
1314
Skeleton,
1415
Switch,
15-
TagInput,
16-
type TagItem,
1716
Textarea,
1817
Tooltip,
1918
} from '@sim/emcn'
2019
import { createLogger } from '@sim/logger'
2120
import { getErrorMessage } from '@sim/utils/errors'
22-
import { normalizeEmail } from '@sim/utils/string'
2321
import { AlertTriangle, Check } from 'lucide-react'
2422
import { GeneratedPasswordInput } from '@/components/ui'
2523
import { isSsoEnabled } from '@/lib/core/config/env-flags'
2624
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
27-
import { quickValidateEmail } from '@/lib/messaging/email/validation'
25+
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
2826
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
2927
import {
3028
type AuthType,
@@ -339,7 +337,7 @@ export function ChatDeploy({
339337
id='chat-deploy-form'
340338
ref={formRef}
341339
onSubmit={handleSubmit}
342-
className='-mx-1 space-y-4 overflow-y-auto px-1'
340+
className='-mx-1 space-y-4 px-1'
343341
>
344342
{errors.general && (
345343
<div className='flex items-center gap-2 rounded-md border border-[color-mix(in_srgb,var(--text-error)_20%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] px-3 py-2 text-[var(--text-error)] text-small'>
@@ -388,6 +386,7 @@ export function ChatDeploy({
388386
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
389387
placeholder='Select which block outputs to use'
390388
disabled={chatSubmitting}
389+
size='md'
391390
className='w-full'
392391
/>
393392
{errors.outputBlocks && (
@@ -693,13 +692,8 @@ function AuthSelector({
693692
hasExistingPassword = false,
694693
error,
695694
}: AuthSelectorProps) {
696-
const [emailError, setEmailError] = useState('')
697-
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
698695
const revealPasswordMutation = useRevealChatPassword()
699696

700-
const emailsRef = useRef(emails)
701-
const invalidEmailItemsRef = useRef(invalidEmailItems)
702-
703697
/**
704698
* Editing or regenerating the password clears a failed reveal. The mutation
705699
* only drops its error on the next attempt, so it would otherwise keep
@@ -710,60 +704,6 @@ function AuthSelector({
710704
onPasswordChange(value)
711705
}
712706

713-
useEffect(() => {
714-
emailsRef.current = emails
715-
}, [emails])
716-
717-
const addEmail = (email: string): boolean => {
718-
if (!email.trim()) return false
719-
720-
const normalized = normalizeEmail(email)
721-
const isDomainPattern = normalized.startsWith('@')
722-
const validation = quickValidateEmail(normalized)
723-
const isValid = validation.isValid || isDomainPattern
724-
725-
if (
726-
emailsRef.current.includes(normalized) ||
727-
invalidEmailItemsRef.current.some((item) => item.value === normalized)
728-
) {
729-
return false
730-
}
731-
732-
if (isValid) {
733-
setEmailError('')
734-
emailsRef.current = [...emailsRef.current, normalized]
735-
onEmailsChange(emailsRef.current)
736-
} else {
737-
invalidEmailItemsRef.current = [
738-
...invalidEmailItemsRef.current,
739-
{ value: normalized, isValid, error: validation.reason ?? 'Invalid email format' },
740-
]
741-
setInvalidEmailItems(invalidEmailItemsRef.current)
742-
}
743-
744-
return isValid
745-
}
746-
747-
const emailItems = [
748-
...emails.map((email) => ({ value: email, isValid: true })),
749-
...invalidEmailItems,
750-
]
751-
752-
const handleRemoveEmailItem = (_value: string, index: number) => {
753-
const itemToRemove = emailItems[index]
754-
if (!itemToRemove) return
755-
756-
if (itemToRemove.isValid) {
757-
emailsRef.current = emailsRef.current.filter((e) => e !== itemToRemove.value)
758-
onEmailsChange(emailsRef.current)
759-
} else {
760-
invalidEmailItemsRef.current = invalidEmailItemsRef.current.filter(
761-
(item) => item.value !== itemToRemove.value
762-
)
763-
setInvalidEmailItems(invalidEmailItemsRef.current)
764-
}
765-
}
766-
767707
const { config: permissionConfig } = usePermissionConfig()
768708
const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes
769709

@@ -835,22 +775,15 @@ function AuthSelector({
835775
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
836776
{authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
837777
</Label>
838-
<TagInput
839-
items={emailItems}
840-
onAdd={(value) => addEmail(value)}
841-
onRemove={handleRemoveEmailItem}
842-
placeholder='Enter emails or domains (@example.com)'
843-
placeholderWithTags='Add email'
778+
<ChipEmailsInput
779+
value={emails}
780+
onChange={onEmailsChange}
781+
validate={validateAllowlistEntry}
782+
allowDomains
783+
placeholder='Enter emails or domains'
784+
placeholderWithTags='Add email or domain'
844785
disabled={disabled}
845786
/>
846-
{emailError && (
847-
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{emailError}</p>
848-
)}
849-
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
850-
{authType === 'email'
851-
? 'Add specific emails or entire domains (@example.com)'
852-
: 'Add emails or domains that can access via SSO'}
853-
</p>
854787
</div>
855788
)}
856789

apps/sim/lib/messaging/email/validation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,17 @@ export function quickValidateEmail(email: string): EmailValidationResult {
133133
checks,
134134
}
135135
}
136+
137+
/**
138+
* App-level policy for a single access-allowlist entry, applied on top of the
139+
* syntax gate in `ChipEmailsInput`. A bare `@domain` entry carries no local
140+
* part, so the address-level checks (disposable providers, suspicious patterns)
141+
* only apply to full addresses.
142+
*
143+
* @returns the rejection reason, or `null` when the entry is accepted.
144+
*/
145+
export function validateAllowlistEntry(entry: string): string | null {
146+
if (entry.startsWith('@')) return null
147+
const result = quickValidateEmail(entry)
148+
return result.isValid ? null : (result.reason ?? 'Invalid email')
149+
}

0 commit comments

Comments
 (0)