Skip to content

Commit 757b594

Browse files
committed
Merge branch 'wip/fx33-e' into wip/platform-org-wiring
# Conflicts: # scripts/platform-allowlists.ts
2 parents a9213a0 + 227d5ea commit 757b594

60 files changed

Lines changed: 3024 additions & 2788 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import type { ReactNode } from 'react'
4+
import { FailureNotice } from '@sim/emcn/status'
5+
import { type GuardBack, SecretRows } from '@/app/(product)/_components/secrets-editor/secret-rows'
6+
import { SectionStack } from '@/app/(product)/_components/settings-parts/settings-parts'
7+
import { useGenericSecrets } from '@/platform/lib/data/generic-secrets'
8+
9+
export interface GenericSecretsProps {
10+
organizationId: string
11+
mode: 'organization' | 'member'
12+
header?: (guardBack: GuardBack) => ReactNode
13+
}
14+
15+
/** One source's Generic Secrets: the organization's, or the viewer's own. */
16+
export function GenericSecrets({ organizationId, mode, header }: GenericSecretsProps) {
17+
const { failure, editor } = useGenericSecrets(organizationId, mode)
18+
const failureNotice = failure && (
19+
<FailureNotice message={failure.message} retrying={failure.retrying} onRetry={failure.retry} />
20+
)
21+
/** A failed refetch keeps the editor mounted with its drafts; only a first load shows failure. */
22+
if (!editor)
23+
return (
24+
<SectionStack>
25+
{header?.((leave) => leave())}
26+
{failureNotice}
27+
</SectionStack>
28+
)
29+
return (
30+
<SecretRows
31+
key={editor.sourceId}
32+
header={header}
33+
refreshFailure={failureNotice}
34+
isSaving={editor.isSaving}
35+
scopes={[
36+
{
37+
id: mode,
38+
title: mode === 'organization' ? 'Organization' : 'Personal',
39+
variables: editor.variables,
40+
save: ({ upsert, remove }) => editor.save({ upsert, remove }),
41+
},
42+
]}
43+
/>
44+
)
45+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)