fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace (#2482)

* fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace

* cleanup

* ack PR comments

* fix failing test
This commit is contained in:
Waleed
2025-12-19 15:29:01 -08:00
committed by GitHub
parent 094f87fa1f
commit df5f823d1c
14 changed files with 443 additions and 20 deletions

View File

@@ -141,6 +141,23 @@ export async function DELETE(
)
}
// Check if deleting this folder would delete the last workflow(s) in the workspace
const workflowsInFolder = await countWorkflowsInFolderRecursively(
id,
existingFolder.workspaceId
)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return NextResponse.json(
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
{ status: 400 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
return stats
}
/**
* Counts the number of workflows in a folder and all its subfolders recursively.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId

View File

@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
const mockGetWorkflowAccessContext = vi.fn()
const mockDbDelete = vi.fn()
const mockDbUpdate = vi.fn()
const mockDbSelect = vi.fn()
vi.mock('@/lib/auth', () => ({
getSession: () => mockGetSession(),
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
db: {
delete: () => mockDbDelete(),
update: () => mockDbUpdate(),
select: () => mockDbSelect(),
},
workflow: {},
}))
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
isWorkspaceOwner: false,
})
// Mock db.select() to return multiple workflows so deletion is allowed
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockDbDelete.mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
})
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
expect(data.success).toBe(true)
})
it('should prevent deletion of the last workflow in workspace', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockGetWorkflowAccessContext.mockResolvedValue({
workflow: mockWorkflow,
workspaceOwnerId: 'workspace-456',
workspacePermission: 'admin',
isOwner: true,
isWorkspaceOwner: false,
})
// Mock db.select() to return only 1 workflow (the one being deleted)
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
})
it.concurrent('should deny deletion for non-admin users', async () => {
const mockWorkflow = {
id: 'workflow-123',

View File

@@ -228,6 +228,21 @@ export async function DELETE(
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if this is the last workflow in the workspace
if (workflowData.workspaceId) {
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workflowData.workspaceId))
if (totalWorkflowsInWorkspace.length <= 1) {
return NextResponse.json(
{ error: 'Cannot delete the only workflow in the workspace' },
{ status: 400 }
)
}
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'

View File

@@ -339,12 +339,31 @@ export function CreateBaseModal({
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='name'>Name</Label>
<Label htmlFor='kb-name'>Name</Label>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<Input
id='name'
id='kb-name'
placeholder='Enter knowledge base name'
{...register('name')}
className={cn(errors.name && 'border-[var(--text-error)]')}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>

View File

@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<EmcnInput
value={newKeyName}
onChange={(e) => {
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>

View File

@@ -141,12 +141,37 @@ export function MemberInvitationCard({
{/* Main invitation input */}
<div className='flex items-start gap-2'>
<div className='flex-1'>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
placeholder='Enter email address'
value={inviteEmail}
onChange={handleEmailChange}
disabled={isInviting || !hasAvailableSeats}
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
name='member_invite_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
{emailError && (
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>

View File

@@ -55,16 +55,31 @@ export function NoOrganizationView({
{/* Form fields - clean layout without card */}
<div className='space-y-4'>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<div>
<Label htmlFor='orgName' className='font-medium text-[13px]'>
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
Team Name
</Label>
<Input
id='orgName'
id='team-name-field'
value={orgName}
onChange={onOrgNameChange}
placeholder='My Team'
className='mt-1'
name='team_name_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
@@ -116,31 +131,52 @@ export function NoOrganizationView({
</ModalHeader>
<div className='space-y-4'>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<div>
<Label htmlFor='org-name' className='font-medium text-[13px]'>
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
Organization Name
</Label>
<Input
id='org-name'
id='org-name-field'
placeholder='Enter organization name'
value={orgName}
onChange={onOrgNameChange}
disabled={isCreatingOrg}
className='mt-1'
name='org_name_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
<div>
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
Organization Slug
</Label>
<Input
id='org-slug'
id='org-slug-field'
placeholder='organization-slug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
disabled={isCreatingOrg}
className='mt-1'
name='org_slug_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
</div>

View File

@@ -390,11 +390,26 @@ export function TemplateProfile() {
disabled={isUploadingProfilePicture}
/>
</div>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
placeholder='Name'
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
className='h-9 flex-1'
name='profile_display_name'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
@@ -15,7 +15,11 @@ import {
useItemRename,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
import {
useCanDelete,
useDeleteFolder,
useDuplicateFolder,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/store'
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const createFolderMutation = useCreateFolder()
const userPermissions = useUserPermissionsContext()
const { canDeleteFolder } = useCanDelete({ workspaceId })
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableDuplicate={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDelete}
/>
{/* Delete Modal */}

View File

@@ -14,6 +14,7 @@ import {
useItemRename,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useCanDelete,
useDeleteWorkflow,
useDuplicateWorkflow,
useExportWorkflow,
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
// Can delete check hook
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
// Presence avatars state
const [hasAvatars, setHasAvatars] = useState(false)
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
// Check if the captured selection can be deleted
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
// If already selected with multiple selections, keep all selections
handleContextMenuBase(e)
},
[workflow.id, workflows, handleContextMenuBase]
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
)
// Rename hook
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
disableRename={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit}
disableExport={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
/>
{/* Delete Confirmation Modal */}

View File

@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
<ModalContent className='w-[500px]'>
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
<form ref={formRef} onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<form
ref={formRef}
onSubmit={handleSubmit}
className='flex min-h-0 flex-1 flex-col'
autoComplete='off'
>
<ModalBody>
<div className='space-y-[12px]'>
<div>
<Label
htmlFor='emails'
htmlFor='invite-field'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Email Addresses
</Label>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[4px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
{invalidEmails.map((email, index) => (
<EmailTag
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
/>
))}
<Input
id='emails'
id='invite-field'
name='invite_search_field'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export { useCanDelete } from './use-can-delete'
export { useDeleteFolder } from './use-delete-folder'
export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'

View File

@@ -0,0 +1,130 @@
import { useCallback, useMemo } from 'react'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface UseCanDeleteProps {
/**
* Current workspace ID
*/
workspaceId: string
}
interface UseCanDeleteReturn {
/**
* Checks if the given workflow IDs can be deleted.
* Returns false if deleting them would leave no workflows in the workspace.
*/
canDeleteWorkflows: (workflowIds: string[]) => boolean
/**
* Checks if the given folder can be deleted.
* Returns false if deleting it would leave no workflows in the workspace.
*/
canDeleteFolder: (folderId: string) => boolean
/**
* Total number of workflows in the workspace.
*/
totalWorkflows: number
}
/**
* Hook for checking if workflows or folders can be deleted.
* Prevents deletion if it would leave the workspace with no workflows.
*
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
*
* @param props - Hook configuration
* @returns Functions to check deletion eligibility
*/
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
/**
* Pre-computed data structures for efficient lookups
*/
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
useMemo(() => {
const workspaceWorkflows = Object.values(workflows).filter(
(w) => w.workspaceId === workspaceId
)
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
const byFolderId = new Map<string, number>()
for (const w of workspaceWorkflows) {
if (w.folderId) {
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
}
}
const childrenByParent = new Map<string, string[]>()
for (const folder of Object.values(folders)) {
if (folder.workspaceId === workspaceId && folder.parentId) {
const children = childrenByParent.get(folder.parentId) || []
children.push(folder.id)
childrenByParent.set(folder.parentId, children)
}
}
return {
totalWorkflows: workspaceWorkflows.length,
workflowIdSet: idSet,
workflowsByFolderId: byFolderId,
childFoldersByParentId: childrenByParent,
}
}, [workflows, folders, workspaceId])
/**
* Count workflows in a folder and all its subfolders recursively.
* Uses pre-computed maps for efficient lookups.
*/
const countWorkflowsInFolder = useCallback(
(folderId: string): number => {
let count = workflowsByFolderId.get(folderId) || 0
const childFolders = childFoldersByParentId.get(folderId)
if (childFolders) {
for (const childId of childFolders) {
count += countWorkflowsInFolder(childId)
}
}
return count
},
[workflowsByFolderId, childFoldersByParentId]
)
/**
* Check if the given workflow IDs can be deleted.
* Returns false if deleting would remove all workflows from the workspace.
*/
const canDeleteWorkflows = useCallback(
(workflowIds: string[]): boolean => {
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
// Must have at least one workflow remaining after deletion
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
},
[totalWorkflows, workflowIdSet]
)
/**
* Check if the given folder can be deleted.
* Empty folders are always deletable. Folders containing all workspace workflows are not.
*/
const canDeleteFolder = useCallback(
(folderId: string): boolean => {
const workflowsInFolder = countWorkflowsInFolder(folderId)
if (workflowsInFolder === 0) return true
return workflowsInFolder < totalWorkflows
},
[totalWorkflows, countWorkflowsInFolder]
)
return {
canDeleteWorkflows,
canDeleteFolder,
totalWorkflows,
}
}

View File

@@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn'
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
*/
const POPOVER_ITEM_BASE_CLASSES =
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75'
/**
* Variant-specific active state styles for popover items.
@@ -425,7 +425,10 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
* ```
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => {
(
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
ref
) => {
// Try to get context - if not available, we're outside Popover (shouldn't happen)
const context = React.useContext(PopoverContext)
const variant = context?.variant || 'default'
@@ -435,18 +438,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
return null
}
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
e.stopPropagation()
return
}
onClick?.(e)
}
return (
<div
className={cn(
POPOVER_ITEM_BASE_CLASSES,
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
!disabled &&
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
disabled && 'cursor-default opacity-50',
className
)}
ref={ref}
role='menuitem'
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
{...props}
>
{children}