mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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'
|
||||
|
||||
+5
-8
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+3
-5
@@ -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())
|
||||
|
||||
+4
-4
@@ -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. */
|
||||
|
||||
+34
-8
@@ -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 ? (
|
||||
|
||||
+6
-12
@@ -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'
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ export function OrganizationInviteModal({
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [emails, selectedWorkspaceIds, organizationId, inviteRole, inviteMember, onOpenChange])
|
||||
}, [emails, selectedWorkspaceIds, organizationId, inviteRole, inviteMember.mutate, onOpenChange])
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEmails([])
|
||||
|
||||
+49
-40
@@ -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})`}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+8
@@ -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}
|
||||
/>
|
||||
|
||||
+5
-6
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user