Skip to content

Commit df5f823

Browse files
authored
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
1 parent 094f87f commit df5f823

File tree

14 files changed

+443
-20
lines changed

14 files changed

+443
-20
lines changed

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,23 @@ export async function DELETE(
141141
)
142142
}
143143

144+
// Check if deleting this folder would delete the last workflow(s) in the workspace
145+
const workflowsInFolder = await countWorkflowsInFolderRecursively(
146+
id,
147+
existingFolder.workspaceId
148+
)
149+
const totalWorkflowsInWorkspace = await db
150+
.select({ id: workflow.id })
151+
.from(workflow)
152+
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
153+
154+
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
155+
return NextResponse.json(
156+
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
157+
{ status: 400 }
158+
)
159+
}
160+
144161
// Recursively delete folder and all its contents
145162
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
146163

@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
202219
return stats
203220
}
204221

222+
/**
223+
* Counts the number of workflows in a folder and all its subfolders recursively.
224+
*/
225+
async function countWorkflowsInFolderRecursively(
226+
folderId: string,
227+
workspaceId: string
228+
): Promise<number> {
229+
let count = 0
230+
231+
const workflowsInFolder = await db
232+
.select({ id: workflow.id })
233+
.from(workflow)
234+
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
235+
236+
count += workflowsInFolder.length
237+
238+
const childFolders = await db
239+
.select({ id: workflowFolder.id })
240+
.from(workflowFolder)
241+
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
242+
243+
for (const childFolder of childFolders) {
244+
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
245+
}
246+
247+
return count
248+
}
249+
205250
// Helper function to check for circular references
206251
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
207252
let currentParentId: string | null = parentId

apps/sim/app/api/workflows/[id]/route.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
1414
const mockGetWorkflowAccessContext = vi.fn()
1515
const mockDbDelete = vi.fn()
1616
const mockDbUpdate = vi.fn()
17+
const mockDbSelect = vi.fn()
1718

1819
vi.mock('@/lib/auth', () => ({
1920
getSession: () => mockGetSession(),
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
4950
db: {
5051
delete: () => mockDbDelete(),
5152
update: () => mockDbUpdate(),
53+
select: () => mockDbSelect(),
5254
},
5355
workflow: {},
5456
}))
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
327329
isWorkspaceOwner: false,
328330
})
329331

332+
// Mock db.select() to return multiple workflows so deletion is allowed
333+
mockDbSelect.mockReturnValue({
334+
from: vi.fn().mockReturnValue({
335+
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
336+
}),
337+
})
338+
330339
mockDbDelete.mockReturnValue({
331340
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
332341
})
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
347356
expect(data.success).toBe(true)
348357
})
349358

359+
it('should prevent deletion of the last workflow in workspace', async () => {
360+
const mockWorkflow = {
361+
id: 'workflow-123',
362+
userId: 'user-123',
363+
name: 'Test Workflow',
364+
workspaceId: 'workspace-456',
365+
}
366+
367+
mockGetSession.mockResolvedValue({
368+
user: { id: 'user-123' },
369+
})
370+
371+
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
372+
mockGetWorkflowAccessContext.mockResolvedValue({
373+
workflow: mockWorkflow,
374+
workspaceOwnerId: 'workspace-456',
375+
workspacePermission: 'admin',
376+
isOwner: true,
377+
isWorkspaceOwner: false,
378+
})
379+
380+
// Mock db.select() to return only 1 workflow (the one being deleted)
381+
mockDbSelect.mockReturnValue({
382+
from: vi.fn().mockReturnValue({
383+
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
384+
}),
385+
})
386+
387+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
388+
method: 'DELETE',
389+
})
390+
const params = Promise.resolve({ id: 'workflow-123' })
391+
392+
const response = await DELETE(req, { params })
393+
394+
expect(response.status).toBe(400)
395+
const data = await response.json()
396+
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
397+
})
398+
350399
it.concurrent('should deny deletion for non-admin users', async () => {
351400
const mockWorkflow = {
352401
id: 'workflow-123',

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ export async function DELETE(
228228
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
229229
}
230230

231+
// Check if this is the last workflow in the workspace
232+
if (workflowData.workspaceId) {
233+
const totalWorkflowsInWorkspace = await db
234+
.select({ id: workflow.id })
235+
.from(workflow)
236+
.where(eq(workflow.workspaceId, workflowData.workspaceId))
237+
238+
if (totalWorkflowsInWorkspace.length <= 1) {
239+
return NextResponse.json(
240+
{ error: 'Cannot delete the only workflow in the workspace' },
241+
{ status: 400 }
242+
)
243+
}
244+
}
245+
231246
// Check if workflow has published templates before deletion
232247
const { searchParams } = new URL(request.url)
233248
const checkTemplates = searchParams.get('check-templates') === 'true'

apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,31 @@ export function CreateBaseModal({
339339
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
340340
<div className='space-y-[12px]'>
341341
<div className='flex flex-col gap-[8px]'>
342-
<Label htmlFor='name'>Name</Label>
342+
<Label htmlFor='kb-name'>Name</Label>
343+
{/* Hidden decoy fields to prevent browser autofill */}
344+
<input
345+
type='text'
346+
name='fakeusernameremembered'
347+
autoComplete='username'
348+
style={{
349+
position: 'absolute',
350+
left: '-9999px',
351+
opacity: 0,
352+
pointerEvents: 'none',
353+
}}
354+
tabIndex={-1}
355+
readOnly
356+
/>
343357
<Input
344-
id='name'
358+
id='kb-name'
345359
placeholder='Enter knowledge base name'
346360
{...register('name')}
347361
className={cn(errors.name && 'border-[var(--text-error)]')}
362+
autoComplete='off'
363+
autoCorrect='off'
364+
autoCapitalize='off'
365+
data-lpignore='true'
366+
data-form-type='other'
348367
/>
349368
</div>
350369

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
490490
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
491491
Enter a name for your API key to help you identify it later.
492492
</p>
493+
{/* Hidden decoy fields to prevent browser autofill */}
494+
<input
495+
type='text'
496+
name='fakeusernameremembered'
497+
autoComplete='username'
498+
style={{
499+
position: 'absolute',
500+
left: '-9999px',
501+
opacity: 0,
502+
pointerEvents: 'none',
503+
}}
504+
tabIndex={-1}
505+
readOnly
506+
/>
493507
<EmcnInput
494508
value={newKeyName}
495509
onChange={(e) => {
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
499513
placeholder='e.g., Development, Production'
500514
className='h-9'
501515
autoFocus
516+
name='api_key_label'
517+
autoComplete='off'
518+
autoCorrect='off'
519+
autoCapitalize='off'
520+
data-lpignore='true'
521+
data-form-type='other'
502522
/>
503523
{createError && (
504524
<p className='text-[11px] text-[var(--text-error)] leading-tight'>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
141141
{/* Main invitation input */}
142142
<div className='flex items-start gap-2'>
143143
<div className='flex-1'>
144+
{/* Hidden decoy fields to prevent browser autofill */}
145+
<input
146+
type='text'
147+
name='fakeusernameremembered'
148+
autoComplete='username'
149+
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
150+
tabIndex={-1}
151+
readOnly
152+
/>
153+
<input
154+
type='email'
155+
name='fakeemailremembered'
156+
autoComplete='email'
157+
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
158+
tabIndex={-1}
159+
readOnly
160+
/>
144161
<Input
145162
placeholder='Enter email address'
146163
value={inviteEmail}
147164
onChange={handleEmailChange}
148165
disabled={isInviting || !hasAvailableSeats}
149166
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
167+
name='member_invite_field'
168+
autoComplete='off'
169+
autoCorrect='off'
170+
autoCapitalize='off'
171+
spellCheck={false}
172+
data-lpignore='true'
173+
data-form-type='other'
174+
aria-autocomplete='none'
150175
/>
151176
{emailError && (
152177
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,31 @@ export function NoOrganizationView({
5555

5656
{/* Form fields - clean layout without card */}
5757
<div className='space-y-4'>
58+
{/* Hidden decoy field to prevent browser autofill */}
59+
<input
60+
type='text'
61+
name='fakeusernameremembered'
62+
autoComplete='username'
63+
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
64+
tabIndex={-1}
65+
readOnly
66+
/>
5867
<div>
59-
<Label htmlFor='orgName' className='font-medium text-[13px]'>
68+
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
6069
Team Name
6170
</Label>
6271
<Input
63-
id='orgName'
72+
id='team-name-field'
6473
value={orgName}
6574
onChange={onOrgNameChange}
6675
placeholder='My Team'
6776
className='mt-1'
77+
name='team_name_field'
78+
autoComplete='off'
79+
autoCorrect='off'
80+
autoCapitalize='off'
81+
data-lpignore='true'
82+
data-form-type='other'
6883
/>
6984
</div>
7085

@@ -116,31 +131,52 @@ export function NoOrganizationView({
116131
</ModalHeader>
117132

118133
<div className='space-y-4'>
134+
{/* Hidden decoy field to prevent browser autofill */}
135+
<input
136+
type='text'
137+
name='fakeusernameremembered'
138+
autoComplete='username'
139+
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
140+
tabIndex={-1}
141+
readOnly
142+
/>
119143
<div>
120-
<Label htmlFor='org-name' className='font-medium text-[13px]'>
144+
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
121145
Organization Name
122146
</Label>
123147
<Input
124-
id='org-name'
148+
id='org-name-field'
125149
placeholder='Enter organization name'
126150
value={orgName}
127151
onChange={onOrgNameChange}
128152
disabled={isCreatingOrg}
129153
className='mt-1'
154+
name='org_name_field'
155+
autoComplete='off'
156+
autoCorrect='off'
157+
autoCapitalize='off'
158+
data-lpignore='true'
159+
data-form-type='other'
130160
/>
131161
</div>
132162

133163
<div>
134-
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
164+
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
135165
Organization Slug
136166
</Label>
137167
<Input
138-
id='org-slug'
168+
id='org-slug-field'
139169
placeholder='organization-slug'
140170
value={orgSlug}
141171
onChange={(e) => setOrgSlug(e.target.value)}
142172
disabled={isCreatingOrg}
143173
className='mt-1'
174+
name='org_slug_field'
175+
autoComplete='off'
176+
autoCorrect='off'
177+
autoCapitalize='off'
178+
data-lpignore='true'
179+
data-form-type='other'
144180
/>
145181
</div>
146182
</div>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,26 @@ export function TemplateProfile() {
390390
disabled={isUploadingProfilePicture}
391391
/>
392392
</div>
393+
{/* Hidden decoy field to prevent browser autofill */}
394+
<input
395+
type='text'
396+
name='fakeusernameremembered'
397+
autoComplete='username'
398+
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
399+
tabIndex={-1}
400+
readOnly
401+
/>
393402
<Input
394403
placeholder='Name'
395404
value={formData.name}
396405
onChange={(e) => updateField('name', e.target.value)}
397406
className='h-9 flex-1'
407+
name='profile_display_name'
408+
autoComplete='off'
409+
autoCorrect='off'
410+
autoCapitalize='off'
411+
data-lpignore='true'
412+
data-form-type='other'
398413
/>
399414
</div>
400415
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}

0 commit comments

Comments
 (0)