fix(settings): a11y labels, URL-backed member search, and design-system cleanup (#5955)

* fix(settings): a11y labels, URL-backed member search, and design-system cleanup

* fix(secrets): use useId for autofill salt so it survives hydration
This commit is contained in:
Waleed
2026-07-24 19:50:46 -07:00
committed by GitHub
parent e39045d94e
commit 49bf3f2c68
20 changed files with 198 additions and 135 deletions
@@ -52,6 +52,7 @@ function ActivityLogRow({
>
<button
type='button'
aria-expanded={expandable ? expanded : undefined}
className='flex w-full items-center gap-3 px-3 py-2 text-left'
onClick={() => expandable && setExpanded(!expanded)}
disabled={!expandable}
@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { ChipConfirmModal, Switch, Tooltip, toast } from '@sim/emcn'
import { ChipConfirmModal, Label, Switch, Tooltip, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Info, Plus } from 'lucide-react'
@@ -98,6 +98,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS
const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS
const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES
const conflictNames = useMemo(() => new Set(conflicts), [conflicts])
const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings)
const allowPersonalApiKeys =
@@ -264,7 +265,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
<SettingsSection label='Personal'>
<div className='flex flex-col gap-2'>
{filteredPersonalKeys.map(({ key }) => {
const isConflict = conflicts.includes(key.name)
const isConflict = conflictNames.has(key.name)
return (
<div key={key.id} className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
@@ -318,11 +319,12 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
<SettingsSection label='Permissions'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-body)] text-sm'>Allow personal API keys</span>
<Label htmlFor='allow-personal-api-keys'>Allow personal API keys</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='About personal API keys'
className='rounded-full p-1 text-[var(--text-muted)] transition hover-hover:text-[var(--text-primary)]'
>
<Info className='size-[12px]' strokeWidth={2} />
@@ -337,6 +339,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
</div>
{isLoadingSettings ? null : (
<Switch
id='allow-personal-api-keys'
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
@@ -43,6 +43,9 @@ vi.mock('@sim/emcn', () => ({
<a href={href}>{children}</a>
),
Credit: () => <span />,
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
Switch: ({
checked,
disabled,
@@ -7,6 +7,7 @@ import {
Credit,
chipVariants,
cn,
Label,
Switch,
Tooltip,
toast,
@@ -494,14 +495,17 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
{showOnDemand && (
<SettingsSection label='Enable on-demand usage'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Allow usage to go past included usage
</span>
<Label htmlFor='on-demand-usage'>Allow usage to go past included usage</Label>
{onDemandLockedOn ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Switch checked disabled onCheckedChange={handleToggleOnDemand} />
<Switch
id='on-demand-usage'
checked
disabled
onCheckedChange={handleToggleOnDemand}
/>
</span>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[260px]'>
@@ -514,6 +518,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
</Tooltip.Root>
) : (
<Switch
id='on-demand-usage'
checked={isOnDemandActive}
disabled={isTogglingOnDemand || !canManageBilling}
onCheckedChange={handleToggleOnDemand}
@@ -526,10 +531,9 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
{!isOrganizationScope && !subscription.isFree && !subscription.isEnterprise && (
<SettingsSection label='Usage notifications'>
<div className='flex items-center justify-between'>
<span className='text-[var(--text-body)] text-small'>
Email me when I reach 80% usage
</span>
<Label htmlFor='usage-notifications'>Email me when I reach 80% usage</Label>
<Switch
id='usage-notifications'
checked={!!billingUsageNotificationsEnabled}
disabled={updateGeneralSetting.isPending}
onCheckedChange={(value: boolean) => {
@@ -403,9 +403,10 @@ export function General() {
<SettingsSection label='Preferences'>
<div className='flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<Label htmlFor='theme-select'>Theme</Label>
<Label>Theme</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
aria-label='Theme'
align='start'
fullWidth
dropdownWidth='trigger'
@@ -442,7 +443,13 @@ export function General() {
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
<button
type='button'
aria-label='About auto-connect on drop'
className='inline-flex cursor-default text-[var(--text-muted)]'
>
<Info className='size-[14px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
@@ -466,7 +473,13 @@ export function General() {
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
<button
type='button'
aria-label='About canvas error notifications'
className='inline-flex cursor-default text-[var(--text-muted)]'
>
<Info className='size-[14px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
@@ -485,9 +498,10 @@ export function General() {
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<Label>Snap to grid</Label>
<div className={DROPDOWN_TRIGGER_CLASS}>
<ChipSelect
aria-label='Snap to grid'
align='start'
fullWidth
dropdownWidth='trigger'
@@ -12,6 +12,7 @@ import {
ChipModalFooter,
ChipModalHeader,
Tooltip,
useCopyToClipboard,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react'
@@ -45,15 +46,11 @@ export function InboxSettingsTab() {
const [editAddressError, setEditAddressError] = useState<string | null>(null)
const [removeSenderError, setRemoveSenderError] = useState<string | null>(null)
const [copiedAddress, setCopiedAddress] = useState(false)
const { copied: copiedAddress, copy } = useCopyToClipboard()
const handleCopyAddress = useCallback(() => {
if (config?.address) {
navigator.clipboard.writeText(config.address)
setCopiedAddress(true)
setTimeout(() => setCopiedAddress(false), 2000)
}
}, [config?.address])
if (config?.address) void copy(config.address)
}, [config?.address, copy])
const handleEditAddress = useCallback(async () => {
if (!newUsername.trim()) return
@@ -172,7 +169,7 @@ export function InboxSettingsTab() {
>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-body)] text-sm'>{member.email}</span>
<Badge variant='gray' className='text-xs'>
<Badge variant='gray' size='sm'>
member
</Badge>
</div>
@@ -171,7 +171,7 @@ export function InboxTaskList() {
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
{formatRelativeTime(task.createdAt)}
</span>
<Badge variant={statusBadge.variant} className='text-xs'>
<Badge variant={statusBadge.variant} size='sm'>
{task.status === 'processing' && (
<span className='mr-1 inline-block size-[6px] animate-pulse rounded-full bg-[var(--badge-amber-text)]' />
)}
@@ -37,32 +37,28 @@ export function Inbox() {
)
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<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-4.5 pt-6 pb-6'>
<div className='flex flex-col items-center justify-center gap-4 py-20'>
<div className='text-center'>
<h3 className='font-medium text-[var(--text-primary)] text-md'>
Sim Mailer requires an active Max plan
</h3>
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
Upgrade to Max and ensure billing is active to receive tasks via email and let Sim
work on your behalf.
</p>
</div>
{canAdmin && (
<Chip
variant='primary'
rightIcon={ArrowRight}
onClick={() => navigateToSettings({ section: 'billing' })}
>
Upgrade to Max
</Chip>
)}
</div>
<SettingsPanel>
<div className='flex flex-col items-center justify-center gap-4 py-20'>
<div className='text-center'>
<h3 className='font-medium text-[var(--text-primary)] text-md'>
Sim Mailer requires an active Max plan
</h3>
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
Upgrade to Max and ensure billing is active to receive tasks via email and let Sim
work on your behalf.
</p>
</div>
{canAdmin && (
<Chip
variant='primary'
rightIcon={ArrowRight}
onClick={() => navigateToSettings({ section: 'billing' })}
>
Upgrade to Max
</Chip>
)}
</div>
</div>
</SettingsPanel>
)
}
@@ -1,7 +1,16 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Badge, Button, ChipInput, ChipModalTabs, ChipSelect, Label, Skeleton } from '@sim/emcn'
import {
Badge,
Button,
ChipCopyInput,
ChipInput,
ChipModalTabs,
ChipSelect,
Label,
Skeleton,
} from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
import { useQueryStates } from 'nuqs'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
@@ -377,7 +386,7 @@ function OverviewTab({
}
function LicensesTab({ environment }: { environment: MothershipEnv }) {
const { data, isLoading, refetch } = useMothershipLicenses(environment)
const { data, isLoading } = useMothershipLicenses(environment)
const generateLicense = useGenerateLicense(environment)
const [newName, setNewName] = useState('')
const [newExpiry, setNewExpiry] = useState('')
@@ -395,11 +404,10 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
setGeneratedKey(result.license_key)
setNewName('')
setNewExpiry('')
refetch()
},
}
)
}, [newName, newExpiry, generateLicense, refetch])
}, [newName, newExpiry, generateLicense.mutate])
return (
<div className='flex flex-col gap-5'>
@@ -437,13 +445,11 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
</div>
{generatedKey && (
<div className='rounded-md border border-[var(--border)] bg-[var(--surface-hover)] p-3'>
<p className='mb-1 text-[var(--text-secondary)] text-caption'>
<div className='flex flex-col gap-1.5'>
<p className='text-[var(--text-secondary)] text-caption'>
License key (only shown once):
</p>
<code className='block break-all font-mono text-[var(--text-primary)] text-caption'>
{generatedKey}
</code>
<ChipCopyInput value={generatedKey} copyLabel='Copy license key' />
</div>
)}
@@ -21,6 +21,7 @@ import {
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useMothershipChats, useRestoreMothershipChat } from '@/hooks/queries/mothership-chats'
@@ -31,7 +32,6 @@ import {
useWorkspaceFileFolders,
} from '@/hooks/queries/workspace-file-folders'
import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useUrlSort } from '@/hooks/use-url-sort'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
@@ -142,7 +142,7 @@ export function RecentlyDeleted() {
const workspaceId = params?.workspaceId as string
const workspacePermissions = useUserPermissionsContext()
const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions)
const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates(
const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates(
recentlyDeletedParsers,
recentlyDeletedUrlKeys
)
@@ -160,9 +160,7 @@ export function RecentlyDeleted() {
* write is debounced. Filtering below is cheap in-memory over a small list, so
* it reads the instant value too.
*/
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
setRecentlyDeletedFilters({ search: value }, options)
)
const [urlSearchTerm, setSearchTerm] = useSettingsSearch()
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, RestoredResourceEntry>>(new Map())
@@ -1,4 +1,4 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
import { parseAsStringLiteral } from 'nuqs/server'
import { createSortParams } from '@/lib/url-state'
/** Selectable resource-type tabs in the Recently Deleted view. */
@@ -34,12 +34,12 @@ export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_
* - `tab` is the active resource-type filter.
* - `sort` / `dir` live in {@link recentlyDeletedSortParams} (shared sort
* convention).
* - `search` is the name filter. The input is controlled directly by the nuqs
* value; only its URL write is debounced via `useDebouncedSearchSetter`.
* - The name filter is the settings-wide `?search=` key, owned by
* `settingsSearchParam` and consumed through `useSettingsSearch` — it is
* deliberately not redeclared here (two definitions of one wire key drift).
*/
export const recentlyDeletedParsers = {
tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'),
search: parseAsString.withDefault(''),
} as const
/** Tab/filter/sort view-state: clean URLs, no back-stack churn. */
@@ -1,9 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { ChipInput, cn, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
@@ -217,6 +216,15 @@ function WorkspaceVariableRow({
onDelete,
onViewDetails,
}: WorkspaceVariableRowProps) {
/**
* Salts the generated `name` attributes so password managers can't match them
* against a known field. `useId` is stable across SSR and hydration and across
* re-renders — a fresh `generateShortId()` per render would patch six DOM
* attributes on every keystroke, and a module-scope one would differ between
* the server and browser bundles.
*/
const autofillSalt = useId()
return (
<div className='contents'>
<ChipInput
@@ -227,7 +235,7 @@ function WorkspaceVariableRow({
onPendingKeyChange(e.target.value)
}}
onBlur={() => onRenameEnd(envKey, value)}
name={`workspace_env_key_${envKey}_${generateShortId()}`}
name={`workspace_env_key_${envKey}_${autofillSalt}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
@@ -241,7 +249,7 @@ function WorkspaceVariableRow({
value={value}
onChange={(next) => onValueChange(envKey, next)}
canEdit={canEdit}
name={`workspace_env_value_${envKey}_${generateShortId()}`}
name={`workspace_env_value_${envKey}_${autofillSalt}`}
/>
<SecretRowMenu
onCopyName={() => copyName(envKey)}
@@ -265,6 +273,15 @@ function NewWorkspaceVariableRow({
onUpdate,
onPaste,
}: NewWorkspaceVariableRowProps) {
/**
* Salts the generated `name` attributes so password managers can't match them
* against a known field. `useId` is stable across SSR and hydration and across
* re-renders — a fresh `generateShortId()` per render would patch six DOM
* attributes on every keystroke, and a module-scope one would differ between
* the server and browser bundles.
*/
const autofillSalt = useId()
const keyError = validateEnvVarKey(envVar.key)
const hasContent = Boolean(envVar.key || envVar.value)
@@ -277,7 +294,7 @@ function NewWorkspaceVariableRow({
onChange={(e) => onUpdate(index, 'key', e.target.value)}
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
placeholder='API_KEY'
name={`new_workspace_key_${envVar.id || index}_${generateShortId()}`}
name={`new_workspace_key_${envVar.id || index}_${autofillSalt}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
@@ -291,7 +308,7 @@ function NewWorkspaceVariableRow({
onChange={(next) => onUpdate(index, 'value', next)}
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
placeholder='Enter value'
name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`}
name={`new_workspace_value_${envVar.id || index}_${autofillSalt}`}
className='ml-0'
/>
{hasContent ? (
@@ -320,6 +337,15 @@ function NewWorkspaceVariableRow({
}
export function SecretsManager() {
/**
* Salts the generated `name` attributes so password managers can't match them
* against a known field. `useId` is stable across SSR and hydration and across
* re-renders — a fresh `generateShortId()` per render would patch six DOM
* attributes on every keystroke, and a module-scope one would differ between
* the server and browser bundles.
*/
const autofillSalt = useId()
const params = useParams()
const router = useRouter()
const workspaceId = (params?.workspaceId as string) || ''
@@ -865,7 +891,7 @@ export function SecretsManager() {
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder='API_KEY'
name={`env_variable_name_${envVar.id || originalIndex}_${generateShortId()}`}
name={`env_variable_name_${envVar.id || originalIndex}_${autofillSalt}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
@@ -881,7 +907,7 @@ export function SecretsManager() {
unmasked={isConflicted}
readOnly={isConflicted}
placeholder={isConflicted ? 'Workspace override active' : 'Enter value'}
name={`env_variable_value_${envVar.id || originalIndex}_${generateShortId()}`}
name={`env_variable_value_${envVar.id || originalIndex}_${autofillSalt}`}
className={cn(isConflicted && 'cursor-not-allowed opacity-50')}
/>
{hasContent ? (
@@ -44,7 +44,6 @@ export function NoOrganizationView({
return (
<div>
<div className='flex flex-col gap-5'>
{/* Header - matching settings page style */}
<div>
<h4 className='font-medium text-[var(--text-primary)] text-base'>
Create Your Team Workspace
@@ -55,23 +54,20 @@ export function NoOrganizationView({
</p>
</div>
{/* Form fields - clean layout without card */}
<div className='flex flex-col gap-4.5'>
{/* Hidden decoy field to prevent browser autofill */}
{/* Decoy field: absorbs browser autofill so it can't target the real inputs. */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
className='-left-[9999px] pointer-events-none absolute opacity-0'
tabIndex={-1}
readOnly
aria-hidden='true'
aria-label='Ignore this field'
/>
<div>
<Label htmlFor='team-name-field' className='font-medium text-small'>
Team Name
</Label>
<Label htmlFor='team-name-field'>Team Name</Label>
<ChipInput
id='team-name-field'
value={orgName}
@@ -88,9 +84,7 @@ export function NoOrganizationView({
</div>
<div>
<Label htmlFor='orgSlug' className='font-medium text-small'>
Team URL
</Label>
<Label htmlFor='orgSlug'>Team URL</Label>
<div className='mt-1 flex items-center'>
<div className='rounded-l-[6px] border border-[var(--border-1)] border-r-0 bg-[var(--surface-4)] px-3 py-1.5 text-[var(--text-muted)] text-small'>
sim.ai/team/
@@ -131,12 +125,12 @@ export function NoOrganizationView({
Create Team Organization
</ChipModalHeader>
<ChipModalBody>
{/* Hidden decoy field to prevent browser autofill */}
{/* Decoy field: absorbs browser autofill so it can't target the real inputs. */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
className='-left-[9999px] pointer-events-none absolute opacity-0'
tabIndex={-1}
readOnly
aria-hidden='true'
@@ -166,7 +166,7 @@ export function OrganizationInviteModal({
},
}
)
}, [emails, selectedWorkspaceIds, organizationId, inviteRole, inviteMember, onOpenChange])
}, [emails, selectedWorkspaceIds, organizationId, inviteRole, inviteMember.mutate, onOpenChange])
const resetState = useCallback(() => {
setEmails([])
@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn'
import { ChipDropdown, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
@@ -73,6 +73,12 @@ interface OrganizationMemberListsProps {
roster: OrganizationRoster | null | undefined
isLoadingRoster: boolean
currentUserId: string
/**
* The roster filter, owned by the page so it can live in the URL — this
* component renders the shared `SettingsPanel` search box's results, it does
* not own the box.
*/
query: string
onRemoveMember: (member: Member) => void
onTransferOwnership?: () => void
}
@@ -89,10 +95,10 @@ export function OrganizationMemberLists({
roster,
isLoadingRoster,
currentUserId,
query,
onRemoveMember,
onTransferOwnership,
}: OrganizationMemberListsProps) {
const [query, setQuery] = useState('')
const [creditsTarget, setCreditsTarget] = useState<ManageCreditsTarget | null>(null)
const updateMemberRole = useUpdateOrganizationMemberRole()
@@ -380,48 +386,51 @@ export function OrganizationMemberLists({
/**
* Group each workspace's members and pending invites once per roster change.
* This is O(workspaces × members) and independent of the search query, so
* hoisting it out of render keeps keystroke filtering cheap on large orgs.
* Indexed by a single pass over the roster rather than a `.find` per
* workspace × member — that inner scan made this O(workspaces × members ×
* access-entries). Members are appended in roster order, so each group keeps
* the same ordering the per-workspace scan produced.
*/
const workspaceGroups = useMemo(
() =>
workspaces.map((workspace) => {
const workspaceMembers = members
.map((member) => ({
member,
access: member.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
const workspaceInvites = pendingInvitations
.map((invitation) => ({
invitation,
access: invitation.workspaces.find((w) => w.workspaceId === workspace.id),
}))
.filter(
(
entry
): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } =>
Boolean(entry.access)
)
return { workspace, workspaceMembers, workspaceInvites }
}),
[workspaces, members, pendingInvitations]
)
const workspaceGroups = useMemo(() => {
const membersByWorkspace = new Map<
string,
{ member: RosterMember; access: RosterWorkspaceAccess }[]
>()
for (const member of members) {
const seen = new Set<string>()
for (const access of member.workspaces) {
if (seen.has(access.workspaceId)) continue
seen.add(access.workspaceId)
const entries = membersByWorkspace.get(access.workspaceId)
if (entries) entries.push({ member, access })
else membersByWorkspace.set(access.workspaceId, [{ member, access }])
}
}
const invitesByWorkspace = new Map<
string,
{ invitation: RosterPendingInvitation; access: RosterWorkspaceAccess }[]
>()
for (const invitation of pendingInvitations) {
const seen = new Set<string>()
for (const access of invitation.workspaces) {
if (seen.has(access.workspaceId)) continue
seen.add(access.workspaceId)
const entries = invitesByWorkspace.get(access.workspaceId)
if (entries) entries.push({ invitation, access })
else invitesByWorkspace.set(access.workspaceId, [{ invitation, access }])
}
}
return workspaces.map((workspace) => ({
workspace,
workspaceMembers: membersByWorkspace.get(workspace.id) ?? [],
workspaceInvites: invitesByWorkspace.get(workspace.id) ?? [],
}))
}, [workspaces, members, pendingInvitations])
return (
<>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search members...'
value={query}
onChange={(e) => setQuery(e.target.value)}
className='flex-1'
/>
</div>
{showMembersSection && (
<MemberSection
label={`Members (${orgRowCount})`}
@@ -1,4 +1,5 @@
import { ChipConfirmModal } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
interface RemoveMemberDialogProps {
open: boolean
@@ -29,8 +30,7 @@ export function RemoveMemberDialog({
? 'Remove External Member'
: 'Remove Team Member'
const errorMessage =
error instanceof Error && error.message ? error.message : error ? String(error) : null
const errorMessage = error ? getErrorMessage(error) || 'Failed to remove member' : null
return (
<ChipConfirmModal
@@ -13,6 +13,7 @@ import {
Search,
Skeleton,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { getUserColor } from '@/lib/workspaces/colors'
import type { RosterMember } from '@/hooks/queries/organization'
@@ -207,7 +208,7 @@ export function TransferOwnershipDialog({
{error && (
<p className='px-2 text-[var(--text-error)] text-sm'>
{error instanceof Error && error.message ? error.message : String(error)}
{getErrorMessage(error) || 'Failed to transfer ownership'}
</p>
)}
</div>
@@ -16,6 +16,7 @@ import {
TeamSeatsOverview,
TransferOwnershipDialog,
} from '@/app/workspace/[workspaceId]/settings/components/team-management/components'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import {
useCreateOrganization,
useOrganization,
@@ -40,6 +41,7 @@ export function TeamManagement({
}: TeamManagementProps) {
const { data: session } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
const [memberQuery, setMemberQuery] = useSettingsSearch()
const { data: userSubscriptionData } = useSubscriptionData()
const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data)
@@ -307,6 +309,11 @@ export function TeamManagement({
return (
<>
<SettingsPanel
search={{
value: memberQuery,
onChange: setMemberQuery,
placeholder: 'Search members...',
}}
actions={
adminOrOwner
? [
@@ -339,6 +346,7 @@ export function TeamManagement({
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
query={memberQuery}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
@@ -19,6 +19,7 @@ import {
Code,
type ComboboxOption,
Label,
useCopyToClipboard,
} from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
@@ -97,7 +98,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
}
}, [])
const [copiedConfig, setCopiedConfig] = useState(false)
const { copied: copiedConfig, copy: copyConfig } = useCopyToClipboard()
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
const [toolToView, setToolToView] = useState<WorkflowMcpTool | null>(null)
@@ -308,12 +309,9 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
const handleCopyConfig = useCallback(
(isPublic: boolean, serverName: string) => {
const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName)
navigator.clipboard.writeText(snippet)
setCopiedConfig(true)
setTimeout(() => setCopiedConfig(false), 2000)
void copyConfig(getConfigSnippet(activeConfigTab, isPublic, serverName))
},
[activeConfigTab, getConfigSnippet]
[activeConfigTab, getConfigSnippet, copyConfig]
)
const handleOpenEditServer = useCallback(() => {
@@ -579,6 +577,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
</span>
<Button
variant='ghost'
aria-label={copiedConfig ? 'Configuration copied' : 'Copy configuration'}
onClick={() => handleCopyConfig(server.isPublic, server.name)}
className='!p-1.5 -my-1.5'
>
@@ -205,8 +205,12 @@ export function useMothershipLicenseDetails(
}
export function useGenerateLicense(environment: MothershipEnv) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { name: string; expirationDate?: string }) =>
mothershipPost('licenses/generate', environment, params),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: mothershipKeys.licenses(environment) })
},
})
}