chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,170 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
type WorkspaceCredentialRole,
} from '@/hooks/queries/credentials'
import { ROLE_OPTIONS } from '../roles'
import { partitionSettledFailures, resolveAddEmail } from '../sharing'
const logger = createLogger('AddPeopleModal')
interface AddPeopleModalProps {
credentialId: string
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Shared "Add people" modal: grants existing workspace members access to a
* credential with a chosen role. Emails are validated against the workspace
* roster and current membership; each add is an idempotent upsert and partial
* failures keep only the people that still need adding.
*/
export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
const { workspacePermissions } = useWorkspacePermissionsContext()
const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
const upsertMember = useUpsertWorkspaceCredentialMember()
const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
const [isAdding, setIsAdding] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const workspaceUserIdByEmail = useMemo(
() =>
new Map(
(workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
),
[workspacePermissions?.users]
)
const existingMemberEmails = useMemo(
() =>
new Set(
members
.filter((member) => member.status === 'active')
.map((member) => (member.userEmail ?? '').toLowerCase())
.filter(Boolean)
),
[members]
)
const validateAddEmail = useCallback(
(email: string): string | null => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'error' in result ? result.error : null
},
[workspaceUserIdByEmail, existingMemberEmails]
)
const handleClose = useCallback(() => {
setEmailsToAdd([])
setRoleToAdd('member')
setSubmitError(null)
onOpenChange(false)
}, [onOpenChange])
const handleAddPeople = useCallback(async () => {
if (emailsToAdd.length === 0 || isAdding) return
setSubmitError(null)
const targets = emailsToAdd
.map((email) => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
return 'userId' in result ? { email, userId: result.userId } : null
})
.filter((target): target is { email: string; userId: string } => target !== null)
if (targets.length === 0) return
setIsAdding(true)
try {
const results = await Promise.allSettled(
targets.map((target) =>
upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
)
)
const failures = partitionSettledFailures(targets, results)
if (failures.length === 0) {
handleClose()
return
}
setEmailsToAdd(failures.map((target) => target.email))
const firstError = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
logger.error('Failed to add some credential members', firstError?.reason)
const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.')
setSubmitError(
failures.length === targets.length
? `Couldn't add people. ${reason}`
: `Couldn't add ${failures.length} of ${targets.length} people. ${reason}`
)
} finally {
setIsAdding(false)
}
}, [
credentialId,
emailsToAdd,
isAdding,
workspaceUserIdByEmail,
existingMemberEmails,
roleToAdd,
upsertMember,
handleClose,
])
return (
<ChipModal
open={open}
onOpenChange={(next) => {
if (!next) handleClose()
}}
srTitle='Add people'
>
<ChipModalHeader onClose={handleClose}>Add people</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emailsToAdd}
onChange={setEmailsToAdd}
validate={validateAddEmail}
placeholder='Enter emails'
disabled={isAdding}
/>
<ChipModalField
type='dropdown'
title='Role'
options={ROLE_OPTIONS}
value={roleToAdd}
placeholder='Select role'
align='start'
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
disabled={isAdding}
/>
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
primaryAction={{
label: isAdding ? 'Adding...' : 'Add',
onClick: handleAddPeople,
disabled: emailsToAdd.length === 0 || isAdding,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,7 @@
import { chipFieldSurfaceClass, chipFieldTextClass, cn } from '@sim/emcn'
/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
export const CHIP_FIELD_SHELL = cn('flex h-[30px] items-center gap-1.5 px-2', chipFieldSurfaceClass)
/** Borderless input/textarea hosted inside {@link CHIP_FIELD_SHELL}. */
export const CHIP_FIELD_INPUT = cn('h-full w-full bg-transparent', chipFieldTextClass)
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
interface CredentialDetailHeadingProps {
/** Leading visual (icon tile or brand tile). */
leading: ReactNode
title: ReactNode
subtitle?: ReactNode
}
/**
* Header row shared by credential detail surfaces: a leading visual beside a
* title over a muted subtitle.
*/
export function CredentialDetailHeading({
leading,
title,
subtitle,
}: CredentialDetailHeadingProps) {
return (
<div className='flex items-center gap-2.5'>
{leading}
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
{subtitle ? (
<span className='truncate text-[12px] text-[var(--text-muted)]'>{subtitle}</span>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
interface CredentialDetailLayoutProps {
/** Back link rendered at the start of the fixed action bar. */
back: ReactNode
/** Optional controls grouped at the end of the action bar. */
actions?: ReactNode
children: ReactNode
}
/**
* Page shell shared by the credential detail surfaces: a fixed action bar
* (back link + grouped actions) above a scrollable, centered body. Surfaces
* supply the slots and body sections; all layout chrome lives here so callsites
* stay free of bespoke styling.
*/
export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
{back}
{actions ? <div className='flex items-center'>{actions}</div> : null}
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions'
import { getUserColor } from '@/lib/workspaces/colors'
import {
useRemoveWorkspaceCredentialMember,
useUpsertWorkspaceCredentialMember,
useWorkspaceCredentialMembers,
type WorkspaceCredentialRole,
} from '@/hooks/queries/credentials'
import { ROLE_OPTIONS } from '../roles'
import { DetailSection } from './detail-section'
const logger = createLogger('CredentialMembersSection')
interface CredentialMembersSectionProps {
credentialId: string
isAdmin: boolean
}
/**
* Active-member list for a credential: avatar + identity, a role dropdown, and a
* remove action. The last remaining admin cannot be demoted or removed. Shared
* by every credential detail surface.
*/
export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) {
const { data: members = [], isPending: membersLoading } =
useWorkspaceCredentialMembers(credentialId)
const upsertMember = useUpsertWorkspaceCredentialMember()
const removeMember = useRemoveWorkspaceCredentialMember()
const activeMembers = members.filter((member) => member.status === 'active')
const explicitAdminCount = activeMembers.filter(
(member) => member.role === 'admin' && member.roleSource !== 'workspace-admin'
).length
const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => {
const current = activeMembers.find((member) => member.userId === userId)
if (current?.role === role) return
try {
await upsertMember.mutateAsync({ credentialId, userId, role })
} catch (error) {
logger.error('Failed to change member role', error)
}
}
const handleRemoveMember = async (userId: string) => {
try {
await removeMember.mutateAsync({ credentialId, userId })
} catch (error) {
logger.error('Failed to remove credential member', error)
}
}
return (
<DetailSection title={`Members (${activeMembers.length})`}>
{membersLoading ? null : (
<div className='flex flex-col gap-2'>
{activeMembers.map((member) => {
const lockReason = credentialRoleLockReason(member.roleSource)
const roleLocked =
member.role === 'admin' &&
member.roleSource !== 'workspace-admin' &&
explicitAdminCount <= 1
const roleDisabled = !isAdmin || roleLocked || lockReason !== null
const removeDisabled = roleLocked || lockReason !== null
return (
<div
key={member.id}
className={cn(
'grid items-center gap-2',
isAdmin ? 'grid-cols-[1fr_120px_72px]' : 'grid-cols-[1fr_200px]'
)}
>
<div className='flex min-w-0 items-center gap-2.5'>
<Avatar className='size-9 flex-shrink-0'>
<AvatarFallback
style={{
background: getUserColor(member.userId || member.userEmail || ''),
}}
className='border border-[var(--border-1)] text-small text-white'
>
{(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{member.userName || member.userEmail || member.userId}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{member.userEmail || member.userId}
</span>
</div>
</div>
<RoleLockTooltip reason={lockReason}>
<ChipDropdown
options={ROLE_OPTIONS}
value={member.role}
placeholder='Role'
disabled={roleDisabled}
onChange={(role) =>
handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole)
}
/>
</RoleLockTooltip>
{isAdmin && (
<Chip
onClick={() => handleRemoveMember(member.userId)}
disabled={removeDisabled}
flush
className='justify-self-end'
>
Remove
</Chip>
)}
</div>
)
})}
</div>
)}
</DetailSection>
)
}
@@ -0,0 +1,14 @@
import type { ComponentType } from 'react'
interface DetailIconTileProps {
icon: ComponentType<{ className?: string }>
}
/** Square tile with a centered icon, used as a detail header's leading visual. */
export function DetailIconTile({ icon: Icon }: DetailIconTileProps) {
return (
<div className='flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-5)]'>
<Icon className='size-[18px] text-[var(--text-tertiary)]' />
</div>
)
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface DetailSectionProps {
title: ReactNode
children: ReactNode
}
/**
* Labeled section with a muted title and a thin inset divider above the body.
* Shared by the credential detail surfaces so every section keeps the same
* vertical rhythm without repeating markup at the callsites.
*/
export function DetailSection({ title, children }: DetailSectionProps) {
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{title}</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
{children}
</section>
)
}
@@ -0,0 +1,27 @@
import { ChipConfirmModal } from '@sim/emcn'
interface UnsavedChangesModalProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Confirmed discard: abandon edits and continue (navigate away / close). */
onDiscard: () => void
}
/**
* Confirmation shown when leaving a surface with unsaved edits. Shared by the
* credential detail surfaces and the secrets list so the copy and affordances
* stay identical.
*/
export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedChangesModalProps) {
return (
<ChipConfirmModal
open={open}
onOpenChange={onOpenChange}
srTitle='Unsaved Changes'
title='Unsaved Changes'
text='You have unsaved changes. Are you sure you want to discard them?'
dismissLabel='Keep editing'
confirm={{ label: 'Discard Changes', onClick: onDiscard }}
/>
)
}
@@ -0,0 +1,87 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
const logger = createLogger('CredentialDetailForm')
interface UseCredentialDetailFormParams {
credential: WorkspaceCredential | null
isAdmin: boolean
/** Where the back link / discard navigates to. */
backHref: string
}
/**
* Shared editable-metadata controller for a credential detail page: Display Name
* and Description drafts seeded from the credential, dirty tracking, an
* admin-only save, and the shared unsaved-changes guard.
*/
export function useCredentialDetailForm({
credential,
isAdmin,
backHref,
}: UseCredentialDetailFormParams) {
const updateCredential = useUpdateWorkspaceCredential()
const [displayNameDraft, setDisplayNameDraft] = useState('')
const [descriptionDraft, setDescriptionDraft] = useState('')
useEffect(() => {
setDisplayNameDraft(credential?.displayName ?? '')
setDescriptionDraft(credential?.description ?? '')
}, [credential?.id, credential?.displayName, credential?.description])
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
const isDescriptionDirty = credential
? descriptionDraft !== (credential.description || '')
: false
const isDirty = isDisplayNameDirty || isDescriptionDirty
const guard = useUnsavedChangesGuard({ isDirty, backHref })
const save = useCallback(async () => {
if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
try {
await updateCredential.mutateAsync({
credentialId: credential.id,
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
})
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
} catch (error) {
toast.error("Couldn't save changes", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
logger.error('Failed to save credential details', error)
}
}, [
credential,
isAdmin,
isDirty,
isDisplayNameDirty,
isDescriptionDirty,
displayNameDraft,
descriptionDraft,
updateCredential,
])
return {
displayNameDraft,
setDisplayNameDraft,
descriptionDraft,
setDescriptionDraft,
isDirty,
save,
isSaving: updateCredential.isPending,
handleBackClick: guard.handleBackClick,
showUnsavedAlert: guard.showUnsavedAlert,
setShowUnsavedAlert: guard.setShowUnsavedAlert,
confirmDiscard: guard.confirmDiscard,
}
}
@@ -0,0 +1,79 @@
'use client'
import type { MouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
interface UseUnsavedChangesGuardParams {
isDirty: boolean
/** Where a confirmed discard navigates to. */
backHref: string
}
/**
* Guards a detail surface against losing unsaved edits: warns on browser unload
* while dirty, intercepts the in-app back link, and traps the browser
* back/forward button to confirm before leaving. Shared by every credential
* detail surface so the behavior is identical.
*
* Native Back is trapped by seeding a single same-URL history entry while dirty,
* so Back pops that entry (no route change) and fires popstate. The seed is
* removed once the form is clean again (save/revert) so it never accumulates
* across edit cycles — and that removal runs in the effect body (only while
* still mounted), never in cleanup, so an intentional discard/navigation away is
* not reversed.
*/
export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
const router = useRouter()
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
const hasSentinelRef = useRef(false)
useEffect(() => {
if (!isDirty) {
// Clean again while still mounted (saved/reverted): pop the seeded entry so
// it can't pile up across edit/save cycles. This runs in the effect body,
// never on unmount, so navigating away mid-edit is never reversed.
if (hasSentinelRef.current) {
hasSentinelRef.current = false
window.history.back()
}
return
}
// Seed one same-URL entry so Back pops it (no route change) and fires
// popstate, letting us confirm before the page actually leaves.
if (!hasSentinelRef.current) {
window.history.pushState(null, '', window.location.href)
hasSentinelRef.current = true
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
}
const handlePopState = () => {
window.history.pushState(null, '', window.location.href)
setShowUnsavedAlert(true)
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('popstate', handlePopState)
}
}, [isDirty])
const handleBackClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (isDirty) {
event.preventDefault()
setShowUnsavedAlert(true)
}
},
[isDirty]
)
const confirmDiscard = useCallback(() => {
setShowUnsavedAlert(false)
router.push(backHref)
}, [router, backHref])
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
}
@@ -0,0 +1,10 @@
export { AddPeopleModal } from './components/add-people-modal'
export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field'
export { CredentialDetailHeading } from './components/credential-detail-heading'
export { CredentialDetailLayout } from './components/credential-detail-layout'
export { CredentialMembersSection } from './components/credential-members-section'
export { DetailIconTile } from './components/detail-icon-tile'
export { DetailSection } from './components/detail-section'
export { UnsavedChangesModal } from './components/unsaved-changes-modal'
export { useCredentialDetailForm } from './hooks/use-credential-detail-form'
export { useUnsavedChangesGuard } from './hooks/use-unsaved-changes-guard'
@@ -0,0 +1,15 @@
import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials'
export interface CredentialRoleOption {
value: WorkspaceCredentialRole
label: string
}
/**
* Roles assignable to a credential member. Shared by every credential detail
* surface (Integrations, Secrets) so role choices never drift between them.
*/
export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [
{ value: 'member', label: 'Member' },
{ value: 'admin', label: 'Admin' },
] as const
@@ -0,0 +1,69 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { partitionSettledFailures, resolveAddEmail } from './sharing'
describe('resolveAddEmail', () => {
const ctx = {
workspaceUserIdByEmail: new Map([
['ada@sim.dev', 'user-ada'],
['grace@sim.dev', 'user-grace'],
]),
existingMemberEmails: new Set(['grace@sim.dev']),
}
it('returns the userId for a workspace member who is not already on the credential', () => {
expect(resolveAddEmail('ada@sim.dev', ctx)).toEqual({ userId: 'user-ada' })
})
it('rejects an email that does not belong to any workspace member', () => {
expect(resolveAddEmail('nope@sim.dev', ctx)).toEqual({
error: "nope@sim.dev isn't a member of this workspace",
})
})
it('rejects an email that already has access to the credential', () => {
expect(resolveAddEmail('grace@sim.dev', ctx)).toEqual({
error: 'grace@sim.dev already has access',
})
})
it('matches case-insensitively while echoing the original email in errors', () => {
expect(resolveAddEmail('ADA@Sim.dev', ctx)).toEqual({ userId: 'user-ada' })
expect(resolveAddEmail('Grace@SIM.dev', ctx)).toEqual({
error: 'Grace@SIM.dev already has access',
})
})
})
describe('partitionSettledFailures', () => {
const targets = [{ email: 'a' }, { email: 'b' }, { email: 'c' }]
it('returns no failures when every result is fulfilled', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'fulfilled', value: 1 },
{ status: 'fulfilled', value: 2 },
{ status: 'fulfilled', value: 3 },
]
expect(partitionSettledFailures(targets, results)).toEqual([])
})
it('returns only the rejected targets (index-aligned) on partial failure', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'fulfilled', value: 1 },
{ status: 'rejected', reason: new Error('boom') },
{ status: 'fulfilled', value: 3 },
]
expect(partitionSettledFailures(targets, results)).toEqual([{ email: 'b' }])
})
it('returns all targets when every result rejected', () => {
const results: PromiseSettledResult<unknown>[] = [
{ status: 'rejected', reason: new Error('1') },
{ status: 'rejected', reason: new Error('2') },
{ status: 'rejected', reason: new Error('3') },
]
expect(partitionSettledFailures(targets, results)).toEqual(targets)
})
})
@@ -0,0 +1,37 @@
export interface ResolveAddEmailContext {
/** Lowercased email -> workspace userId for every workspace member. */
workspaceUserIdByEmail: Map<string, string>
/** Lowercased emails that already have access to the credential. */
existingMemberEmails: Set<string>
}
export type ResolveAddEmailResult = { userId: string } | { error: string }
/**
* Decide whether a format-valid email can be added to a credential: it must
* belong to a workspace member and not already have access. Matching is
* case-insensitive (the context map/set are keyed by lowercased email) while
* error messages echo the email as the user typed it. Returns the resolved
* `userId` on success, or a user-facing `error` message.
*/
export function resolveAddEmail(
email: string,
{ workspaceUserIdByEmail, existingMemberEmails }: ResolveAddEmailContext
): ResolveAddEmailResult {
const normalized = email.toLowerCase()
const userId = workspaceUserIdByEmail.get(normalized)
if (!userId) return { error: `${email} isn't a member of this workspace` }
if (existingMemberEmails.has(normalized)) return { error: `${email} already has access` }
return { userId }
}
/**
* Given the targets passed to a batch add and the index-aligned
* `Promise.allSettled` results, return the targets whose settle rejected.
*/
export function partitionSettledFailures<T>(
targets: readonly T[],
results: readonly PromiseSettledResult<unknown>[]
): T[] {
return targets.filter((_, index) => results[index]?.status === 'rejected')
}