@open-mercato/core 0.6.8-develop.7037.1.ea0277b01e → 0.6.8-develop.7038.1.ea954afd80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/integrations/api/[id]/credentials/route.js +6 -1
  3. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  4. package/dist/modules/integrations/backend/integrations/[id]/page.js +38 -29
  5. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  6. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +36 -9
  7. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  8. package/dist/modules/integrations/backend/integrations/credential-secret-fields.js +55 -0
  9. package/dist/modules/integrations/backend/integrations/credential-secret-fields.js.map +7 -0
  10. package/dist/modules/integrations/data/validators.js +11 -3
  11. package/dist/modules/integrations/data/validators.js.map +2 -2
  12. package/dist/modules/integrations/lib/credentials-masking.js +6 -2
  13. package/dist/modules/integrations/lib/credentials-masking.js.map +2 -2
  14. package/package.json +7 -7
  15. package/src/modules/integrations/api/[id]/credentials/route.ts +6 -4
  16. package/src/modules/integrations/backend/integrations/[id]/page.tsx +61 -31
  17. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +44 -9
  18. package/src/modules/integrations/backend/integrations/credential-secret-fields.ts +84 -0
  19. package/src/modules/integrations/data/validators.ts +12 -2
  20. package/src/modules/integrations/i18n/de.json +1 -0
  21. package/src/modules/integrations/i18n/en.json +1 -0
  22. package/src/modules/integrations/i18n/es.json +1 -0
  23. package/src/modules/integrations/i18n/ko.json +1 -0
  24. package/src/modules/integrations/i18n/pl.json +1 -0
  25. package/src/modules/integrations/lib/credentials-masking.ts +10 -6
@@ -52,6 +52,11 @@ import {
52
52
  refreshIntegrationDetailPanels,
53
53
  refreshIntegrationRunActivityPanels,
54
54
  } from '../detail-page-refresh'
55
+ import {
56
+ buildCredentialEditValues,
57
+ buildIntegrationCredentialSavePayload,
58
+ type SecretFieldsConfigured,
59
+ } from '../credential-secret-fields'
55
60
  import { isValidCredentialUrl } from '../../../lib/credentials-field-validation'
56
61
 
57
62
  type CredentialField = IntegrationCredentialField
@@ -290,19 +295,32 @@ function resolvePathnameId(pathname: string): string | undefined {
290
295
  return decodeURIComponent(integrationId)
291
296
  }
292
297
 
293
- function buildCredentialFields(credFields: CredentialField[]): CrudField[] {
298
+ function buildCredentialFields(
299
+ credFields: CredentialField[],
300
+ secretFieldsConfigured: SecretFieldsConfigured,
301
+ t: ReturnType<typeof useT>,
302
+ ): CrudField[] {
294
303
  return credFields.map((field) => {
304
+ const baseDescription = field.helpDetails ? (
305
+ <div className="space-y-1">
306
+ {field.helpText ? <div>{field.helpText}</div> : null}
307
+ <WebhookSetupGuide guide={field.helpDetails} />
308
+ </div>
309
+ ) : field.helpText
310
+ const description = field.type === 'secret' && secretFieldsConfigured[field.key] ? (
311
+ <div className="space-y-1">
312
+ {baseDescription ? <div>{baseDescription}</div> : null}
313
+ <p className="text-xs text-muted-foreground">
314
+ {t('integrations.detail.credentials.secretConfigured')}
315
+ </p>
316
+ </div>
317
+ ) : baseDescription
295
318
  const shared = {
296
319
  id: field.key,
297
320
  label: field.label,
298
- description: field.helpDetails ? (
299
- <div className="space-y-1">
300
- {field.helpText ? <div>{field.helpText}</div> : null}
301
- <WebhookSetupGuide guide={field.helpDetails} buttonLabel="Show details" />
302
- </div>
303
- ) : field.helpText,
321
+ description,
304
322
  placeholder: field.placeholder,
305
- required: field.required,
323
+ required: field.required && !(field.type === 'secret' && secretFieldsConfigured[field.key]),
306
324
  visibleWhen: field.visibleWhen,
307
325
  }
308
326
 
@@ -317,6 +335,7 @@ function buildCredentialFields(credFields: CredentialField[]): CrudField[] {
317
335
  value={typeof value === 'string' ? value : ''}
318
336
  onChange={(event) => setValue(event.target.value)}
319
337
  disabled={disabled}
338
+ autoComplete="new-password"
320
339
  />
321
340
  ),
322
341
  }
@@ -426,6 +445,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
426
445
  const [isNotFound, setIsNotFound] = React.useState(false)
427
446
 
428
447
  const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
448
+ const [secretFieldsConfigured, setSecretFieldsConfigured] = React.useState<SecretFieldsConfigured>({})
429
449
  const [credentialsUpdatedAt, setCredentialsUpdatedAt] = React.useState<string | null>(null)
430
450
  const [credentialsFormKey, setCredentialsFormKey] = React.useState(0)
431
451
  const [isSavingCredentials, setIsSavingCredentials] = React.useState(false)
@@ -490,13 +510,18 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
490
510
  const loadCredentials = React.useCallback(async () => {
491
511
  const currentIntegrationId = resolveCurrentIntegrationId()
492
512
  if (!currentIntegrationId) return
493
- const call = await apiCall<{ credentials: Record<string, unknown>; updatedAt?: string | null }>(
513
+ const call = await apiCall<{
514
+ credentials: Record<string, unknown>
515
+ secretFieldsConfigured?: SecretFieldsConfigured
516
+ updatedAt?: string | null
517
+ }>(
494
518
  `/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`,
495
519
  undefined,
496
520
  { fallback: null },
497
521
  )
498
522
  if (call.ok && call.result) {
499
523
  setCredentialsUpdatedAt(call.result.updatedAt ?? null)
524
+ setSecretFieldsConfigured(call.result.secretFieldsConfigured ?? {})
500
525
  }
501
526
  if (call.ok && call.result?.credentials) {
502
527
  const next = { ...call.result.credentials }
@@ -713,35 +738,32 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
713
738
  if (!currentIntegrationId) return
714
739
  setIsSavingCredentials(true)
715
740
  try {
716
- const sanitizedValues = { ...values }
717
- if (currentIntegrationId === 'storage_s3') {
718
- const authMode = sanitizedValues.authMode
719
- if (authMode !== 'access_keys' && authMode !== 'ambient') {
720
- const hasKeys = Boolean(sanitizedValues.accessKeyId || sanitizedValues.secretAccessKey)
721
- sanitizedValues.authMode = hasKeys ? 'access_keys' : 'ambient'
722
- }
723
- if (sanitizedValues.authMode === 'ambient') {
724
- delete sanitizedValues.accessKeyId
725
- delete sanitizedValues.secretAccessKey
726
- delete sanitizedValues.sessionToken
727
- }
728
- }
741
+ const credentialFields = (
742
+ detail?.integration.credentials?.fields
743
+ ?? detail?.bundle?.credentials?.fields
744
+ ?? []
745
+ )
746
+ const savePayload = buildIntegrationCredentialSavePayload(
747
+ currentIntegrationId,
748
+ values,
749
+ credentialFields,
750
+ secretFieldsConfigured,
751
+ )
729
752
  const call = await runMutationWithContext({
730
753
  actionId: 'save-credentials',
731
754
  tabId: 'credentials',
732
- mutationPayload: { integrationId: currentIntegrationId, credentials: sanitizedValues },
755
+ mutationPayload: { integrationId: currentIntegrationId, ...savePayload },
733
756
  operation: () => withScopedApiRequestHeaders(
734
757
  buildOptimisticLockHeader(credentialsUpdatedAt),
735
758
  () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`, {
736
759
  method: 'PUT',
737
760
  headers: { 'Content-Type': 'application/json' },
738
- body: JSON.stringify({ credentials: sanitizedValues }),
761
+ body: JSON.stringify(savePayload),
739
762
  }, { fallback: null }),
740
763
  ),
741
764
  })
742
765
 
743
766
  if (call.ok) {
744
- setCredValues(sanitizedValues)
745
767
  setCredentialsFormKey((current) => current + 1)
746
768
  flash(t('integrations.detail.credentials.saved'), 'success')
747
769
  void loadCredentials()
@@ -759,7 +781,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
759
781
  } finally {
760
782
  setIsSavingCredentials(false)
761
783
  }
762
- }, [credentialsUpdatedAt, loadCredentials, resolveCurrentIntegrationId, runMutationWithContext, t])
784
+ }, [credentialsUpdatedAt, detail, loadCredentials, resolveCurrentIntegrationId, runMutationWithContext, secretFieldsConfigured, t])
763
785
 
764
786
  const handleVersionChange = React.useCallback(async (version: string) => {
765
787
  const currentIntegrationId = resolveCurrentIntegrationId()
@@ -837,8 +859,12 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
837
859
  [detail?.bundle?.credentials?.fields, detail?.integration.credentials?.fields],
838
860
  )
839
861
  const credentialFormFields = React.useMemo(
840
- () => buildCredentialFields(editableCredentialFields),
841
- [editableCredentialFields],
862
+ () => buildCredentialFields(editableCredentialFields, secretFieldsConfigured, t),
863
+ [editableCredentialFields, secretFieldsConfigured, t],
864
+ )
865
+ const credentialFormValues = React.useMemo(
866
+ () => buildCredentialEditValues(credValues, secretFieldsConfigured),
867
+ [credValues, secretFieldsConfigured],
842
868
  )
843
869
  const credentialSchema = React.useMemo(() => (
844
870
  z.object({}).passthrough().superRefine((rawValues, ctx) => {
@@ -880,7 +906,11 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
880
906
 
881
907
  const normalizedValue = typeof value === 'string' ? value : ''
882
908
 
883
- if (field.required && normalizedValue.trim().length === 0) {
909
+ if (
910
+ field.required
911
+ && normalizedValue.trim().length === 0
912
+ && !(field.type === 'secret' && secretFieldsConfigured[field.key])
913
+ ) {
884
914
  ctx.addIssue({
885
915
  code: z.ZodIssueCode.custom,
886
916
  path: [field.key],
@@ -922,7 +952,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
922
952
  }
923
953
  })
924
954
  })
925
- ) as z.ZodType<Record<string, unknown>>, [editableCredentialFields, t])
955
+ ) as z.ZodType<Record<string, unknown>>, [editableCredentialFields, secretFieldsConfigured, t])
926
956
  const latestHealthLog = React.useMemo(() => logs.find(isHealthLog) ?? null, [logs])
927
957
  const latestOperationalLog = React.useMemo(
928
958
  () => logs.find((log) => (
@@ -1257,7 +1287,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
1257
1287
  entityId="integrations.integration"
1258
1288
  schema={credentialSchema}
1259
1289
  fields={credentialFormFields}
1260
- initialValues={credValues}
1290
+ initialValues={credentialFormValues}
1261
1291
  onSubmit={handleSaveCredentials}
1262
1292
  embedded
1263
1293
  hideFooterActions
@@ -8,6 +8,7 @@ import { Badge } from '@open-mercato/ui/primitives/badge'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { Switch } from '@open-mercato/ui/primitives/switch'
10
10
  import { Input } from '@open-mercato/ui/primitives/input'
11
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
11
12
  import {
12
13
  Select,
13
14
  SelectContent,
@@ -23,6 +24,11 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
23
24
  import { useT } from '@open-mercato/shared/lib/i18n/context'
24
25
  import type { CredentialFieldType, IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
25
26
  import { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
27
+ import {
28
+ buildCredentialEditValues,
29
+ buildCredentialSavePayload,
30
+ type SecretFieldsConfigured,
31
+ } from '../../credential-secret-fields'
26
32
 
27
33
  type CredentialField = IntegrationCredentialField
28
34
 
@@ -88,6 +94,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
88
94
  const [error, setError] = React.useState<string | null>(null)
89
95
  const [isNotFound, setIsNotFound] = React.useState(false)
90
96
  const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
97
+ const [secretFieldsConfigured, setSecretFieldsConfigured] = React.useState<SecretFieldsConfigured>({})
91
98
  const [credentialsUpdatedAt, setCredentialsUpdatedAt] = React.useState<string | null>(null)
92
99
  const [isSavingCreds, setIsSavingCreds] = React.useState(false)
93
100
  const [togglingIds, setTogglingIds] = React.useState<Set<string>>(new Set())
@@ -134,13 +141,18 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
134
141
  }
135
142
  setDetail(call.result)
136
143
 
137
- const credCall = await apiCall<{ credentials: Record<string, unknown>; updatedAt?: string | null }>(
144
+ const credCall = await apiCall<{
145
+ credentials: Record<string, unknown>
146
+ secretFieldsConfigured?: SecretFieldsConfigured
147
+ updatedAt?: string | null
148
+ }>(
138
149
  `/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`,
139
150
  undefined,
140
151
  { fallback: null },
141
152
  )
142
153
  if (credCall.ok && credCall.result) {
143
154
  setCredentialsUpdatedAt(credCall.result.updatedAt ?? null)
155
+ setSecretFieldsConfigured(credCall.result.secretFieldsConfigured ?? {})
144
156
  }
145
157
  if (credCall.ok && credCall.result?.credentials) {
146
158
  const next = { ...credCall.result.credentials }
@@ -151,7 +163,10 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
151
163
  next.authMode = hasKeys ? 'access_keys' : 'ambient'
152
164
  }
153
165
  }
154
- setCredValues(next)
166
+ setCredValues(buildCredentialEditValues(
167
+ next,
168
+ credCall.result.secretFieldsConfigured ?? {},
169
+ ))
155
170
  }
156
171
  setIsLoading(false)
157
172
  }, [resolveCurrentBundleId, t])
@@ -163,8 +178,13 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
163
178
  if (!currentBundleId) return
164
179
  setIsSavingCreds(true)
165
180
  try {
181
+ const savePayload = buildCredentialSavePayload(
182
+ credValues,
183
+ detail?.bundle?.credentials?.fields ?? [],
184
+ secretFieldsConfigured,
185
+ )
166
186
  const call = await runMutation({
167
- mutationPayload: { bundleId: currentBundleId, credentials: credValues },
187
+ mutationPayload: { bundleId: currentBundleId, ...savePayload },
168
188
  context: {
169
189
  formId: mutationContextId,
170
190
  operation: 'update',
@@ -179,7 +199,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
179
199
  () => apiCall(`/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`, {
180
200
  method: 'PUT',
181
201
  headers: { 'Content-Type': 'application/json' },
182
- body: JSON.stringify({ credentials: credValues }),
202
+ body: JSON.stringify(savePayload),
183
203
  }, { fallback: null }),
184
204
  ),
185
205
  })
@@ -194,7 +214,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
194
214
  } finally {
195
215
  setIsSavingCreds(false)
196
216
  }
197
- }, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, load, t])
217
+ }, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, detail?.bundle?.credentials?.fields, load, secretFieldsConfigured, t])
198
218
 
199
219
  const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean, updatedAt?: string | null) => {
200
220
  setTogglingIds((prev) => new Set(prev).add(integrationId))
@@ -295,15 +315,15 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
295
315
  <CardContent className="space-y-4">
296
316
  {credFields.filter(isFieldVisible).map((field) => (
297
317
  <div key={field.key} className="space-y-1.5">
298
- <label className="text-sm font-medium">
299
- {field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
318
+ <label htmlFor={`bundle-credential-${field.key}`} className="text-sm font-medium">
319
+ {field.label}{field.required && <span className="ml-0.5 text-destructive">*</span>}
300
320
  </label>
301
321
  {field.type === 'select' && field.options ? (
302
322
  <Select
303
323
  value={(credValues[field.key] as string) || undefined}
304
324
  onValueChange={(value) => setCredValues((prev) => ({ ...prev, [field.key]: value ?? '' }))}
305
325
  >
306
- <SelectTrigger>
326
+ <SelectTrigger id={`bundle-credential-${field.key}`}>
307
327
  <SelectValue placeholder="—" />
308
328
  </SelectTrigger>
309
329
  <SelectContent>
@@ -314,17 +334,32 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
314
334
  </Select>
315
335
  ) : field.type === 'boolean' ? (
316
336
  <Switch
337
+ id={`bundle-credential-${field.key}`}
317
338
  checked={Boolean(credValues[field.key])}
318
339
  onCheckedChange={(checked) => setCredValues((prev) => ({ ...prev, [field.key]: checked }))}
319
340
  />
341
+ ) : field.type === 'secret' ? (
342
+ <PasswordInput
343
+ id={`bundle-credential-${field.key}`}
344
+ placeholder={field.placeholder}
345
+ value={(credValues[field.key] as string) ?? ''}
346
+ onChange={(event) => setCredValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
347
+ autoComplete="new-password"
348
+ />
320
349
  ) : (
321
350
  <Input
322
- type={field.type === 'secret' ? 'password' : 'text'}
351
+ id={`bundle-credential-${field.key}`}
352
+ type="text"
323
353
  placeholder={field.placeholder}
324
354
  value={(credValues[field.key] as string) ?? ''}
325
355
  onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
326
356
  />
327
357
  )}
358
+ {field.type === 'secret' && secretFieldsConfigured[field.key] ? (
359
+ <p className="text-xs text-muted-foreground">
360
+ {t('integrations.detail.credentials.secretConfigured')}
361
+ </p>
362
+ ) : null}
328
363
  </div>
329
364
  ))}
330
365
  <Button type="button" onClick={() => void handleSaveCredentials()} disabled={isSavingCreds}>
@@ -0,0 +1,84 @@
1
+ import type { IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
2
+ import { SECRET_CREDENTIAL_FIELD_TYPES } from '../../lib/credentials-masking'
3
+
4
+ export type SecretFieldsConfigured = Record<string, boolean>
5
+
6
+ export type CredentialSavePayload = {
7
+ credentials: Record<string, unknown>
8
+ unchangedSecretFields?: string[]
9
+ }
10
+
11
+ export function buildCredentialEditValues(
12
+ credentials: Record<string, unknown>,
13
+ secretFieldsConfigured: SecretFieldsConfigured,
14
+ ): Record<string, unknown> {
15
+ const editValues = { ...credentials }
16
+
17
+ for (const [fieldKey, configured] of Object.entries(secretFieldsConfigured)) {
18
+ if (configured) delete editValues[fieldKey]
19
+ }
20
+
21
+ return editValues
22
+ }
23
+
24
+ export function buildCredentialSavePayload(
25
+ values: Record<string, unknown>,
26
+ fields: readonly IntegrationCredentialField[],
27
+ secretFieldsConfigured: SecretFieldsConfigured,
28
+ deliberatelyClearedSecretFields: ReadonlySet<string> = new Set(),
29
+ ): CredentialSavePayload {
30
+ const credentials = { ...values }
31
+ const unchangedSecretFields = new Set<string>()
32
+
33
+ for (const field of fields) {
34
+ if (!SECRET_CREDENTIAL_FIELD_TYPES.has(field.type)) continue
35
+
36
+ if (deliberatelyClearedSecretFields.has(field.key)) {
37
+ delete credentials[field.key]
38
+ continue
39
+ }
40
+
41
+ if (!secretFieldsConfigured[field.key]) continue
42
+ const value = credentials[field.key]
43
+ if (value !== undefined && value !== '') continue
44
+
45
+ delete credentials[field.key]
46
+ unchangedSecretFields.add(field.key)
47
+ }
48
+
49
+ return unchangedSecretFields.size > 0
50
+ ? { credentials, unchangedSecretFields: [...unchangedSecretFields] }
51
+ : { credentials }
52
+ }
53
+
54
+ export function buildIntegrationCredentialSavePayload(
55
+ integrationId: string,
56
+ values: Record<string, unknown>,
57
+ fields: readonly IntegrationCredentialField[],
58
+ secretFieldsConfigured: SecretFieldsConfigured,
59
+ ): CredentialSavePayload {
60
+ const normalizedValues = { ...values }
61
+ const deliberatelyClearedSecretFields = new Set<string>()
62
+
63
+ if (integrationId === 'storage_s3') {
64
+ const authMode = normalizedValues.authMode
65
+ if (authMode !== 'access_keys' && authMode !== 'ambient') {
66
+ const hasKeys = Boolean(normalizedValues.accessKeyId || normalizedValues.secretAccessKey)
67
+ normalizedValues.authMode = hasKeys ? 'access_keys' : 'ambient'
68
+ }
69
+ if (normalizedValues.authMode === 'ambient') {
70
+ delete normalizedValues.accessKeyId
71
+ delete normalizedValues.secretAccessKey
72
+ delete normalizedValues.sessionToken
73
+ deliberatelyClearedSecretFields.add('secretAccessKey')
74
+ deliberatelyClearedSecretFields.add('sessionToken')
75
+ }
76
+ }
77
+
78
+ return buildCredentialSavePayload(
79
+ normalizedValues,
80
+ fields,
81
+ secretFieldsConfigured,
82
+ deliberatelyClearedSecretFields,
83
+ )
84
+ }
@@ -1,13 +1,23 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ const credentialFieldKeySchema = z.string().min(1).max(128)
4
+
3
5
  export const saveCredentialsSchema = z.object({
4
6
  credentials: z.record(
5
- z.string().min(1).max(128),
7
+ credentialFieldKeySchema,
6
8
  z.union([z.string().max(20_000), z.number(), z.boolean(), z.null()]),
7
9
  ),
10
+ unchangedSecretFields: z.array(credentialFieldKeySchema).max(200).optional(),
8
11
  }).refine((value) => Object.keys(value.credentials).length <= 200, {
9
12
  message: 'At most 200 credential fields are allowed',
10
- })
13
+ }).refine(
14
+ (value) => !value.unchangedSecretFields
15
+ || new Set(value.unchangedSecretFields).size === value.unchangedSecretFields.length,
16
+ {
17
+ message: 'Unchanged secret field names must be unique',
18
+ path: ['unchangedSecretFields'],
19
+ },
20
+ )
11
21
 
12
22
  export type SaveCredentialsInput = z.infer<typeof saveCredentialsSchema>
13
23
 
@@ -15,6 +15,7 @@
15
15
  "integrations.detail.credentials.save": "Zugangsdaten speichern",
16
16
  "integrations.detail.credentials.saveError": "Zugangsdaten konnten nicht gespeichert werden",
17
17
  "integrations.detail.credentials.saved": "Zugangsdaten gespeichert",
18
+ "integrations.detail.credentials.secretConfigured": "Konfiguriert. Geben Sie einen neuen Wert ein, um ihn zu ersetzen.",
18
19
  "integrations.detail.credentials.validation.boolean": "Select a valid value.",
19
20
  "integrations.detail.credentials.validation.option": "Select one of the available options.",
20
21
  "integrations.detail.credentials.validation.required": "{field} is required.",
@@ -15,6 +15,7 @@
15
15
  "integrations.detail.credentials.save": "Save Credentials",
16
16
  "integrations.detail.credentials.saveError": "Failed to save credentials",
17
17
  "integrations.detail.credentials.saved": "Credentials saved",
18
+ "integrations.detail.credentials.secretConfigured": "Configured. Enter a new value to replace it.",
18
19
  "integrations.detail.credentials.validation.boolean": "Select a valid value.",
19
20
  "integrations.detail.credentials.validation.option": "Select one of the available options.",
20
21
  "integrations.detail.credentials.validation.required": "{field} is required.",
@@ -15,6 +15,7 @@
15
15
  "integrations.detail.credentials.save": "Guardar credenciales",
16
16
  "integrations.detail.credentials.saveError": "No se pudieron guardar las credenciales",
17
17
  "integrations.detail.credentials.saved": "Credenciales guardadas",
18
+ "integrations.detail.credentials.secretConfigured": "Configurado. Introduce un valor nuevo para reemplazarlo.",
18
19
  "integrations.detail.credentials.validation.boolean": "Select a valid value.",
19
20
  "integrations.detail.credentials.validation.option": "Select one of the available options.",
20
21
  "integrations.detail.credentials.validation.required": "{field} is required.",
@@ -15,6 +15,7 @@
15
15
  "integrations.detail.credentials.save": "자격 증명 저장",
16
16
  "integrations.detail.credentials.saveError": "자격 증명 저장에 실패했습니다",
17
17
  "integrations.detail.credentials.saved": "자격 증명이 저장되었습니다",
18
+ "integrations.detail.credentials.secretConfigured": "구성됨. 바꾸려면 새 값을 입력하세요.",
18
19
  "integrations.detail.credentials.validation.boolean": "유효한 값을 선택하세요.",
19
20
  "integrations.detail.credentials.validation.option": "사용 가능한 옵션 중 하나를 선택하세요.",
20
21
  "integrations.detail.credentials.validation.required": "{field}은(는) 필수입니다.",
@@ -15,6 +15,7 @@
15
15
  "integrations.detail.credentials.save": "Zapisz dane",
16
16
  "integrations.detail.credentials.saveError": "Nie udało się zapisać danych",
17
17
  "integrations.detail.credentials.saved": "Dane zapisane",
18
+ "integrations.detail.credentials.secretConfigured": "Skonfigurowano. Wpisz nową wartość, aby ją zastąpić.",
18
19
  "integrations.detail.credentials.validation.boolean": "Select a valid value.",
19
20
  "integrations.detail.credentials.validation.option": "Select one of the available options.",
20
21
  "integrations.detail.credentials.validation.required": "{field} is required.",
@@ -84,22 +84,26 @@ export function maskSecretCredentials(
84
84
  }
85
85
 
86
86
  /**
87
- * Reverse of {@link maskSecretCredentials} for the save path. When the client
88
- * submits the mask sentinel for a secret field it means "leave it unchanged":
89
- * restore the existing stored secret, or drop the field entirely when nothing
90
- * was previously stored (so the literal sentinel is never persisted). Any other
91
- * value (including an empty string, which clears the secret) is written as-is.
87
+ * Reverse of {@link maskSecretCredentials} for the save path. The exact mask
88
+ * sentinel and explicitly listed omitted secret fields mean "leave unchanged".
89
+ * Explicit values win over the list, including an empty string used to clear a
90
+ * secret, while plain omission retains the full-replacement contract.
92
91
  */
93
92
  export function mergeMaskedSecretCredentials(
94
93
  schema: IntegrationCredentialsSchema | undefined,
95
94
  incoming: Record<string, unknown>,
96
95
  existing: Record<string, unknown>,
96
+ unchangedSecretFields: readonly string[] = [],
97
97
  ): Record<string, unknown> {
98
98
  const merged: Record<string, unknown> = { ...incoming }
99
+ const unchangedSecretFieldSet = new Set(unchangedSecretFields)
99
100
 
100
101
  for (const field of schema?.fields ?? []) {
101
102
  if (!isSecretField(field.type)) continue
102
- if (merged[field.key] !== MASKED_SECRET_VALUE) continue
103
+ const hasIncomingValue = Object.prototype.hasOwnProperty.call(merged, field.key)
104
+ const submittedMask = merged[field.key] === MASKED_SECRET_VALUE
105
+ const explicitlyUnchanged = !hasIncomingValue && unchangedSecretFieldSet.has(field.key)
106
+ if (!submittedMask && !explicitlyUnchanged) continue
103
107
 
104
108
  if (hasPresentValue(existing[field.key])) {
105
109
  merged[field.key] = existing[field.key]