diff --git a/services/platform/app/components/env/env-var-list-editor.tsx b/services/platform/app/components/env/env-var-list-editor.tsx index 2ad0edb38..ee056cf01 100644 --- a/services/platform/app/components/env/env-var-list-editor.tsx +++ b/services/platform/app/components/env/env-var-list-editor.tsx @@ -14,11 +14,11 @@ import { Button } from '@tale/ui/button'; import { Checkbox } from '@tale/ui/checkbox'; import { Input } from '@tale/ui/input'; import { HStack, VStack } from '@tale/ui/layout'; -import { SkeletonText } from '@tale/ui/skeleton'; -import { Text } from '@tale/ui/text'; +import { Table, TableBody, TableCell, TableRow } from '@tale/ui/table'; import { Plus, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; import { Select } from '@/app/components/ui/forms/select'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; @@ -132,6 +132,8 @@ export function EnvVarListEditor({ const [localRows, setLocalRows] = useState([]); const [saving, setSaving] = useState(false); + // Index of the row awaiting remove confirmation (null = no dialog open). + const [pendingRemove, setPendingRemove] = useState(null); // While the user has unsaved edits, server (re)loads must not clobber them. // After a save we clear this so the post-write reactive update re-snapshots // with the freshly-computed secret previews. @@ -172,6 +174,22 @@ export function EnvVarListEditor({ dirty.current = true; setLocalRows((rs) => rs.filter((_, j) => j !== i)); }; + // A blank, never-saved row carries no data — drop it immediately. Any row with + // content (a typed key, or one loaded from the server) goes through a + // confirmation modal before it's removed. + const requestRemove = (i: number): void => { + const r = localRows[i]; + if (r && r.existingKey === null && r.key.trim() === '') { + removeRow(i); + return; + } + setPendingRemove(i); + }; + const confirmRemove = (): void => { + if (pendingRemove === null) return; + removeRow(pendingRemove); + setPendingRemove(null); + }; const onSave = async (): Promise => { const active = localRows.filter((r) => r.key.trim() !== ''); @@ -230,158 +248,169 @@ export function EnvVarListEditor({ const busy = saving || disabled; - if (isLoading && localRows.length === 0) { - return ; - } - - return ( - - {localRows.length === 0 && ( - - {t('none')} - - )} - {localRows.map((r, i) => { - const isBinding = r.tokenSourceSlug !== undefined; - // When the surface supports token sources, a single per-row "type" - // chooser (Value / Secret / a source) replaces the Secret checkbox — - // the user picks ONE: type a value, or draw from a rotating pool. The - // value field shows only for Value/Secret. Surfaces without sources keep - // the plain value + Secret-checkbox layout. - const hasSources = - tokenSources !== undefined && tokenSources.length > 0; - const rowType = isBinding - ? 'token-source' - : r.isSecret - ? 'secret' - : 'value'; - const onTypeChange = (v: string): void => { - if (!v) return; // ignore Radix's spurious '' during flux - if (v === 'value') { - patch(i, { - isSecret: false, - tokenSourceSlug: undefined, - masked: false, - ...(r.isSecret && { secretDirty: true, value: '' }), - }); - } else if (v === 'secret') { - patch(i, { - isSecret: true, - secretDirty: true, - tokenSourceSlug: undefined, - ...(r.masked && { value: '', masked: false }), - }); - } else if (v === 'token-source') { - // Default to the first source; the second dropdown lets the user - // change it. (Only reachable when hasSources, so [0] exists.) - const firstSlug = tokenSources?.[0]?.slug; - if (firstSlug !== undefined) { - patch(i, { - tokenSourceSlug: firstSlug, - isSecret: true, - value: '', - masked: false, - secretDirty: false, - }); - } - } - }; - return ( - + // Headerless, no-skeleton, no-empty-state table (#1950): when there are no + // rows the table isn't rendered at all — only the Add/Save controls remain. + const renderRow = (r: Row, i: number) => { + const isBinding = r.tokenSourceSlug !== undefined; + // When the surface supports token sources, a single per-row "type" + // chooser (Value / Secret / a source) replaces the Secret checkbox — + // the user picks ONE: type a value, or draw from a rotating pool. The + // value field shows only for Value/Secret. Surfaces without sources keep + // the plain value + Secret-checkbox layout. + const hasSources = tokenSources !== undefined && tokenSources.length > 0; + const rowType = isBinding + ? 'token-source' + : r.isSecret + ? 'secret' + : 'value'; + const onTypeChange = (v: string): void => { + if (!v) return; // ignore Radix's spurious '' during flux + if (v === 'value') { + patch(i, { + isSecret: false, + tokenSourceSlug: undefined, + masked: false, + ...(r.isSecret && { secretDirty: true, value: '' }), + }); + } else if (v === 'secret') { + patch(i, { + isSecret: true, + secretDirty: true, + tokenSourceSlug: undefined, + ...(r.masked && { value: '', masked: false }), + }); + } else if (v === 'token-source') { + // Default to the first source; the second dropdown lets the user + // change it. (Only reachable when hasSources, so [0] exists.) + const firstSlug = tokenSources?.[0]?.slug; + if (firstSlug !== undefined) { + patch(i, { + tokenSourceSlug: firstSlug, + isSecret: true, + value: '', + masked: false, + secretDirty: false, + }); + } + } + }; + return ( + + + patch(i, { key: e.target.value })} + /> + + {hasSources && ( + + ({ + value: s.slug, + label: s.displayName, + }))} + onValueChange={(v) => { + if (v) patch(i, { tokenSourceSlug: v }); + }} + /> + ) : ( patch(i, { key: e.target.value })} + onFocus={() => { + if (r.masked) patch(i, { value: '', masked: false }); + }} + onChange={(e) => + patch(i, { + value: e.target.value, + masked: false, + ...(r.isSecret && { secretDirty: true }), + }) + } + onBlur={() => { + if ( + r.isSecret && + r.existingKey !== null && + !r.secretDirty && + r.value === '' + ) { + patch(i, { value: r.maskedDisplay, masked: true }); + } + }} /> - {hasSources && ( - ({ - value: s.slug, - label: s.displayName, - }))} - onValueChange={(v) => { - if (v) patch(i, { tokenSourceSlug: v }); - }} - /> - ) : ( - + {!hasSources && ( + + - ); - })} + {t('secret')} + + + )} + + + + + ); + }; + + const pendingRow = + pendingRemove !== null ? localRows[pendingRemove] : undefined; + + return ( + + {localRows.length > 0 && ( + + {localRows.map((r, i) => renderRow(r, i))} +
+ )} - - ))} - + + + {passkeys.map((pk) => ( + + + + {pk.name?.trim() || t('passkeys.unnamed')} + + + + + + + ))} + +
)} - {!isLoading && (!passkeys || passkeys.length === 0) && ( - - {t('passkeys.empty')} - - )} + { + if (!open && !revoking) setPendingRevoke(null); + }} + title={t('passkeys.confirmRevokeTitle')} + description={t('passkeys.confirmRevokeDescription')} + preview={ + pendingRevoke + ? { + primary: pendingRevoke.name?.trim() || t('passkeys.unnamed'), + } + : undefined + } + deleteText={t('passkeys.revokeButton')} + isDeleting={revoking} + onDelete={() => void confirmRevoke()} + />