Skip to content

Commit 314dcb0

Browse files
improvement(credentials): credentials invites, secrets tab wiring up (#4874)
* improvement(credentials): move away from invite notion * wire up secrets ui/ux * address comments * get consistent styling by removing emcninput + text area * styling consistency * remove fallback * address comment:
1 parent 91bfc08 commit 314dcb0

26 files changed

Lines changed: 1624 additions & 1201 deletions

File tree

apps/sim/app/api/credentials/[id]/members/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface RouteContext {
1818

1919
async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
2020
const [cred] = await db
21-
.select({ id: credential.id, workspaceId: credential.workspaceId })
21+
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
2222
.from(credential)
2323
.where(eq(credential.id, credentialId))
2424
.limit(1)
@@ -39,7 +39,7 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str
3939
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
4040
return null
4141
}
42-
return membership
42+
return { ...membership, credentialType: cred.type }
4343
}
4444

4545
export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
@@ -104,6 +104,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
104104
if (!admin) {
105105
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
106106
}
107+
if (admin.credentialType === 'env_personal') {
108+
return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 })
109+
}
107110

108111
const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context)
109112
if (!parsed.success) return parsed.response

apps/sim/app/api/credentials/route.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
3+
import { account, credential, credentialMember } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { getPostgresErrorCode } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
@@ -22,7 +22,7 @@ import {
2222
normalizeAtlassianDomain,
2323
validateAtlassianServiceAccount,
2424
} from '@/lib/credentials/atlassian-service-account'
25-
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
25+
import { getWorkspaceMembership } from '@/lib/credentials/environment'
2626
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
2727
import { getServiceConfigByProviderId } from '@/lib/oauth'
2828
import {
@@ -498,11 +498,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
498498

499499
const now = new Date()
500500
const credentialId = generateId()
501-
const [workspaceRow] = await db
502-
.select({ ownerId: workspace.ownerId })
503-
.from(workspace)
504-
.where(eq(workspace.id, workspaceId))
505-
.limit(1)
501+
const {
502+
ownerId: workspaceOwnerId,
503+
memberUserIds: workspaceMemberUserIds,
504+
adminUserIds: workspaceAdminUserIds,
505+
} = await getWorkspaceMembership(workspaceId)
506506

507507
await db.transaction(async (tx) => {
508508
// service_account has no DB-level unique index on (workspaceId, providerId,
@@ -534,18 +534,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
534534
updatedAt: now,
535535
})
536536

537-
if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
538-
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
539-
if (workspaceUserIds.length > 0) {
540-
for (const memberUserId of workspaceUserIds) {
537+
if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) {
538+
if (workspaceMemberUserIds.length > 0) {
539+
for (const memberUserId of workspaceMemberUserIds) {
540+
const isAdmin =
541+
memberUserId === session.user.id || workspaceAdminUserIds.has(memberUserId)
541542
await tx.insert(credentialMember).values({
542543
id: generateId(),
543544
credentialId,
544545
userId: memberUserId,
545-
role:
546-
memberUserId === workspaceRow.ownerId || memberUserId === session.user.id
547-
? 'admin'
548-
: 'member',
546+
role: isAdmin ? 'admin' : 'member',
549547
status: 'active',
550548
joinedAt: now,
551549
invitedBy: session.user.id,

apps/sim/app/api/workspaces/[id]/environment/route.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1818
import {
1919
createWorkspaceEnvCredentials,
2020
deleteWorkspaceEnvCredentials,
21+
getWorkspaceEnvKeyAdminAccess,
2122
} from '@/lib/credentials/environment'
2223
import {
2324
getPersonalAndWorkspaceEnv,
@@ -91,15 +92,46 @@ export const PUT = withRouteHandler(
9192
}
9293

9394
const userId = session.user.id
94-
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
95-
if (!permission || (permission !== 'admin' && permission !== 'write')) {
96-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
97-
}
9895

9996
const parsed = await parseRequest(upsertWorkspaceEnvironmentContract, request, context)
10097
if (!parsed.success) return parsed.response
10198
const { variables } = parsed.data.body
10299

100+
// Caller must have workspace access at all (blocks non-member writes);
101+
// per-key gating below then requires credential-admin to edit existing
102+
// secrets and write/admin to add brand-new keys.
103+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
104+
if (!permission) {
105+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
106+
}
107+
108+
const incomingKeys = Object.keys(variables)
109+
if (incomingKeys.length === 0) {
110+
return NextResponse.json({ success: true })
111+
}
112+
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
113+
workspaceId,
114+
envKeys: incomingKeys,
115+
userId,
116+
})
117+
const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
118+
if (forbiddenExisting.length > 0) {
119+
return NextResponse.json(
120+
{ error: 'You must be an admin of these secrets to edit them' },
121+
{ status: 403 }
122+
)
123+
}
124+
if (
125+
incomingKeys.some((k) => !knownKeys.has(k)) &&
126+
permission !== 'admin' &&
127+
permission !== 'write'
128+
) {
129+
return NextResponse.json(
130+
{ error: 'Write access is required to add new secrets' },
131+
{ status: 403 }
132+
)
133+
}
134+
103135
const encryptedIncoming = await Promise.all(
104136
Object.entries(variables).map(async ([key, value]) => {
105137
const { encrypted } = await encryptSecret(value)
@@ -184,15 +216,38 @@ export const DELETE = withRouteHandler(
184216
}
185217

186218
const userId = session.user.id
187-
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
188-
if (!permission || (permission !== 'admin' && permission !== 'write')) {
189-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
190-
}
191219

192220
const parsed = await parseRequest(removeWorkspaceEnvironmentContract, request, context)
193221
if (!parsed.success) return parsed.response
194222
const { keys } = parsed.data.body
195223

224+
// Caller must have workspace access at all; deleting an existing secret then
225+
// requires being its credential admin, while a key with no credential yet
226+
// (legacy) falls back to workspace write/admin.
227+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
228+
if (!permission) {
229+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
230+
}
231+
232+
const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
233+
workspaceId,
234+
envKeys: keys,
235+
userId,
236+
})
237+
const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
238+
if (forbiddenExisting.length > 0) {
239+
return NextResponse.json(
240+
{ error: 'You must be an admin of these secrets to delete them' },
241+
{ status: 403 }
242+
)
243+
}
244+
if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') {
245+
return NextResponse.json(
246+
{ error: 'Write access is required to remove these secrets' },
247+
{ status: 403 }
248+
)
249+
}
250+
196251
const result = await db.transaction(async (tx) => {
197252
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`)
198253

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use client'
2+
3+
import { useCallback, useMemo, useState } from 'react'
4+
import { createLogger } from '@sim/logger'
5+
import { getErrorMessage } from '@sim/utils/errors'
6+
import {
7+
Chip,
8+
ChipModal,
9+
ChipModalBody,
10+
ChipModalField,
11+
ChipModalFooter,
12+
ChipModalHeader,
13+
toast,
14+
} from '@/components/emcn'
15+
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
16+
import {
17+
useUpsertWorkspaceCredentialMember,
18+
useWorkspaceCredentialMembers,
19+
type WorkspaceCredentialRole,
20+
} from '@/hooks/queries/credentials'
21+
import { ROLE_OPTIONS } from '../roles'
22+
import { partitionSettledFailures, resolveAddEmail } from '../sharing'
23+
24+
const logger = createLogger('AddPeopleModal')
25+
26+
interface AddPeopleModalProps {
27+
credentialId: string
28+
open: boolean
29+
onOpenChange: (open: boolean) => void
30+
}
31+
32+
/**
33+
* Shared "Add people" modal: grants existing workspace members access to a
34+
* credential with a chosen role. Emails are validated against the workspace
35+
* roster and current membership; each add is an idempotent upsert and partial
36+
* failures keep only the people that still need adding.
37+
*/
38+
export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
39+
const { workspacePermissions } = useWorkspacePermissionsContext()
40+
const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
41+
const upsertMember = useUpsertWorkspaceCredentialMember()
42+
43+
const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
44+
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
45+
const [isAdding, setIsAdding] = useState(false)
46+
47+
const workspaceUserIdByEmail = useMemo(
48+
() =>
49+
new Map(
50+
(workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
51+
),
52+
[workspacePermissions?.users]
53+
)
54+
55+
const existingMemberEmails = useMemo(
56+
() =>
57+
new Set(
58+
members
59+
.filter((member) => member.status === 'active')
60+
.map((member) => (member.userEmail ?? '').toLowerCase())
61+
.filter(Boolean)
62+
),
63+
[members]
64+
)
65+
66+
const validateAddEmail = useCallback(
67+
(email: string): string | null => {
68+
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
69+
return 'error' in result ? result.error : null
70+
},
71+
[workspaceUserIdByEmail, existingMemberEmails]
72+
)
73+
74+
const handleClose = useCallback(() => {
75+
setEmailsToAdd([])
76+
setRoleToAdd('member')
77+
onOpenChange(false)
78+
}, [onOpenChange])
79+
80+
const handleAddPeople = useCallback(async () => {
81+
if (emailsToAdd.length === 0 || isAdding) return
82+
const targets = emailsToAdd
83+
.map((email) => {
84+
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
85+
return 'userId' in result ? { email, userId: result.userId } : null
86+
})
87+
.filter((target): target is { email: string; userId: string } => target !== null)
88+
if (targets.length === 0) return
89+
90+
setIsAdding(true)
91+
try {
92+
const results = await Promise.allSettled(
93+
targets.map((target) =>
94+
upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
95+
)
96+
)
97+
const failures = partitionSettledFailures(targets, results)
98+
if (failures.length === 0) {
99+
handleClose()
100+
return
101+
}
102+
setEmailsToAdd(failures.map((target) => target.email))
103+
const firstError = results.find(
104+
(result): result is PromiseRejectedResult => result.status === 'rejected'
105+
)
106+
logger.error('Failed to add some credential members', firstError?.reason)
107+
toast.error(
108+
failures.length === targets.length
109+
? "Couldn't add people"
110+
: `Couldn't add ${failures.length} of ${targets.length} people`,
111+
{ description: getErrorMessage(firstError?.reason, 'Please try again in a moment.') }
112+
)
113+
} finally {
114+
setIsAdding(false)
115+
}
116+
}, [
117+
credentialId,
118+
emailsToAdd,
119+
isAdding,
120+
workspaceUserIdByEmail,
121+
existingMemberEmails,
122+
roleToAdd,
123+
upsertMember,
124+
handleClose,
125+
])
126+
127+
return (
128+
<ChipModal
129+
open={open}
130+
onOpenChange={(next) => {
131+
if (!next) handleClose()
132+
}}
133+
srTitle='Add people'
134+
>
135+
<ChipModalHeader onClose={handleClose}>Add people</ChipModalHeader>
136+
<ChipModalBody>
137+
<ChipModalField
138+
type='emails'
139+
title='Emails'
140+
value={emailsToAdd}
141+
onChange={setEmailsToAdd}
142+
validate={validateAddEmail}
143+
placeholder='Enter emails'
144+
disabled={isAdding}
145+
/>
146+
<ChipModalField
147+
type='dropdown'
148+
title='Role'
149+
options={ROLE_OPTIONS}
150+
value={roleToAdd}
151+
placeholder='Select role'
152+
align='start'
153+
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
154+
disabled={isAdding}
155+
/>
156+
</ChipModalBody>
157+
<ChipModalFooter>
158+
<Chip
159+
variant='primary'
160+
onClick={handleAddPeople}
161+
disabled={emailsToAdd.length === 0 || isAdding}
162+
>
163+
{isAdding ? 'Adding...' : 'Add'}
164+
</Chip>
165+
</ChipModalFooter>
166+
</ChipModal>
167+
)
168+
}

0 commit comments

Comments
 (0)