Skip to content

Commit 36022ce

Browse files
committed
fix(chat): show deployment passwords to admins
1 parent 8348592 commit 36022ce

13 files changed

Lines changed: 542 additions & 18 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
auditMock,
6+
auditMockFns,
7+
authMockFns,
8+
encryptionMock,
9+
encryptionMockFns,
10+
workflowsApiUtilsMock,
11+
workflowsApiUtilsMockFns,
12+
} from '@sim/testing'
13+
import { NextRequest } from 'next/server'
14+
import { beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
const { mockCheckChatAccess } = vi.hoisted(() => ({
17+
mockCheckChatAccess: vi.fn(),
18+
}))
19+
20+
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
21+
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
22+
const mockRecordAudit = auditMockFns.mockRecordAudit
23+
24+
vi.mock('@sim/audit', () => auditMock)
25+
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
26+
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
27+
vi.mock('@/app/api/chat/utils', () => ({
28+
checkChatAccess: mockCheckChatAccess,
29+
}))
30+
31+
import { GET } from '@/app/api/chat/manage/[id]/password/route'
32+
33+
const passwordChat = {
34+
id: 'chat-123',
35+
workflowId: 'workflow-123',
36+
identifier: 'test-chat',
37+
title: 'Test Chat',
38+
authType: 'password',
39+
password: 'encrypted-password',
40+
}
41+
42+
function makeRequest() {
43+
return new NextRequest('http://localhost:3000/api/chat/manage/chat-123/password')
44+
}
45+
46+
function callGet() {
47+
return GET(makeRequest(), { params: Promise.resolve({ id: 'chat-123' }) })
48+
}
49+
50+
describe('Chat Password Reveal API Route', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
54+
authMockFns.mockGetSession.mockResolvedValue({
55+
user: { id: 'user-id', name: 'Test User', email: 'user@example.com' },
56+
})
57+
58+
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
59+
return new Response(JSON.stringify({ error: message }), {
60+
status,
61+
headers: { 'Content-Type': 'application/json' },
62+
})
63+
})
64+
65+
mockDecryptSecret.mockResolvedValue({ decrypted: 'super-secret' })
66+
mockCheckChatAccess.mockResolvedValue({
67+
hasAccess: true,
68+
chat: passwordChat,
69+
workspaceId: 'workspace-123',
70+
})
71+
})
72+
73+
it('should return 401 when user is not authenticated', async () => {
74+
authMockFns.mockGetSession.mockResolvedValue(null)
75+
76+
const response = await callGet()
77+
78+
expect(response.status).toBe(401)
79+
const data = await response.json()
80+
expect(data.error).toBe('Unauthorized')
81+
expect(mockDecryptSecret).not.toHaveBeenCalled()
82+
})
83+
84+
it('should return 404 when chat not found or access denied', async () => {
85+
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
86+
87+
const response = await callGet()
88+
89+
expect(response.status).toBe(404)
90+
const data = await response.json()
91+
expect(data.error).toBe('Chat not found or access denied')
92+
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
93+
expect(mockDecryptSecret).not.toHaveBeenCalled()
94+
})
95+
96+
it('should return 404 when the chat has no password set', async () => {
97+
mockCheckChatAccess.mockResolvedValue({
98+
hasAccess: true,
99+
chat: { ...passwordChat, authType: 'public', password: null },
100+
workspaceId: 'workspace-123',
101+
})
102+
103+
const response = await callGet()
104+
105+
expect(response.status).toBe(404)
106+
const data = await response.json()
107+
expect(data.error).toBe('This chat does not have a password set')
108+
expect(mockDecryptSecret).not.toHaveBeenCalled()
109+
})
110+
111+
it('should return the decrypted password and record an audit event', async () => {
112+
const response = await callGet()
113+
114+
expect(response.status).toBe(200)
115+
const data = await response.json()
116+
expect(data.password).toBe('super-secret')
117+
expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-password')
118+
expect(mockRecordAudit).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
workspaceId: 'workspace-123',
121+
actorId: 'user-id',
122+
action: 'chat.password_viewed',
123+
resourceId: 'chat-123',
124+
})
125+
)
126+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
127+
})
128+
129+
it('should return 500 when decryption fails', async () => {
130+
mockDecryptSecret.mockRejectedValue(new Error('Decryption failed'))
131+
132+
const response = await callGet()
133+
134+
expect(response.status).toBe(500)
135+
const data = await response.json()
136+
expect(data.error).toBe('Decryption failed')
137+
expect(mockRecordAudit).not.toHaveBeenCalled()
138+
})
139+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { getChatPasswordContract } from '@/lib/api/contracts/chats'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { decryptSecret } from '@/lib/core/security/encryption'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { checkChatAccess } from '@/app/api/chat/utils'
12+
import { createErrorResponse } from '@/app/api/workflows/utils'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
const logger = createLogger('ChatPasswordAPI')
17+
const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const
18+
19+
/**
20+
* GET endpoint that reveals a chat deployment's current password.
21+
* Restricted to workspace admins (checkChatAccess requires admin permission
22+
* on the workflow's workspace); each reveal is recorded in the audit log.
23+
*/
24+
export const GET = withRouteHandler(
25+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
26+
try {
27+
const session = await getSession()
28+
29+
if (!session) {
30+
return createErrorResponse('Unauthorized', 401)
31+
}
32+
33+
const parsed = await parseRequest(getChatPasswordContract, request, context)
34+
if (!parsed.success) return parsed.response
35+
36+
const { id: chatId } = parsed.data.params
37+
38+
const {
39+
hasAccess,
40+
chat: chatRecord,
41+
workspaceId: chatWorkspaceId,
42+
} = await checkChatAccess(chatId, session.user.id)
43+
44+
if (!hasAccess || !chatRecord) {
45+
return createErrorResponse('Chat not found or access denied', 404)
46+
}
47+
48+
if (chatRecord.authType !== 'password' || !chatRecord.password) {
49+
return createErrorResponse('This chat does not have a password set', 404)
50+
}
51+
52+
const { decrypted } = await decryptSecret(chatRecord.password)
53+
54+
recordAudit({
55+
workspaceId: chatWorkspaceId || null,
56+
actorId: session.user.id,
57+
actorName: session.user.name,
58+
actorEmail: session.user.email,
59+
action: AuditAction.CHAT_PASSWORD_VIEWED,
60+
resourceType: AuditResourceType.CHAT,
61+
resourceId: chatId,
62+
resourceName: chatRecord.title,
63+
description: `Viewed the password for chat deployment "${chatRecord.title}"`,
64+
metadata: {
65+
identifier: chatRecord.identifier,
66+
workflowId: chatRecord.workflowId,
67+
},
68+
request,
69+
})
70+
71+
return NextResponse.json({ password: decrypted }, { headers: PRIVATE_NO_STORE })
72+
} catch (error) {
73+
logger.error('Error revealing chat password:', error)
74+
return createErrorResponse(getErrorMessage(error, 'Failed to reveal chat password'), 500)
75+
}
76+
}
77+
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type ChatFormData,
3232
useCreateChat,
3333
useDeleteChat,
34+
useRevealChatPassword,
3435
useUpdateChat,
3536
} from '@/hooks/queries/chats'
3637
import type { ChatDetail } from '@/hooks/queries/deployments'
@@ -41,6 +42,7 @@ import {
4142
getPasswordPlaceholder,
4243
hasExistingPassword,
4344
isPasswordRequired,
45+
shouldConfirmPasswordChange,
4446
} from './utils'
4547

4648
const logger = createLogger('ChatDeploy')
@@ -57,6 +59,7 @@ interface ChatDeployProps {
5759
onRefetchChat: () => Promise<void>
5860
chatSubmitting: boolean
5961
setChatSubmitting: (submitting: boolean) => void
62+
canRevealPassword: boolean
6063
onValidationChange?: (isValid: boolean) => void
6164
showDeleteConfirmation?: boolean
6265
setShowDeleteConfirmation?: (show: boolean) => void
@@ -97,6 +100,7 @@ export function ChatDeploy({
97100
onRefetchChat,
98101
chatSubmitting,
99102
setChatSubmitting,
103+
canRevealPassword,
100104
onValidationChange,
101105
showDeleteConfirmation: externalShowDeleteConfirmation,
102106
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
@@ -106,6 +110,7 @@ export function ChatDeploy({
106110
}: ChatDeployProps) {
107111
const [imageUrl, setImageUrl] = useState<string | null>(null)
108112
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
113+
const [showPasswordChangeConfirmation, setShowPasswordChangeConfirmation] = useState(false)
109114

110115
const showDeleteConfirmation =
111116
externalShowDeleteConfirmation !== undefined
@@ -213,9 +218,7 @@ export function ChatDeploy({
213218
}
214219
}, [existingChat, isLoadingChat])
215220

216-
const handleSubmit = async (e?: React.FormEvent) => {
217-
if (e) e.preventDefault()
218-
221+
const submitChat = async (passwordChangeConfirmed = false) => {
219222
if (chatSubmitting) return
220223

221224
setChatSubmitting(true)
@@ -227,14 +230,20 @@ export function ChatDeploy({
227230
try {
228231
if (!validateForm()) {
229232
newTab?.close()
230-
setChatSubmitting(false)
231233
return
232234
}
233235

234236
if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
235237
newTab?.close()
236238
setError('identifier', 'Please wait for identifier validation to complete')
237-
setChatSubmitting(false)
239+
return
240+
}
241+
242+
if (
243+
!passwordChangeConfirmed &&
244+
shouldConfirmPasswordChange(Boolean(existingChat?.id), formData.authType, formData.password)
245+
) {
246+
setShowPasswordChangeConfirmation(true)
238247
return
239248
}
240249

@@ -283,6 +292,11 @@ export function ChatDeploy({
283292
}
284293
}
285294

295+
const handleSubmit = async (event: React.FormEvent) => {
296+
event.preventDefault()
297+
await submitChat()
298+
}
299+
286300
const handleDelete = async () => {
287301
if (!existingChat || !existingChat.id) return
288302

@@ -306,6 +320,11 @@ export function ChatDeploy({
306320
}
307321
}
308322

323+
const handleConfirmPasswordChange = async () => {
324+
setShowPasswordChangeConfirmation(false)
325+
await submitChat(true)
326+
}
327+
309328
if (isLoadingChat) {
310329
return <LoadingSkeleton />
311330
}
@@ -404,6 +423,8 @@ export function ChatDeploy({
404423

405424
<AuthSelector
406425
key={`${existingChat?.id ?? 'new'}-${formInitCounter}`}
426+
chatId={existingChat?.id ?? null}
427+
canRevealPassword={canRevealPassword}
407428
authType={formData.authType}
408429
savedAuthType={existingChat?.authType as AuthType | undefined}
409430
password={formData.password}
@@ -445,6 +466,21 @@ export function ChatDeploy({
445466
</div>
446467
</form>
447468

469+
<ChipConfirmModal
470+
open={showPasswordChangeConfirmation}
471+
onOpenChange={setShowPasswordChangeConfirmation}
472+
srTitle='Change deployment password'
473+
title='Change deployment password?'
474+
text='Are you sure you want to change the password for this deployment?'
475+
confirm={{
476+
label: 'Change Password and Redeploy',
477+
onClick: handleConfirmPasswordChange,
478+
variant: 'primary',
479+
pending: chatSubmitting,
480+
pendingLabel: 'Updating...',
481+
}}
482+
/>
483+
448484
<ChipConfirmModal
449485
open={showDeleteConfirmation}
450486
onOpenChange={setShowDeleteConfirmation}
@@ -617,6 +653,8 @@ function IdentifierInput({
617653
}
618654

619655
interface AuthSelectorProps {
656+
chatId: string | null
657+
canRevealPassword: boolean
620658
authType: AuthType
621659
/** The persisted mode of an existing chat, kept selectable even if newly disallowed. */
622660
savedAuthType?: AuthType
@@ -638,6 +676,8 @@ const AUTH_LABELS: Record<AuthType, string> = {
638676
}
639677

640678
function AuthSelector({
679+
chatId,
680+
canRevealPassword,
641681
authType,
642682
savedAuthType,
643683
password,
@@ -651,6 +691,7 @@ function AuthSelector({
651691
}: AuthSelectorProps) {
652692
const [emailError, setEmailError] = useState('')
653693
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
694+
const revealPasswordMutation = useRevealChatPassword()
654695

655696
const emailsRef = useRef(emails)
656697
const invalidEmailItemsRef = useRef(invalidEmailItems)
@@ -756,9 +797,19 @@ function AuthSelector({
756797
value={password}
757798
onChange={onPasswordChange}
758799
disabled={disabled}
759-
placeholder={getPasswordPlaceholder(hasExistingPassword)}
800+
placeholder={hasExistingPassword ? '' : getPasswordPlaceholder(false)}
760801
required={!hasExistingPassword}
802+
fetchCurrentPassword={
803+
canRevealPassword && chatId && hasExistingPassword
804+
? () => revealPasswordMutation.mutateAsync({ chatId })
805+
: undefined
806+
}
761807
/>
808+
{canRevealPassword && revealPasswordMutation.isError && (
809+
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
810+
Failed to load the current password
811+
</p>
812+
)}
762813
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
763814
{getPasswordHelperText(hasExistingPassword)}
764815
</p>

0 commit comments

Comments
 (0)