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
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:
+170
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -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)
|
||||
+30
@@ -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>
|
||||
)
|
||||
}
|
||||
+29
@@ -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>
|
||||
)
|
||||
}
|
||||
+124
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
@@ -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>
|
||||
)
|
||||
}
|
||||
+21
@@ -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>
|
||||
)
|
||||
}
|
||||
+27
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+87
@@ -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,
|
||||
}
|
||||
}
|
||||
+79
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user