diff --git a/apps/sim/app/api/chat/manage/[id]/password/route.test.ts b/apps/sim/app/api/chat/manage/[id]/password/route.test.ts new file mode 100644 index 00000000000..e366fe5b73e --- /dev/null +++ b/apps/sim/app/api/chat/manage/[id]/password/route.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + auditMockFns, + authMockFns, + encryptionMock, + encryptionMockFns, + workflowsApiUtilsMock, + workflowsApiUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckChatAccess } = vi.hoisted(() => ({ + mockCheckChatAccess: vi.fn(), +})) + +const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse +const mockDecryptSecret = encryptionMockFns.mockDecryptSecret +const mockRecordAudit = auditMockFns.mockRecordAudit + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/app/api/chat/utils', () => ({ + checkChatAccess: mockCheckChatAccess, +})) + +import { GET } from '@/app/api/chat/manage/[id]/password/route' + +const passwordChat = { + id: 'chat-123', + workflowId: 'workflow-123', + identifier: 'test-chat', + title: 'Test Chat', + authType: 'password', + password: 'encrypted-password', +} + +function makeRequest() { + return new NextRequest('http://localhost:3000/api/chat/manage/chat-123/password') +} + +function callGet() { + return GET(makeRequest(), { params: Promise.resolve({ id: 'chat-123' }) }) +} + +describe('Chat Password Reveal API Route', () => { + beforeEach(() => { + vi.clearAllMocks() + + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id', name: 'Test User', email: 'user@example.com' }, + }) + + mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + mockDecryptSecret.mockResolvedValue({ decrypted: 'super-secret' }) + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: passwordChat, + workspaceId: 'workspace-123', + }) + }) + + it('should return 401 when user is not authenticated', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await callGet() + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('should return 404 when chat not found or access denied', async () => { + mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + + const response = await callGet() + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Chat not found or access denied') + expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('should return 404 when the chat has no password set', async () => { + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { ...passwordChat, authType: 'public', password: null }, + workspaceId: 'workspace-123', + }) + + const response = await callGet() + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('This chat does not have a password set') + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('should return the decrypted password and record an audit event', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.password).toBe('super-secret') + expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-password') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-123', + actorId: 'user-id', + action: 'chat.password_viewed', + resourceId: 'chat-123', + }) + ) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('should return 500 without echoing the decryption error', async () => { + mockDecryptSecret.mockRejectedValue( + new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + ) + + const response = await callGet() + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Failed to reveal chat password') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/chat/manage/[id]/password/route.ts b/apps/sim/app/api/chat/manage/[id]/password/route.ts new file mode 100644 index 00000000000..ddc88e9594e --- /dev/null +++ b/apps/sim/app/api/chat/manage/[id]/password/route.ts @@ -0,0 +1,81 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getChatPasswordContract } from '@/lib/api/contracts/chats' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { decryptSecret } from '@/lib/core/security/encryption' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkChatAccess } from '@/app/api/chat/utils' +import { createErrorResponse } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ChatPasswordAPI') +const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const + +/** + * GET endpoint that reveals a chat deployment's current password. + * Restricted to workspace admins (checkChatAccess requires admin permission + * on the workflow's workspace); each reveal is recorded in the audit log. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const parsed = await parseRequest(getChatPasswordContract, request, context) + if (!parsed.success) return parsed.response + + const { id: chatId } = parsed.data.params + + const { + hasAccess, + chat: chatRecord, + workspaceId: chatWorkspaceId, + } = await checkChatAccess(chatId, session.user.id) + + if (!hasAccess || !chatRecord) { + return createErrorResponse('Chat not found or access denied', 404) + } + + if (chatRecord.authType !== 'password' || !chatRecord.password) { + return createErrorResponse('This chat does not have a password set', 404) + } + + const { decrypted } = await decryptSecret(chatRecord.password) + + recordAudit({ + workspaceId: chatWorkspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_PASSWORD_VIEWED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: chatRecord.title, + description: `Viewed the password for chat deployment "${chatRecord.title}"`, + metadata: { + identifier: chatRecord.identifier, + workflowId: chatRecord.workflowId, + }, + request, + }) + + return NextResponse.json({ password: decrypted }, { headers: PRIVATE_NO_STORE }) + } catch (error) { + logger.error('Error revealing chat password:', error) + /** + * Deliberately opaque: the only errors that reach here come from + * decryption, whose messages describe the stored ciphertext's shape. + * The logged error carries the detail for operators. + */ + return createErrorResponse('Failed to reveal chat password', 500) + } + } +) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 81ab09ae02e..04a68cb661d 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -429,6 +429,24 @@ describe('Chat Edit API Route', () => { expect(data.error).toBe('Password is required when using password protection') }) + it('rejects a whitespace-only replacement password', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'password', password: ' ' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Password cannot contain only whitespace') + expect(mockCheckChatAccess).not.toHaveBeenCalled() + expect(mockEncryptSecret).not.toHaveBeenCalled() + }) + it('should keep the existing password when updating a password-protected chat', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' }, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index ec2a8ede3dc..af90c4fedeb 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -241,7 +241,13 @@ export const PATCH = withRouteHandler( } } - if (encryptedPassword) { + /** + * Only store a new password when the chat ends up password-protected. + * Applying it unconditionally re-armed the secret that the branch above + * just cleared, so `PATCH { authType: 'email', password }` persisted an + * encrypted password on an email-gated chat. + */ + if (encryptedPassword && (authType ?? existingChat[0].authType) === 'password') { updateData.password = encryptedPassword } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index f08467fe3e6..cf69b197258 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -31,6 +31,7 @@ import { type ChatFormData, useCreateChat, useDeleteChat, + useRevealChatPassword, useUpdateChat, } from '@/hooks/queries/chats' import type { ChatDetail } from '@/hooks/queries/deployments' @@ -41,6 +42,8 @@ import { getPasswordPlaceholder, hasExistingPassword, isPasswordRequired, + isWhitespaceOnlyPassword, + shouldConfirmPasswordChange, } from './utils' const logger = createLogger('ChatDeploy') @@ -57,6 +60,7 @@ interface ChatDeployProps { onRefetchChat: () => Promise chatSubmitting: boolean setChatSubmitting: (submitting: boolean) => void + canRevealPassword: boolean onValidationChange?: (isValid: boolean) => void showDeleteConfirmation?: boolean setShowDeleteConfirmation?: (show: boolean) => void @@ -97,6 +101,7 @@ export function ChatDeploy({ onRefetchChat, chatSubmitting, setChatSubmitting, + canRevealPassword, onValidationChange, showDeleteConfirmation: externalShowDeleteConfirmation, setShowDeleteConfirmation: externalSetShowDeleteConfirmation, @@ -106,6 +111,7 @@ export function ChatDeploy({ }: ChatDeployProps) { const [imageUrl, setImageUrl] = useState(null) const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false) + const [showPasswordChangeConfirmation, setShowPasswordChangeConfirmation] = useState(false) const showDeleteConfirmation = externalShowDeleteConfirmation !== undefined @@ -153,6 +159,8 @@ export function ChatDeploy({ if (isPasswordRequired(formData.authType, formData.password, existingPassword)) { newErrors.password = 'Password is required when using password protection' + } else if (formData.authType === 'password' && isWhitespaceOnlyPassword(formData.password)) { + newErrors.password = 'Password cannot contain only whitespace' } if ( @@ -175,6 +183,7 @@ export function ChatDeploy({ Boolean(formData.title.trim()) && formData.selectedOutputBlocks.length > 0 && !isPasswordRequired(formData.authType, formData.password, existingPassword) && + (formData.authType !== 'password' || !isWhitespaceOnlyPassword(formData.password)) && ((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0) useEffect(() => { @@ -213,9 +222,7 @@ export function ChatDeploy({ } }, [existingChat, isLoadingChat]) - const handleSubmit = async (e?: React.FormEvent) => { - if (e) e.preventDefault() - + const submitChat = async (passwordChangeConfirmed = false) => { if (chatSubmitting) return setChatSubmitting(true) @@ -227,14 +234,20 @@ export function ChatDeploy({ try { if (!validateForm()) { newTab?.close() - setChatSubmitting(false) return } if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) { newTab?.close() setError('identifier', 'Please wait for identifier validation to complete') - setChatSubmitting(false) + return + } + + if ( + !passwordChangeConfirmed && + shouldConfirmPasswordChange(existingPassword, formData.authType, formData.password) + ) { + setShowPasswordChangeConfirmation(true) return } @@ -283,6 +296,11 @@ export function ChatDeploy({ } } + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + await submitChat() + } + const handleDelete = async () => { if (!existingChat || !existingChat.id) return @@ -306,6 +324,11 @@ export function ChatDeploy({ } } + const handleConfirmPasswordChange = async () => { + setShowPasswordChangeConfirmation(false) + await submitChat(true) + } + if (isLoadingChat) { return } @@ -404,6 +427,8 @@ export function ChatDeploy({ + + = { } function AuthSelector({ + chatId, + canRevealPassword, authType, savedAuthType, password, @@ -651,10 +695,21 @@ function AuthSelector({ }: AuthSelectorProps) { const [emailError, setEmailError] = useState('') const [invalidEmailItems, setInvalidEmailItems] = useState([]) + 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 + * reporting a stale failure over a field the admin has already moved on from. + */ + const handlePasswordChange = (value: string) => { + if (revealPasswordMutation.isError) revealPasswordMutation.reset() + onPasswordChange(value) + } + useEffect(() => { emailsRef.current = emails }, [emails]) @@ -754,11 +809,21 @@ function AuthSelector({ revealPasswordMutation.mutateAsync({ chatId }) + : undefined + } /> + {canRevealPassword && revealPasswordMutation.isError && ( +

+ Failed to load the current password +

+ )}

{getPasswordHelperText(hasExistingPassword)}

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.test.ts index ce35a849135..eaff01650c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.test.ts @@ -4,6 +4,8 @@ import { getPasswordPlaceholder, hasExistingPassword, isPasswordRequired, + isWhitespaceOnlyPassword, + shouldConfirmPasswordChange, } from './utils' describe.concurrent('chat password state', () => { @@ -23,10 +25,29 @@ describe.concurrent('chat password state', () => { expect(isPasswordRequired('password', '', true)).toBe(false) }) + it('identifies whitespace-only password values', () => { + expect(isWhitespaceOnlyPassword(' ')).toBe(true) + expect(isWhitespaceOnlyPassword('')).toBe(false) + expect(isWhitespaceOnlyPassword(' password ')).toBe(false) + }) + it('returns copy that matches the stored-password state', () => { expect(getPasswordPlaceholder(true)).toBe('Enter new password to change') expect(getPasswordHelperText(true)).toBe('Leave empty to keep the current password') expect(getPasswordPlaceholder(false)).toBe('Enter password') expect(getPasswordHelperText(false)).toBe('This password will be required to access your chat') }) + + it('confirms only when a stored password is actually being replaced', () => { + expect(shouldConfirmPasswordChange(true, 'password', 'new-password')).toBe(true) + expect(shouldConfirmPasswordChange(true, 'password', '')).toBe(false) + expect(shouldConfirmPasswordChange(true, 'password', ' ')).toBe(false) + expect(shouldConfirmPasswordChange(true, 'public', 'new-password')).toBe(false) + }) + + it('does not confirm when the deployment has no password to replace', () => { + expect(shouldConfirmPasswordChange(false, 'password', 'new-password')).toBe(false) + expect(hasExistingPassword({ authType: 'public', hasPassword: false })).toBe(false) + expect(hasExistingPassword({ authType: 'password', hasPassword: true })).toBe(true) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.ts index bb1967aba9c..ee67d26baea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/utils.ts @@ -19,6 +19,24 @@ export function isPasswordRequired( return authType === 'password' && !existingPassword && !password.trim() } +export function isWhitespaceOnlyPassword(password: string): boolean { + return password.length > 0 && password.trim().length === 0 +} + +/** + * Whether submitting should confirm before overwriting the stored password. + * Only a genuine replacement warrants the prompt — keying this on "a chat + * exists" asked an admin to confirm changing a password that was never set, + * e.g. when switching a public chat to password protection for the first time. + */ +export function shouldConfirmPasswordChange( + existingPassword: boolean, + authType: AuthType, + password: string +): boolean { + return existingPassword && authType === 'password' && password.trim().length > 0 +} + export function getPasswordPlaceholder(existingPassword: boolean): string { return existingPassword ? 'Enter new password to change' : 'Enter password' } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index aa33d96cc92..d46e57a420c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -576,6 +576,7 @@ export function DeployModal({ onRefetchChat={handleRefetchChat} chatSubmitting={chatSubmitting} setChatSubmitting={setChatSubmitting} + canRevealPassword={userPermissions.canAdmin} onValidationChange={setIsChatFormValid} onDeploymentComplete={handleCloseModal} onDeployed={handleChatDeployed} diff --git a/apps/sim/components/ui/generated-password-input.test.tsx b/apps/sim/components/ui/generated-password-input.test.tsx new file mode 100644 index 00000000000..e50ad2d4823 --- /dev/null +++ b/apps/sim/components/ui/generated-password-input.test.tsx @@ -0,0 +1,192 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { GeneratedPasswordInput } from '@/components/ui/generated-password-input' + +const { mockCopy } = vi.hoisted(() => ({ + mockCopy: vi.fn(async () => true), +})) + +vi.mock('@sim/emcn', () => ({ + Button: ({ + children, + variant: _variant, + ...props + }: { + children?: ReactNode + variant?: string + } & ButtonHTMLAttributes) => , + ChipInput: ({ + endAdornment, + error: _error, + ...props + }: { + endAdornment?: ReactNode + error?: boolean + } & InputHTMLAttributes) => ( +
+ + {endAdornment} +
+ ), + Loader: () => , + Tooltip: { + Root: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children }: { children?: ReactNode }) => children, + Content: () => null, + }, + useCopyToClipboard: () => ({ copied: false, copy: mockCopy }), +})) + +let container: HTMLDivElement +let root: Root + +interface RenderInputOptions { + fetchCurrentPassword?: () => Promise + onChange?: (value: string) => void + showGenerate?: boolean + value?: string +} + +function renderInput({ + fetchCurrentPassword, + onChange = vi.fn(), + showGenerate = false, + value = '', +}: RenderInputOptions = {}) { + act(() => { + root.render( + + ) + }) +} + +function passwordInput(): HTMLInputElement { + const input = container.querySelector('[data-testid="password-input"]') + if (!input) throw new Error('Password input was not rendered') + return input +} + +function passwordButton(label: string): HTMLButtonElement { + const button = container.querySelector(`[aria-label="${label}"]`) + if (!button) throw new Error(`${label} button was not rendered`) + return button +} + +describe('GeneratedPasswordInput', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('shows a masked placeholder without fetching the saved password', () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + + renderInput({ fetchCurrentPassword }) + + expect(fetchCurrentPassword).not.toHaveBeenCalled() + expect(passwordInput()).toHaveAttribute('type', 'password') + expect(passwordInput()).toHaveValue('') + expect(passwordInput()).toHaveAttribute('placeholder', '••••••••') + }) + + it('fetches the saved password on reveal', async () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + + renderInput({ fetchCurrentPassword }) + await act(async () => passwordButton('Show password').click()) + + expect(fetchCurrentPassword).toHaveBeenCalledOnce() + expect(passwordInput()).toHaveAttribute('type', 'text') + expect(passwordInput()).toHaveValue('saved-secret') + }) + + it('discards the saved password when hidden and re-fetches on the next reveal', async () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + + renderInput({ fetchCurrentPassword }) + await act(async () => passwordButton('Show password').click()) + + act(() => passwordButton('Hide password').click()) + + expect(passwordInput()).toHaveAttribute('type', 'password') + expect(passwordInput()).toHaveValue('') + expect(passwordInput()).toHaveAttribute('placeholder', '••••••••') + expect(passwordButton('Copy password')).toBeDisabled() + + await act(async () => passwordButton('Show password').click()) + + expect(fetchCurrentPassword).toHaveBeenCalledTimes(2) + expect(passwordInput()).toHaveValue('saved-secret') + }) + + it('keeps an edited value when hidden', async () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + const onChange = vi.fn() + + renderInput({ fetchCurrentPassword, onChange, value: 'typed-secret' }) + await act(async () => passwordButton('Show password').click()) + act(() => passwordButton('Hide password').click()) + + expect(fetchCurrentPassword).not.toHaveBeenCalled() + expect(passwordInput()).toHaveValue('typed-secret') + }) + + it('stays masked when loading the saved password fails', async () => { + renderInput({ fetchCurrentPassword: vi.fn().mockRejectedValue(new Error('Failed')) }) + + await act(async () => passwordButton('Show password').click()) + + expect(passwordInput()).toHaveAttribute('type', 'password') + expect(passwordInput()).toHaveValue('') + expect(passwordInput()).toHaveAttribute('placeholder', '••••••••') + }) + + it('stays empty when no saved-password loader is provided', () => { + renderInput() + + expect(passwordInput()).toHaveAttribute('type', 'password') + expect(passwordInput()).toHaveValue('') + expect(passwordInput()).not.toHaveAttribute('placeholder', '••••••••') + }) + + it('keeps a generated password hidden when the field is hidden', () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + const onChange = vi.fn() + renderInput({ fetchCurrentPassword, onChange, showGenerate: true }) + + act(() => passwordButton('Generate password').click()) + + expect(fetchCurrentPassword).not.toHaveBeenCalled() + expect(passwordInput()).toHaveAttribute('type', 'password') + expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^.{24}$/)) + }) + + it('keeps a generated password visible when the field is visible', async () => { + const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret') + const onChange = vi.fn() + renderInput({ fetchCurrentPassword, onChange, showGenerate: true }) + + await act(async () => passwordButton('Show password').click()) + act(() => passwordButton('Generate password').click()) + + expect(passwordInput()).toHaveAttribute('type', 'text') + expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^.{24}$/)) + }) +}) diff --git a/apps/sim/components/ui/generated-password-input.tsx b/apps/sim/components/ui/generated-password-input.tsx index 9993878d60d..b8211479969 100644 --- a/apps/sim/components/ui/generated-password-input.tsx +++ b/apps/sim/components/ui/generated-password-input.tsx @@ -1,10 +1,12 @@ 'use client' -import { useEffect, useState } from 'react' -import { Button, ChipInput, Tooltip } from '@sim/emcn' +import { useState } from 'react' +import { Button, ChipInput, Loader, Tooltip, useCopyToClipboard } from '@sim/emcn' import { Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react' import { generatePassword } from '@/lib/core/security/encryption' +const MASKED_PASSWORD = '••••••••' + interface GeneratedPasswordInputProps { value: string onChange: (value: string) => void @@ -15,6 +17,11 @@ interface GeneratedPasswordInputProps { required?: boolean autoComplete?: string error?: boolean + /** + * Resolves the currently saved password when the Show toggle is clicked. + * While hidden, an empty field displays a masked placeholder. + */ + fetchCurrentPassword?: () => Promise } /** @@ -31,27 +38,59 @@ export function GeneratedPasswordInput({ required = false, autoComplete = 'new-password', error = false, + fetchCurrentPassword, }: GeneratedPasswordInputProps) { const [showPassword, setShowPassword] = useState(false) - const [copySuccess, setCopySuccess] = useState(false) + const [currentPassword, setCurrentPassword] = useState(null) + const [isFetchingCurrent, setIsFetchingCurrent] = useState(false) + const { copied, copy } = useCopyToClipboard() + + const displayValue = currentPassword ?? value + const displayPlaceholder = fetchCurrentPassword && !displayValue ? MASKED_PASSWORD : placeholder + + const handleChange = (nextValue: string) => { + setCurrentPassword(null) + onChange(nextValue) + } - useEffect(() => { - if (!copySuccess) return - const timer = setTimeout(() => setCopySuccess(false), 2000) - return () => clearTimeout(timer) - }, [copySuccess]) + const handleGeneratePassword = () => { + handleChange(generatePassword(24)) + } + + const toggleShowPassword = async () => { + if (showPassword) { + setShowPassword(false) + /** + * Discard the fetched password instead of masking it. Keeping it would + * leave the plaintext in the input's DOM value and keep Copy armed while + * the field reads as hidden. A later reveal re-fetches, which also keeps + * the audit log at one entry per disclosure. An edited value lives in + * `value` and is deliberately untouched. + */ + setCurrentPassword(null) + return + } + + if (!displayValue && fetchCurrentPassword) { + setIsFetchingCurrent(true) + try { + setCurrentPassword(await fetchCurrentPassword()) + } catch { + return + } finally { + setIsFetchingCurrent(false) + } + } - const copyToClipboard = () => { - navigator.clipboard.writeText(value) - setCopySuccess(true) + setShowPassword(true) } return ( onChange(e.target.value)} + placeholder={displayPlaceholder} + value={displayValue} + onChange={(e) => handleChange(e.target.value)} disabled={disabled} required={required} autoComplete={autoComplete} @@ -64,7 +103,7 @@ export function GeneratedPasswordInput({ - {copySuccess ? 'Copied' : 'Copy'} + {copied ? 'Copied' : 'Copy'} @@ -99,12 +138,18 @@ export function GeneratedPasswordInput({ diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index b0e4c37a4ab..ec69f65f270 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -11,6 +11,7 @@ import { type DeployedChatAuthBody, type DeployedChatConfig, deleteChatContract, + getChatPasswordContract, getDeployedChatConfigContract, requestChatEmailOtpContract, type UpdateChatBody, @@ -378,3 +379,30 @@ export function useDeleteChat() { }, }) } + +interface RevealChatPasswordVariables { + chatId: string +} + +/** + * Mutation hook that fetches a chat deployment's current password for workspace + * admins. Modeled as a mutation (despite the GET) because revealing a secret is + * an audited, explicitly-triggered action, not cacheable read state. + * + * `gcTime: 0` evicts the decrypted password from the mutation cache as soon as + * the last observer unmounts, rather than letting it sit there for the default + * five minutes after the deploy modal closes. While the modal is open the caller + * holds the plaintext anyway, and it discards it when the field is hidden. + */ +export function useRevealChatPassword() { + return useMutation({ + gcTime: 0, + mutationFn: async ({ chatId }: RevealChatPasswordVariables): Promise => { + const result = await requestJson(getChatPasswordContract, { params: { id: chatId } }) + return result.password + }, + onError: (error) => { + logger.error('Failed to reveal chat password', { error }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/chats.password.test.ts b/apps/sim/lib/api/contracts/chats.password.test.ts new file mode 100644 index 00000000000..3aad3347859 --- /dev/null +++ b/apps/sim/lib/api/contracts/chats.password.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + chatDeploymentPasswordSchema, + createChatBodySchema, + deployedChatAuthBodySchema, + deployedChatPostBodySchema, + updateChatBodySchema, +} from '@/lib/api/contracts/chats' + +const createBody = { + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { primaryColor: 'var(--brand-hover)', welcomeMessage: 'Hi' }, +} + +describe('chat deployment password contract', () => { + it('accepts the empty string, which means "keep the stored password"', () => { + expect(chatDeploymentPasswordSchema.safeParse('').success).toBe(true) + }) + + it('rejects a whitespace-only password', () => { + const result = chatDeploymentPasswordSchema.safeParse(' ') + expect(result.success).toBe(false) + expect(result.error?.issues[0].message).toBe('Password cannot contain only whitespace') + }) + + it('preserves surrounding whitespace, which login compares byte-exact', () => { + expect(chatDeploymentPasswordSchema.parse(' hunter2 ')).toBe(' hunter2 ') + }) + + /** + * The security-relevant invariant: a password long enough to save must still + * be short enough to submit. If the set path outgrew the login path, the + * deployment would be permanently unreachable — the login POST would 400 on + * length before authentication ever ran. + */ + it('caps length at the same boundary the deployed-chat login enforces', () => { + const atLimit = 'a'.repeat(1024) + const overLimit = 'a'.repeat(1025) + + expect(chatDeploymentPasswordSchema.safeParse(atLimit).success).toBe(true) + expect(chatDeploymentPasswordSchema.safeParse(overLimit).success).toBe(false) + + expect(deployedChatAuthBodySchema.safeParse({ password: atLimit }).success).toBe(true) + expect(deployedChatPostBodySchema.safeParse({ password: atLimit }).success).toBe(true) + }) + + it('applies to both the create and update bodies', () => { + const tooLong = 'a'.repeat(1025) + + expect(createChatBodySchema.safeParse({ ...createBody, password: ' ' }).success).toBe(false) + expect(createChatBodySchema.safeParse({ ...createBody, password: tooLong }).success).toBe(false) + expect(createChatBodySchema.safeParse({ ...createBody, password: 'ok' }).success).toBe(true) + + expect(updateChatBodySchema.safeParse({ password: ' ' }).success).toBe(false) + expect(updateChatBodySchema.safeParse({ password: tooLong }).success).toBe(false) + expect(updateChatBodySchema.safeParse({ password: '' }).success).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 528e20c5c28..2755536a5a2 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -4,6 +4,27 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const chatAuthTypeSchema = z.enum(['public', 'password', 'email', 'sso']) export type ChatAuthType = z.output +/** + * Shared cap for chat deployment passwords. The set path and the deployed-chat + * login path must agree: a password long enough to save but too long to submit + * would lock every visitor out of the deployment permanently. + */ +const MAX_CHAT_PASSWORD_CHARS = 1024 + +/** + * Password accepted when setting or changing a chat deployment's password. The + * empty string is allowed and means "keep the stored password"; a whitespace-only + * value is rejected because the login form refuses to submit one, which would + * strand the deployment behind an unenterable password. + */ +export const chatDeploymentPasswordSchema = z + .string() + .max(MAX_CHAT_PASSWORD_CHARS, 'Password is too long') + .refine( + (password) => password.length === 0 || password.trim().length > 0, + 'Password cannot contain only whitespace' + ) + export const chatIdParamsSchema = z.object({ id: z.string().min(1), }) @@ -38,7 +59,7 @@ export const createChatBodySchema = z.object({ description: z.string().optional(), customizations: chatCustomizationsSchema, authType: chatAuthTypeSchema.default('public'), - password: z.string().optional(), + password: chatDeploymentPasswordSchema.optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ @@ -59,7 +80,7 @@ export const updateChatBodySchema = z.object({ description: z.string().optional(), customizations: chatCustomizationsSchema.optional(), authType: chatAuthTypeSchema.optional(), - password: z.string().optional(), + password: chatDeploymentPasswordSchema.optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), includeThinking: z.boolean().optional(), @@ -86,6 +107,11 @@ export const deleteChatResponseSchema = z.object({ message: z.string(), }) +export const chatPasswordResponseSchema = z.object({ + password: z.string(), +}) +export type ChatPasswordResponse = z.output + export const deployedChatConfigSchema = z.object({ id: z.string(), title: z.string(), @@ -115,7 +141,7 @@ export const deployedChatConfigSchema = z.object({ export type DeployedChatConfig = z.output export const deployedChatAuthBodySchema = z.object({ - password: z.string().max(1024, 'Password is too long').optional(), + password: z.string().max(MAX_CHAT_PASSWORD_CHARS, 'Password is too long').optional(), email: z.string().email('Invalid email format').optional().or(z.literal('')), }) export type DeployedChatAuthBody = z.input @@ -137,7 +163,7 @@ export const deployedChatFileSchema = z.object({ export const deployedChatPostBodySchema = z.object({ input: z.string().max(MAX_CHAT_INPUT_CHARS, 'Input is too long').optional(), - password: z.string().max(1024, 'Password is too long').optional(), + password: z.string().max(MAX_CHAT_PASSWORD_CHARS, 'Password is too long').optional(), email: z.string().email('Invalid email format').optional().or(z.literal('')), conversationId: z.string().max(256, 'Conversation ID is too long').optional(), files: z @@ -292,3 +318,17 @@ export const deleteChatContract = defineRouteContract({ schema: deleteChatResponseSchema, }, }) + +/** + * Admin-only reveal of a chat deployment's current password. The route + * decrypts the stored password after re-verifying workspace admin access. + */ +export const getChatPasswordContract = defineRouteContract({ + method: 'GET', + path: '/api/chat/manage/[id]/password', + params: chatIdParamsSchema, + response: { + mode: 'json', + schema: chatPasswordResponseSchema, + }, +}) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts new file mode 100644 index 00000000000..7b48bab36e1 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkflowDeploymentSummary, mockPerformFullDeploy, mockCheckNeedsRedeployment } = + vi.hoisted(() => ({ + mockGetWorkflowDeploymentSummary: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockCheckNeedsRedeployment: vi.fn(), + })) + +vi.mock('@/lib/workflows/orchestration/deploy', () => ({ + getWorkflowDeploymentSummary: mockGetWorkflowDeploymentSummary, + performFullDeploy: mockPerformFullDeploy, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + checkNeedsRedeployment: mockCheckNeedsRedeployment, +})) + +import { performChatDeploy } from '@/lib/workflows/orchestration/chat-deploy' + +const basePayload = { + workflowId: 'workflow-1', + userId: 'user-1', + identifier: 'my-chat', + title: 'Support', +} + +describe('performChatDeploy password guards', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: { id: 'deployment-1' }, + latestDeploymentAttempt: { status: 'active' }, + warnings: [], + }) + mockCheckNeedsRedeployment.mockResolvedValue(false) + }) + + afterAll(() => { + resetDbChainMock() + }) + + /** + * The copilot `deploy_chat` tool reaches this function without a route + * contract, so these guards are the only thing standing between an agent and + * a deployment nobody can log into. + */ + it('rejects a whitespace-only password before touching the deployment', async () => { + await expect( + performChatDeploy({ ...basePayload, authType: 'password', password: ' ' }) + ).resolves.toEqual({ + success: false, + error: 'Password cannot contain only whitespace', + }) + + expect(mockGetWorkflowDeploymentSummary).not.toHaveBeenCalled() + }) + + it('rejects a password longer than the chat login accepts', async () => { + await expect( + performChatDeploy({ ...basePayload, authType: 'password', password: 'a'.repeat(1025) }) + ).resolves.toEqual({ + success: false, + error: 'Password is too long', + }) + + expect(mockGetWorkflowDeploymentSummary).not.toHaveBeenCalled() + }) + + it('rejects a whitespace-only password regardless of auth type', async () => { + await expect( + performChatDeploy({ ...basePayload, authType: 'public', password: ' ' }) + ).resolves.toEqual({ + success: false, + error: 'Password cannot contain only whitespace', + }) + }) + + it('rejects password protection with no password and no stored one', async () => { + queueTableRows(schemaMock.chat, []) + + await expect(performChatDeploy({ ...basePayload, authType: 'password' })).resolves.toEqual({ + success: false, + error: 'Password is required when using password protection', + }) + }) +}) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 3bfe35d3de7..106cb090df0 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -4,6 +4,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' +import { chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -68,6 +69,20 @@ export async function performChatDeploy( includeToolCalls = false, } = params + /** + * Validate the password here rather than only at the HTTP boundary. The + * copilot `deploy_chat` tool reaches this function without going through a + * route contract, so a whitespace-only or over-long password would otherwise + * be encrypted and stored — and neither can ever be submitted through the + * chat login form, permanently locking visitors out of the deployment. + */ + if (password !== undefined) { + const validatedPassword = chatDeploymentPasswordSchema.safeParse(password) + if (!validatedPassword.success) { + return { success: false, error: validatedPassword.error.issues[0].message } + } + } + const customizations = { primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)', welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?', @@ -125,6 +140,16 @@ export async function performChatDeploy( .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) .limit(1) + /** + * A password-protected chat must end up with a stored password. Both HTTP + * routes already reject this; without the same guard here a copilot + * `deploy_chat` call could create one with no password, which fails closed at + * login with an opaque "Authentication configuration error". + */ + if (authType === 'password' && !encryptedPassword && !existingDeployment?.password) { + return { success: false, error: 'Password is required when using password protection' } + } + let chatId: string if (existingDeployment) { chatId = existingDeployment.id diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 43b409bcc3d..cb94f7fa435 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -18,6 +18,7 @@ export const AuditAction = { CHAT_DEPLOYED: 'chat.deployed', CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + CHAT_PASSWORD_VIEWED: 'chat.password_viewed', // Custom Blocks (deploy-as-block) CUSTOM_BLOCK_PUBLISHED: 'custom_block.published', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 7c708b92e7d..377bbc4e6ec 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -40,6 +40,7 @@ export const auditMock = { CHAT_DEPLOYED: 'chat.deployed', CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + CHAT_PASSWORD_VIEWED: 'chat.password_viewed', CREDENTIAL_CREATED: 'credential.created', CREDENTIAL_UPDATED: 'credential.updated', CREDENTIAL_RENAMED: 'credential.renamed', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7c744ed7c6b..d799d13d14a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1003, - zodRoutes: 1003, + totalRoutes: 1004, + zodRoutes: 1004, nonZodRoutes: 0, } as const