diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 1fefe86ba36..cda535f095a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -24,12 +24,22 @@ import { CreateApiKeyModal } from './components' const logger = createLogger('ApiKeys') +/** Stable empty references so memoized derivations don't re-run while data loads. */ +const EMPTY_KEYS: ApiKey[] = [] +const EMPTY_KEY_NAMES: string[] = [] + /** Copies an API key's name and confirms with a toast. */ function copyKeyName(name: string) { void navigator.clipboard.writeText(name) toast.success('Copied name to clipboard') } +/** Formats an API key's last-used timestamp, or "Never" when unused. */ +function formatLastUsed(dateString?: string | null): string { + if (!dateString) return 'Never' + return formatDate(new Date(dateString)) +} + interface ApiKeyRowMenuProps { keyName: string onDelete: () => void @@ -73,9 +83,9 @@ export function ApiKeys() { const deleteApiKeyMutation = useDeleteApiKey() const updateSettingsMutation = useUpdateWorkspaceApiKeySettings() - const workspaceKeys = apiKeysData?.workspaceKeys || [] - const personalKeys = apiKeysData?.personalKeys || [] - const conflicts = apiKeysData?.conflicts || [] + const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS + const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS + const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES const isLoading = isLoadingKeys || isLoadingSettings const allowPersonalApiKeys = @@ -90,21 +100,27 @@ export function ApiKeys() { const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys) const filteredWorkspaceKeys = useMemo(() => { - if (!searchTerm.trim()) { - return workspaceKeys.map((key, index) => ({ key, originalIndex: index })) + const term = searchTerm.trim().toLowerCase() + const result: { key: ApiKey; originalIndex: number }[] = [] + for (let index = 0; index < workspaceKeys.length; index++) { + const key = workspaceKeys[index] + if (term === '' || key.name.toLowerCase().includes(term)) { + result.push({ key, originalIndex: index }) + } } - return workspaceKeys - .map((key, index) => ({ key, originalIndex: index })) - .filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase())) + return result }, [workspaceKeys, searchTerm]) const filteredPersonalKeys = useMemo(() => { - if (!searchTerm.trim()) { - return personalKeys.map((key, index) => ({ key, originalIndex: index })) + const term = searchTerm.trim().toLowerCase() + const result: { key: ApiKey; originalIndex: number }[] = [] + for (let index = 0; index < personalKeys.length; index++) { + const key = personalKeys[index] + if (term === '' || key.name.toLowerCase().includes(term)) { + result.push({ key, originalIndex: index }) + } } - return personalKeys - .map((key, index) => ({ key, originalIndex: index })) - .filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase())) + return result }, [personalKeys, searchTerm]) const handleDeleteKey = async () => { @@ -128,11 +144,6 @@ export function ApiKeys() { } } - const formatLastUsed = (dateString?: string | null) => { - if (!dateString) return 'Never' - return formatDate(new Date(dateString)) - } - const actions: SettingsAction[] = [ { text: 'Create API key', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index b8dfb6d975b..bd85229e898 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -143,6 +143,7 @@ export function CreateApiKeyModal({ type='text' name='fakeusernameremembered' autoComplete='username' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 3dc0ca2b8b7..d4ffc001032 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -90,12 +90,23 @@ function formatInvoiceDate(createdSeconds: number): string { }) } +/** Cached currency formatters, keyed by upper-cased ISO currency code. */ +const invoiceAmountFormatters = new Map() + +/** Resolve (and memoize) an `Intl.NumberFormat` for a currency code. */ +function getInvoiceAmountFormatter(currency: string): Intl.NumberFormat { + const code = currency.toUpperCase() + let formatter = invoiceAmountFormatters.get(code) + if (!formatter) { + formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: code }) + invoiceAmountFormatters.set(code, formatter) + } + return formatter +} + /** Format a minor-unit (e.g. cents) amount as a localized currency string. */ function formatInvoiceAmount(amountMinor: number, currency: string): string { - return new Intl.NumberFormat(undefined, { - style: 'currency', - currency: currency.toUpperCase(), - }).format(amountMinor / 100) + return getInvoiceAmountFormatter(currency).format(amountMinor / 100) } export function Billing() { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx index fd56bcd442c..48b198dc5b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx @@ -13,6 +13,15 @@ import { useDebounce } from '@/hooks/use-debounce' /** Delay before a usage-limit edit is auto-saved once the user stops typing. */ const AUTOSAVE_DELAY_MS = 1000 +/** Static help accessory for the usage-limit header; hoisted so it's a stable reference. */ +const USAGE_LIMIT_INFO = ( + + { + "Max usage to consume per month, set in credits — Sim's usage unit (1,000 credits = $5). By default, it's your plan's included usage, but you can set it beyond." + } + +) + interface UsageLimitFieldProps { /** Current monthly usage limit, in dollars. */ currentLimit: number @@ -111,16 +120,7 @@ export function UsageLimitField({ }, [debouncedDraft, minimumLimit, canEdit, context, organizationId, saveOrgLimit, saveUserLimit]) return ( - - { - "Max usage to consume per month, set in credits — Sim's usage unit (1,000 credits = $5). By default, it's your plan's included usage, but you can set it beyond." - } - - } - > + setSearchTerm(e.target.value)} @@ -380,6 +381,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { type='text' name='fakeusernameremembered' autoComplete='username' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', @@ -391,6 +393,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { />
{ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 985734b6ba5..c365196abcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -325,19 +325,18 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const { data, isLoading } = useBYOKKeys(workspaceId) - const keys = data?.keys ?? [] const upsertKey = useUpsertBYOKKey() const deleteKey = useDeleteBYOKKey() const keysByProvider = useMemo(() => { const grouped = new Map() - for (const key of keys) { + for (const key of data?.keys ?? []) { const providerKeys = grouped.get(key.providerId) ?? [] providerKeys.push({ id: key.id, name: key.name, maskedKey: key.maskedKey }) grouped.set(key.providerId, providerKeys) } return grouped - }, [keys]) + }, [data?.keys]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index b02be972520..6d17bf41545 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -27,6 +27,12 @@ import { const logger = createLogger('CopilotSettings') +/** Formats a key's last-used timestamp, falling back to "Never" when unset. */ +function formatLastUsed(dateString?: string | null): string { + if (!dateString) return 'Never' + return formatDate(new Date(dateString)) +} + /** * Copilot Keys management component for handling API keys used with the Copilot feature. * Provides functionality to create, view, and delete copilot API keys. @@ -95,11 +101,6 @@ export function Copilot() { } } - const formatLastUsed = (dateString?: string | null) => { - if (!dateString) return 'Never' - return formatDate(new Date(dateString)) - } - const hasKeys = keys.length > 0 const showEmptyState = !hasKeys const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index bb285ffdd02..e55a36eafe7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -57,6 +57,11 @@ import { useSubscriptionData } from '@/hooks/queries/subscription' const logger = createLogger('EmailPolling') +function getProviderIcon(providerId: string | null) { + if (providerId === 'outlook') return + return +} + export function CredentialSets() { const { data: session } = useSession() const { data: organizationsData } = useOrganizations() @@ -240,10 +245,13 @@ export function CredentialSets() { } }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet]) - const validEmails = useMemo( - () => emailItems.filter((item) => item.isValid).map((item) => item.value), - [emailItems] - ) + const validEmails = useMemo(() => { + const result: string[] = [] + for (const item of emailItems) { + if (item.isValid) result.push(item.value) + } + return result + }, [emailItems]) const handleInviteMembers = useCallback(async () => { if (!viewingSet?.id) return @@ -367,11 +375,6 @@ export function CredentialSets() { } }, [deletingSet, activeOrganization?.id, deleteCredentialSet]) - const getProviderIcon = (providerId: string | null) => { - if (providerId === 'outlook') return - return - } - const activeMemberships = useMemo( () => memberships.filter((m) => m.status === 'active'), [memberships] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 4d38140a26b..0c0aa46005c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -10,7 +10,6 @@ import { ChipModalFooter, ChipModalHeader, ChipSelect, - handleKeyboardActivation, Input, Label, Switch, @@ -293,12 +292,11 @@ export function General() {
-
handleKeyboardActivation(event, handleProfilePictureClick)} > {(() => { if (imageUrl) { @@ -334,7 +332,7 @@ export function General() { )}
-
+ setName(e.target.value)} onKeyDown={handleKeyDown} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx index f3976f33415..b8eb1683031 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx @@ -27,16 +27,13 @@ export function InboxEnableToggle() { const [isDisableOpen, setIsDisableOpen] = useState(false) const [enableUsername, setEnableUsername] = useState('') - const handleToggle = useCallback( - async (checked: boolean) => { - if (checked) { - setIsEnableOpen(true) - return - } - setIsDisableOpen(true) - }, - [workspaceId] - ) + const handleToggle = useCallback(async (checked: boolean) => { + if (checked) { + setIsEnableOpen(true) + return + } + setIsDisableOpen(true) + }, []) const handleDisable = useCallback(async () => { try { @@ -45,7 +42,7 @@ export function InboxEnableToggle() { } catch (error) { logger.error('Failed to disable inbox', { error }) } - }, [workspaceId]) + }, [workspaceId, toggleInbox.mutateAsync]) const handleEnable = useCallback(async () => { try { @@ -59,7 +56,7 @@ export function InboxEnableToggle() { } catch (error) { logger.error('Failed to enable inbox', { error }) } - }, [workspaceId, enableUsername]) + }, [workspaceId, enableUsername, toggleInbox.mutateAsync]) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index 942fd5d8acd..4bb93b6a2ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -65,7 +65,7 @@ export function InboxSettingsTab() { } catch (error) { setEditAddressError(getErrorMessage(error, 'Failed to update address')) } - }, [workspaceId, newUsername]) + }, [workspaceId, newUsername, updateAddress.mutateAsync]) const handleAddSender = useCallback(async () => { if (!newSenderEmail.trim()) return @@ -82,7 +82,7 @@ export function InboxSettingsTab() { } catch (error) { setAddSenderError(getErrorMessage(error, 'Failed to add sender')) } - }, [workspaceId, newSenderEmail, newSenderLabel]) + }, [workspaceId, newSenderEmail, newSenderLabel, addSender.mutateAsync]) const handleRemoveSender = useCallback( async (senderId: string) => { @@ -93,7 +93,7 @@ export function InboxSettingsTab() { setRemoveSenderError(getErrorMessage(error, 'Failed to remove sender')) } }, - [workspaceId] + [workspaceId, removeSender.mutateAsync] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 3b52de1ea3f..6b4e88013c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -133,25 +133,13 @@ export function InboxTaskList() { const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received const isClickable = task.chatId && (task.status === 'completed' || task.status === 'failed') - return ( -
handleTaskClick(task)} - onKeyDown={(e) => { - if (isClickable && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault() - handleTaskClick(task) - } - }} - > + const rowClassName = `flex w-full items-center gap-2.5 rounded-lg p-2 text-left transition-colors ${ + isClickable + ? 'cursor-pointer hover-hover:bg-[var(--surface-active)]' + : 'cursor-default' + }` + const rowContent = ( + <>
@@ -202,6 +190,21 @@ export function InboxTaskList() { )}
+ + ) + + return isClickable ? ( + + ) : ( +
+ {rowContent}
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index a7bbfc7d4a0..9c93dd8fa01 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -672,6 +672,7 @@ export function McpServerFormModal({ type='text' name='fakeusernameremembered' autoComplete='username' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly @@ -680,6 +681,7 @@ export function McpServerFormModal({ type='password' name='fakepasswordremembered' autoComplete='current-password' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 78f91e8510e..e1f79ef2cd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -303,7 +303,7 @@ export function RecentlyDeleted() { } const col = (activeSort ?? DEFAULT_SORT).column const dir = (activeSort ?? DEFAULT_SORT).direction - items = [...items].sort((a, b) => { + items.sort((a, b) => { let cmp = 0 switch (col) { case 'name': diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 84b226c14ea..b49c4e801e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -173,10 +173,14 @@ function parseEnvVarLine(line: string): UIEnvironmentVariable | null { /** Parses an array of raw text lines, returning only valid non-empty KEY=VALUE entries. */ function parseValidEnvVars(lines: string[]): UIEnvironmentVariable[] { - return lines - .map(parseEnvVarLine) - .filter((parsed): parsed is UIEnvironmentVariable => parsed !== null) - .filter(({ key, value }) => key && value) + const result: UIEnvironmentVariable[] = [] + for (const line of lines) { + const parsed = parseEnvVarLine(line) + if (parsed?.key && parsed.value) { + result.push(parsed) + } + } + return result } interface WorkspaceVariableRowProps { @@ -771,9 +775,10 @@ export function SecretsManager() { } const personalChanged = (() => { - const initialMap = new Map( - initialVarsRef.current.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) + const initialMap = new Map() + for (const v of initialVarsRef.current) { + if (v.key && v.value) initialMap.set(v.key, v.value) + } const currentKeys = Object.keys(validVariables) if (initialMap.size !== currentKeys.length) return true for (const [key, value] of Object.entries(validVariables)) { @@ -912,13 +917,14 @@ export function SecretsManager() { return ( <> -
+ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index f3f30c7bca0..5b91277a2aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -65,6 +65,8 @@ export function NoOrganizationView({ style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly + aria-hidden='true' + aria-label='Ignore this field' />