Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/integrations/square.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details"
{/* MANUAL-CONTENT-START:intro */}
Use [Square](https://squareup.com/) to take, retrieve, list, cancel, complete, or refund payments; manage customer profiles; create, retrieve, search, or pay for orders; and create, publish, cancel, or delete invoices.

The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production access token. Payment and refund actions change live commerce records; use the action's idempotency fields before running them.
The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production or sandbox access token, and choose the environment that issued it; a sandbox credential runs every action against the Square sandbox. Payment and refund actions on a production credential change live commerce records; use the action's idempotency fields before running them.
{/* MANUAL-CONTENT-END */}

## Use the block [#usage-instructions]
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -17950,6 +17950,12 @@
"additionalProperties": false
}
},
"defaultValue": {
"description": "Value an optional field takes when it is omitted.",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"hint": {
"description": "Provider-specific setup guidance.",
"type": "string",
Expand Down Expand Up @@ -18106,6 +18112,12 @@
"additionalProperties": false
}
},
"defaultValue": {
"description": "Value an optional field takes when it is omitted.",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"hint": {
"description": "Provider-specific setup guidance.",
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { ServiceAccountSecretFields } from '@/lib/credentials/service-accou
import {
CLIENT_CREDENTIALS_AUTH_METHOD,
getTokenServiceAccountErrorMessage,
isTokenServiceAccountFieldRequired,
type TokenServiceAccountDescriptor,
type TokenServiceAccountField,
} from '@/lib/credentials/token-service-accounts/descriptors'
Expand Down Expand Up @@ -115,10 +116,20 @@ function TokenServiceAccountModalForm({
? CLIENT_CREDENTIALS_AUTH_METHOD
: 'token')
const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD
const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
const domainField = descriptor.fields.find((field) => field.id === 'domain')
const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId')
/**
* A new connection starts on the domain's default (the region or host it had before the field
* existed). A reconnect starts blank, which keeps the stored value: the server carries it
* forward, since the stored one is never returned to the client.
*/
const initialDomain = credentialId ? '' : (domainField?.defaultValue ?? '')

const [apiToken, setApiToken] = useState('')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [domain, setDomain] = useState('')
const [domain, setDomain] = useState(initialDomain)
const [providerWorkspaceId, setProviderWorkspaceId] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
Expand All @@ -127,26 +138,65 @@ function TokenServiceAccountModalForm({
const createCredential = useCreateScopedCredential()
const updateCredential = useUpdateScopedCredential()

const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
const domainField = descriptor.fields.find((field) => field.id === 'domain')
const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId')

const trimmedToken = apiToken.trim()
const normalizedDomain = domainField?.url ? domain.trim() : normalizeDomainInput(domain)
const trimmedWorkspaceId = providerWorkspaceId.trim()
const isPending =
createCredential.isPending || updateCredential.isPending || submitPending === true
const isDisabled =
!authMethod ||
(usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) ||
(Boolean(domainField) && !normalizedDomain) ||
(Boolean(workspaceField) && !providerWorkspaceId.trim()) ||
Boolean(domainField && isTokenServiceAccountFieldRequired(domainField) && !normalizedDomain) ||
(Boolean(workspaceField) && !trimmedWorkspaceId) ||
isPending

const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => {
if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined
return field.hintPattern.test(value) ? undefined : field.hintMessage
}

const renderConnectionField = (
field: TokenServiceAccountField,
value: string,
setValue: (value: string) => void,
formatError?: string
) => {
const onChange = (next: string) => {
setValue(next)
if (error) setError(null)
}
const placeholder =
credentialId && !isTokenServiceAccountFieldRequired(field)
? `Keep the current ${field.label.toLowerCase()}`
: field.placeholder
if (field.options) {
return (
<ChipModalField
type='dropdown'
title={field.label}
value={value || undefined}
onChange={onChange}
options={field.options}
placeholder={placeholder}
align='start'
required={isTokenServiceAccountFieldRequired(field)}
/>
)
}
return (
<ChipModalField
type='input'
title={field.label}
value={value}
onChange={onChange}
placeholder={placeholder}
autoComplete='off'
required={isTokenServiceAccountFieldRequired(field)}
error={formatError}
/>
)
}

const handleSubmit = async () => {
setError(null)
if (isDisabled) return
Expand All @@ -159,8 +209,8 @@ function TokenServiceAccountModalForm({
clientSecret: clientSecret.trim(),
}
: { apiToken: tokenField?.preserveWhitespace ? apiToken : trimmedToken }),
...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}),
...(domainField ? { domain: normalizedDomain } : {}),
...(workspaceField ? { providerWorkspaceId: trimmedWorkspaceId } : {}),
...(domainField && normalizedDomain ? { domain: normalizedDomain } : {}),
}
if (onSubmitCredentials) {
const savedId = await onSubmitCredentials({
Expand Down Expand Up @@ -275,32 +325,15 @@ function TokenServiceAccountModalForm({
</ChipModalField>
)}

{workspaceField && (
<ChipModalField
type='input'
title={workspaceField.label}
value={providerWorkspaceId}
onChange={setProviderWorkspaceId}
placeholder={workspaceField.placeholder}
required
autoComplete='off'
/>
)}
{domainField && (
<ChipModalField
type='input'
title={domainField.label}
value={domain}
onChange={(value) => {
setDomain(value)
if (error) setError(null)
}}
placeholder={domainField.placeholder}
autoComplete='off'
required
error={hintFor(domainField, normalizedDomain)}
/>
)}
{workspaceField &&
renderConnectionField(workspaceField, providerWorkspaceId, setProviderWorkspaceId)}
{domainField &&
renderConnectionField(
domainField,
domain,
setDomain,
hintFor(domainField, normalizedDomain)
)}

<ChipModalField
type='input'
Expand Down
6 changes: 2 additions & 4 deletions apps/sim/blocks/blocks/new_relic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { withSavedCredential } from '@/blocks/utils'
import type { NewRelicCustomAttributes } from '@/tools/new_relic/types'
import { NEW_RELIC_REGIONS } from '@/tools/new_relic/utils'

function parseCustomAttributes(value: unknown): NewRelicCustomAttributes | undefined {
if (!value) return undefined
Expand Down Expand Up @@ -77,10 +78,7 @@ export const NewRelicBlock: BlockConfig = {
id: 'region',
title: 'Region',
type: 'dropdown',
options: [
{ label: 'US', id: 'us' },
{ label: 'EU', id: 'eu' },
],
options: NEW_RELIC_REGIONS.options.map(({ value, label }) => ({ id: value, label })),
value: () => 'us',
},
{
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/api/contracts/v2/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ export const v2CredentialProviderFieldSchema = z
.max(20)
.optional()
.describe('Fixed values accepted by a selector field.'),
defaultValue: z
.string()
.min(1)
.max(255)
.optional()
.describe('Value an optional field takes when it is omitted.'),
hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'),
})
.strict()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ describe('v2 credential provider catalog contract', () => {
).toBe(false)
})

it('tells API callers the region an optional region field takes when omitted', async () => {
const providers = await listProviders()
const sentry = providers.find(
(provider) =>
provider.type === 'service_account' && provider.providerId === 'sentry-service-account'
)

expect(sentry?.fields.find((field) => field.id === 'domain')).toMatchObject({
required: false,
defaultValue: 'us',
options: [
{ value: 'us', label: 'US (sentry.io)' },
{ value: 'de', label: 'EU (de.sentry.io)' },
],
})
expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
})

it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => {
const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context)
const installation = catalog.find(
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/lib/credentials/application/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface CredentialProviderField {
multiline: boolean
requiredForAuthMethods?: string[]
options?: CredentialProviderFieldOption[]
/** The value an optional field takes when it is omitted (a region's default). */
defaultValue?: string
hint?: string
}

Expand Down Expand Up @@ -119,13 +121,18 @@ function providerField(
id: field.id,
label: field.label,
placeholder: field.placeholder,
required: !('optional' in field && field.optional),
required:
!('optional' in field && field.optional) &&
!('defaultValue' in field && field.defaultValue !== undefined),
secret: field.secret,
multiline: 'multiline' in field && field.multiline === true,
...('requiredForAuthMethods' in field && field.requiredForAuthMethods
? { requiredForAuthMethods: [...field.requiredForAuthMethods] }
: {}),
...('options' in field && field.options ? { options: [...field.options] } : {}),
...('defaultValue' in field && field.defaultValue !== undefined
? { defaultValue: field.defaultValue }
: {}),
...('hint' in field && field.hint
? { hint: field.hint }
: 'hintMessage' in field && field.hintMessage
Expand Down
35 changes: 35 additions & 0 deletions apps/sim/lib/credentials/orchestration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,41 @@ describe('performUpdateCredential — service-account secret rotation', () => {
)
})

it.each([
{ domain: undefined, expected: 'eu' },
{ domain: ' ', expected: 'eu' },
{ domain: 'federal', expected: 'federal' },
])(
'sends region $expected when a token credential stored on eu is reconnected with $domain',
async ({ domain, expected }) => {
mockCredential({ providerId: 'launchdarkly-service-account', displayName: 'Flags (eu)' })
mockStoredBlob({
type: 'token_service_account',
providerId: 'launchdarkly-service-account',
apiToken: 'old-token',
domain: 'eu',
})
mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({
providerId: 'launchdarkly-service-account',
encryptedServiceAccountKey: 'new-cipher',
displayName: `LaunchDarkly (${expected})`,
auditMetadata: {},
})

await performUpdateCredential({
credentialId: 'cred-1',
userId: 'user-1',
apiToken: 'new-token',
...(domain === undefined ? {} : { domain }),
})

expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
'launchdarkly-service-account',
expect.objectContaining({ apiToken: 'new-token', domain: expected })
)
}
)

it('fails the reconnect when the stored secret cannot be decrypted', async () => {
mockCredential()
queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }])
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/lib/credentials/orchestration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
import {
getTokenServiceAccountDomainDefault,
SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
} from '@/lib/credentials/token-service-accounts/descriptors'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import {
assertReconnectedSubjectUnclaimed,
Expand Down Expand Up @@ -159,7 +162,7 @@ async function readStoredSecretBlob(credentialId: string): Promise<Record<string
*/
function readStoredField(
blob: Record<string, unknown> | null,
field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct'
field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct' | 'domain'
): string | undefined {
const value = blob?.[field]
return typeof value === 'string' && value ? value : undefined
Expand Down Expand Up @@ -364,6 +367,10 @@ export async function updateCredentialRecord(
providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID &&
params.atlassianProduct === undefined
const needsStoredUsername = params.username === undefined && isMultiGrantProvider
// A token provider's defaulted region or host is left blank on a reconnect that keeps it;
// filling the default instead would move an EU or sandbox credential to the default host.
const needsStoredDomain =
!params.domain?.trim() && getTokenServiceAccountDomainDefault(providerId) !== undefined
// A keyless AWS role, Databricks principal, Microsoft app, Google service account, or
// Snowflake service user keeps its trusted subject across reconnects.
const needsStoredBoundSubject =
Expand All @@ -389,6 +396,7 @@ export async function updateCredentialRecord(
needsStoredScope ||
needsStoredAuthMethod ||
needsStoredUsername ||
needsStoredDomain ||
needsStoredIdentity ||
needsStoredAtlassianProduct ||
needsStoredBoundSubject
Expand Down Expand Up @@ -445,7 +453,7 @@ export async function updateCredentialRecord(
signingSecret: params.signingSecret,
botToken: params.botToken,
apiToken: params.apiToken,
domain: params.domain,
domain: needsStoredDomain ? readStoredField(storedBlob, 'domain') : params.domain,
providerWorkspaceId: params.providerWorkspaceId,
atlassianProduct: needsStoredAtlassianProduct
? readStoredField(storedBlob, 'atlassianProduct') === 'confluence'
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/credentials/service-account-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ describe('required fields for a service-account create', () => {
)
})

it.each([
'launchdarkly-service-account',
'langsmith-service-account',
'square-service-account',
'sendgrid-service-account',
'hex-service-account',
'sentry-service-account',
])(
'lets a %s create omit the region or host it defaults, as it could before one existed',
(providerId) => {
expect(getMissingServiceAccountFields(providerId, { apiToken: 'token' })).toEqual([])
expect(getMissingServiceAccountFields(providerId, {})).toEqual(['apiToken'])
}
)

it('does not let a JSON configuration stand in for another token provider’s token', () => {
expect(
getMissingServiceAccountFields('hubspot-service-account', { serviceAccountJson: '{}' })
Expand Down
Loading
Loading