|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { type ClipboardEvent, type ReactNode, useId } from 'react' |
| 4 | +import { Input } from '@sim/emcn/field/inputs' |
| 5 | +import { ActionMenu, type MenuAction } from '@sim/emcn/menu/action-menu' |
| 6 | +import { SecretValueInput } from '@/app/(product)/_components/secrets-editor/secret-value-input' |
| 7 | +import styles from '@/app/(product)/_components/secrets-editor/secrets-editor.module.css' |
| 8 | +import { |
| 9 | + copySecretName, |
| 10 | + type UIEnvironmentVariable, |
| 11 | + validateEnvVarKey, |
| 12 | +} from '@/platform/lib/data/secrets' |
| 13 | + |
| 14 | +/** Keys a scope's rows and prefixes their salted autofill names. */ |
| 15 | +export type SecretScopeId = 'organization' | 'member' | 'workspace' | 'personal' |
| 16 | + |
| 17 | +const OVERRIDDEN_MESSAGE = 'A workspace secret with this name overrides it. Rename it to use it' |
| 18 | + |
| 19 | +function secretRowActions({ |
| 20 | + name, |
| 21 | + onViewDetails, |
| 22 | + onDelete, |
| 23 | +}: { |
| 24 | + name: string |
| 25 | + onViewDetails?: () => void |
| 26 | + onDelete?: () => void |
| 27 | +}): MenuAction[] { |
| 28 | + return [ |
| 29 | + ...(onViewDetails ? [{ id: 'details', label: 'View details', onSelect: onViewDetails }] : []), |
| 30 | + { id: 'copy', label: 'Copy name', onSelect: () => copySecretName(name) }, |
| 31 | + ...(onDelete |
| 32 | + ? [ |
| 33 | + { |
| 34 | + id: 'delete', |
| 35 | + label: 'Delete', |
| 36 | + destructive: true, |
| 37 | + separatorBefore: true, |
| 38 | + onSelect: onDelete, |
| 39 | + }, |
| 40 | + ] |
| 41 | + : []), |
| 42 | + ] |
| 43 | +} |
| 44 | + |
| 45 | +function SecretRowMenu(props: Parameters<typeof secretRowActions>[0]) { |
| 46 | + return ( |
| 47 | + <ActionMenu label='Secret actions' icon='I108' align='end' actions={secretRowActions(props)} /> |
| 48 | + ) |
| 49 | +} |
| 50 | + |
| 51 | +/** Clears a field's read-only state on focus so a password manager never fills it first. */ |
| 52 | +function unlockOnFocus(event: { currentTarget: HTMLInputElement }) { |
| 53 | + event.currentTarget.removeAttribute('readOnly') |
| 54 | +} |
| 55 | + |
| 56 | +function RowMessage({ children }: { children: ReactNode }) { |
| 57 | + return ( |
| 58 | + <p className={styles.message} data-type='T060' role='alert'> |
| 59 | + {children} |
| 60 | + </p> |
| 61 | + ) |
| 62 | +} |
| 63 | + |
| 64 | +interface SavedRowProps { |
| 65 | + scope: SecretScopeId |
| 66 | + envKey: string |
| 67 | + value: string |
| 68 | + /** The name being typed over `envKey`, while it is being renamed. */ |
| 69 | + pendingKey: string | null |
| 70 | + /** Editing the value, deleting and renaming; renaming also needs create access. */ |
| 71 | + canEdit: boolean |
| 72 | + canReveal: boolean |
| 73 | + overridden: boolean |
| 74 | + onRenameStart: () => void |
| 75 | + onPendingKeyChange: (value: string) => void |
| 76 | + onRenameEnd: () => void |
| 77 | + onValueChange: (value: string) => void |
| 78 | + onDelete: () => void |
| 79 | + onViewDetails?: () => void |
| 80 | +} |
| 81 | + |
| 82 | +/** A saved secret: its name renames on blur, and its value edits in place. */ |
| 83 | +export function SavedRow({ |
| 84 | + scope, |
| 85 | + envKey, |
| 86 | + value, |
| 87 | + pendingKey, |
| 88 | + canEdit, |
| 89 | + canReveal, |
| 90 | + overridden, |
| 91 | + onRenameStart, |
| 92 | + onPendingKeyChange, |
| 93 | + onRenameEnd, |
| 94 | + onValueChange, |
| 95 | + onDelete, |
| 96 | + onViewDetails, |
| 97 | +}: SavedRowProps) { |
| 98 | + /** Salts the `name` attributes so password managers cannot match them to a known field. */ |
| 99 | + const autofillSalt = useId() |
| 100 | + return ( |
| 101 | + <> |
| 102 | + <Input |
| 103 | + label={`Name of ${envKey}`} |
| 104 | + name={`${scope}_env_key_${envKey}_${autofillSalt}`} |
| 105 | + placeholder='API_KEY' |
| 106 | + value={pendingKey ?? envKey} |
| 107 | + invalid={overridden} |
| 108 | + onChange={(next) => { |
| 109 | + if (pendingKey === null) onRenameStart() |
| 110 | + onPendingKeyChange(next) |
| 111 | + }} |
| 112 | + onBlur={onRenameEnd} |
| 113 | + onFocus={canEdit ? unlockOnFocus : undefined} |
| 114 | + autoComplete='off' |
| 115 | + autoCapitalize='off' |
| 116 | + spellCheck={false} |
| 117 | + readOnly |
| 118 | + /> |
| 119 | + <SecretValueInput |
| 120 | + label={`Value of ${envKey}`} |
| 121 | + name={`${scope}_env_value_${envKey}_${autofillSalt}`} |
| 122 | + value={value} |
| 123 | + onChange={onValueChange} |
| 124 | + canEdit={canEdit} |
| 125 | + canReveal={canReveal} |
| 126 | + unmasked={overridden} |
| 127 | + readOnly={overridden} |
| 128 | + placeholder={overridden ? 'Workspace override active' : 'Enter value'} |
| 129 | + /> |
| 130 | + <SecretRowMenu |
| 131 | + name={envKey} |
| 132 | + onViewDetails={onViewDetails} |
| 133 | + onDelete={canEdit ? onDelete : undefined} |
| 134 | + /> |
| 135 | + {overridden && <RowMessage>{OVERRIDDEN_MESSAGE}</RowMessage>} |
| 136 | + </> |
| 137 | + ) |
| 138 | +} |
| 139 | + |
| 140 | +interface NewRowProps { |
| 141 | + scope: SecretScopeId |
| 142 | + noun?: string |
| 143 | + envVar: UIEnvironmentVariable |
| 144 | + index: number |
| 145 | + /** Another new row in the same scope has this name. */ |
| 146 | + duplicate: boolean |
| 147 | + overridden: boolean |
| 148 | + onUpdate: (index: number, field: 'key' | 'value', value: string) => void |
| 149 | + onPaste: (event: ClipboardEvent<HTMLInputElement>) => void |
| 150 | + onDelete: () => void |
| 151 | +} |
| 152 | + |
| 153 | +/** A secret being typed, saved with the rest. */ |
| 154 | +export function NewRow({ |
| 155 | + scope, |
| 156 | + noun, |
| 157 | + envVar, |
| 158 | + index, |
| 159 | + duplicate, |
| 160 | + overridden, |
| 161 | + onUpdate, |
| 162 | + onPaste, |
| 163 | + onDelete, |
| 164 | +}: NewRowProps) { |
| 165 | + const autofillSalt = useId() |
| 166 | + const keyError = validateEnvVarKey(envVar.key) |
| 167 | + const hasContent = Boolean(envVar.key || envVar.value) |
| 168 | + const blankLabel = noun ? `New ${noun} secret` : 'New secret' |
| 169 | + const message = |
| 170 | + keyError ?? |
| 171 | + (duplicate ? 'Another new secret has this name' : overridden ? OVERRIDDEN_MESSAGE : undefined) |
| 172 | + return ( |
| 173 | + <> |
| 174 | + <Input |
| 175 | + label={envVar.key ? `Name of ${envVar.key}` : `${blankLabel} name`} |
| 176 | + name={`new_${scope}_key_${envVar.id ?? index}_${autofillSalt}`} |
| 177 | + value={envVar.key} |
| 178 | + placeholder='API_KEY' |
| 179 | + invalid={Boolean(message)} |
| 180 | + onChange={(next) => onUpdate(index, 'key', next)} |
| 181 | + onPaste={onPaste} |
| 182 | + onFocus={unlockOnFocus} |
| 183 | + autoComplete='off' |
| 184 | + autoCapitalize='off' |
| 185 | + spellCheck={false} |
| 186 | + readOnly |
| 187 | + /> |
| 188 | + <SecretValueInput |
| 189 | + label={envVar.key ? `Value of ${envVar.key}` : `${blankLabel} value`} |
| 190 | + name={`new_${scope}_value_${envVar.id ?? index}_${autofillSalt}`} |
| 191 | + value={envVar.value} |
| 192 | + onChange={(next) => onUpdate(index, 'value', next)} |
| 193 | + onPaste={onPaste} |
| 194 | + unmasked={overridden} |
| 195 | + readOnly={overridden} |
| 196 | + placeholder={overridden ? 'Workspace override active' : 'Enter value'} |
| 197 | + /> |
| 198 | + {hasContent ? <SecretRowMenu name={envVar.key} onDelete={onDelete} /> : <span />} |
| 199 | + {message && <RowMessage>{message}</RowMessage>} |
| 200 | + </> |
| 201 | + ) |
| 202 | +} |
0 commit comments