Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
TagInput,
type TagItem,
} from '@sim/emcn'
import { Send } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
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'

Expand All @@ -42,17 +40,14 @@ const ACCESS_LABELS: Record<AccessMode, string> = {
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,
Expand Down Expand Up @@ -83,7 +78,7 @@ export function ShareModal({
const [draftEmails, setDraftEmails] = useState<string[] | null>(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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 (
<ChipModal open={open} onOpenChange={handleClose} size='sm' srTitle={`Share ${fileName}`}>
<ChipModalHeader icon={Send} onClose={handleClose}>
Expand Down Expand Up @@ -236,18 +216,15 @@ export function ShareModal({
) : null}
{effectiveMode === 'email' || effectiveMode === 'sso' ? (
<ChipModalField
type='custom'
type='emails'
title='Allowed emails'
hint='Add specific emails or whole domains (@example.com).'
>
<TagInput
items={emailItems}
onAdd={addEmail}
onRemove={removeEmail}
placeholder='Enter emails or domains'
placeholderWithTags='Add email'
/>
</ChipModalField>
value={effectiveEmails}
onChange={setDraftEmails}
validate={validateAllowlistEntry}
allowDomains
placeholder='Enter emails or domains'
placeholderWithTags='Add email or domain'
/>
) : null}
{effectiveMode !== 'private' && shareUrl ? (
<ChipModalField type='copy' title='Link' value={shareUrl} copyLabel='Copy link' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -87,6 +93,7 @@ export function OutputSelect({
valueMode = 'id',
align = 'start',
maxHeight = 200,
size = 'sm',
className,
}: OutputSelectProps) {
const blocks = useWorkflowStore((state) => state.blocks)
Expand Down Expand Up @@ -299,10 +306,12 @@ export function OutputSelect({
.filter((v): v is string => v !== null)
}, [selectedOutputs, workflowOutputs, valueMode])

const Trigger = size === 'md' ? ChipCombobox : Combobox

return (
<Combobox
size='sm'
className={cn('!py-0.5 w-fit min-w-[100px] rounded-md px-2.5', className)}
<Trigger
size={size}
className={cn('min-w-[100px]', size === 'sm' && '!py-0.5 w-fit rounded-md px-2.5', className)}
groups={comboboxGroups}
options={[]}
multiSelect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,24 @@ import {
ButtonGroup,
ButtonGroupItem,
ChipConfirmModal,
ChipEmailsInput,
ChipInput,
cn,
Input,
Label,
Loader,
Skeleton,
Switch,
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { AlertTriangle, Check } from 'lucide-react'
import { GeneratedPasswordInput } from '@/components/ui'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
import {
type AuthType,
Expand Down Expand Up @@ -339,7 +337,7 @@ export function ChatDeploy({
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
className='-mx-1 space-y-4 px-1'
>
{errors.general && (
<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'>
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -693,13 +692,8 @@ function AuthSelector({
hasExistingPassword = false,
error,
}: AuthSelectorProps) {
const [emailError, setEmailError] = useState('')
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
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
Expand All @@ -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

Expand Down Expand Up @@ -835,22 +775,15 @@ function AuthSelector({
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
{authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
</Label>
<TagInput
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={handleRemoveEmailItem}
placeholder='Enter emails or domains (@example.com)'
placeholderWithTags='Add email'
<ChipEmailsInput
value={emails}
onChange={onEmailsChange}
validate={validateAllowlistEntry}
allowDomains
placeholder='Enter emails or domains'
placeholderWithTags='Add email or domain'
disabled={disabled}
/>
{emailError && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{emailError}</p>
)}
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
{authType === 'email'
? 'Add specific emails or entire domains (@example.com)'
: 'Add emails or domains that can access via SSO'}
</p>
</div>
)}

Expand Down
55 changes: 54 additions & 1 deletion apps/sim/lib/messaging/email/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isValidEmailSyntax } from '@sim/utils/string'
import { describe, expect, it } from 'vitest'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { quickValidateEmail, validateAllowlistEntry } from '@/lib/messaging/email/validation'

describe('quickValidateEmail', () => {
it.concurrent('should validate a correct email', () => {
Expand Down Expand Up @@ -145,3 +146,55 @@ describe('quickValidateEmail', () => {
expect(result.checks.domain).toBe(true)
})
})

describe('isValidEmailSyntax', () => {
it.concurrent('should only accept a bare domain when allowDomains is set', () => {
expect(isValidEmailSyntax('@example.com')).toBe(false)
expect(isValidEmailSyntax('@example.com', true)).toBe(true)
})

it.concurrent('should accept single-label domains, which self-hosted deployments use', () => {
expect(isValidEmailSyntax('@intranet', true)).toBe(true)
expect(isValidEmailSyntax('@localhost', true)).toBe(true)
})

it.concurrent('should reject bare domains the old startsWith("@") check let through', () => {
for (const entry of ['@', '@-bad.com', '@bad-.com', '@example.com.', '@exa mple.com']) {
expect(isValidEmailSyntax(entry, true)).toBe(false)
}
})

it.concurrent('should enforce the 254-character cap', () => {
const at254 = `${'a'.repeat(242)}@example.com`
const at255 = `${'a'.repeat(243)}@example.com`
expect(at254).toHaveLength(254)
expect(at255).toHaveLength(255)
expect(isValidEmailSyntax(at254)).toBe(true)
expect(isValidEmailSyntax(at255)).toBe(false)
})

it.concurrent('should enforce the 63-character DNS label limit on bare domains', () => {
expect(isValidEmailSyntax(`@${'a'.repeat(63)}.com`, true)).toBe(true)
expect(isValidEmailSyntax(`@${'a'.repeat(64)}.com`, true)).toBe(false)
})
})

describe('validateAllowlistEntry', () => {
it.concurrent('should accept a valid address', () => {
expect(validateAllowlistEntry('user@example.com')).toBeNull()
})

it.concurrent('should waive address-level policy for bare domain entries', () => {
expect(validateAllowlistEntry('@mailinator.com')).toBeNull()
expect(validateAllowlistEntry('user@mailinator.com')).toBe(
'Disposable email addresses are not allowed'
)
})

it.concurrent('should surface the underlying rejection reason', () => {
expect(validateAllowlistEntry('notanemail')).toBe('Invalid email format')
expect(validateAllowlistEntry('user..name@example.com')).toBe(
'Email contains suspicious patterns'
)
})
})
Loading
Loading