@jskit-ai/connectors-web 0.1.1
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.
- package/README.md +113 -0
- package/package.json +31 -0
- package/src/client/IntegrationConfigurationFields.vue +301 -0
- package/src/client/index.js +1 -0
- package/test/configuration.spec.js +493 -0
- package/test/fixture/App.vue +126 -0
- package/test/fixture/index.html +2 -0
- package/test/fixture/main.js +8 -0
- package/test/playwright.config.js +18 -0
- package/test/vite.config.js +9 -0
- package/test-results/.last-run.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Integration configuration fields
|
|
2
|
+
|
|
3
|
+
`IntegrationConfigurationFields` is a controlled Vue/Vuetify component imported
|
|
4
|
+
from `@jskit-ai/connectors-web/client`. It edits the same configuration accepted
|
|
5
|
+
by `@jskit-ai/connectors-core/shared/configuration`.
|
|
6
|
+
|
|
7
|
+
```vue
|
|
8
|
+
<IntegrationConfigurationFields
|
|
9
|
+
v-model="configuration"
|
|
10
|
+
integration-id="calendar"
|
|
11
|
+
:provider="googleCalendarDefinition"
|
|
12
|
+
:field-errors="fieldErrors"
|
|
13
|
+
:disabled="saving"
|
|
14
|
+
:callback-url="resolvedCallbackUrl"
|
|
15
|
+
/>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The parent owns loading, saving, optimistic concurrency and navigation, using
|
|
19
|
+
its normal JSKIT `useAddEdit()`/command/resource flow. This component makes no
|
|
20
|
+
HTTP requests and creates no second source of connection state. Model updates
|
|
21
|
+
preserve other integration slots, registrations and extension values.
|
|
22
|
+
|
|
23
|
+
Provider `settingsFields` metadata supplies labelled select controls when `items`
|
|
24
|
+
are provided and text controls otherwise, with optional hints and placeholders.
|
|
25
|
+
Defaults and applicable fields come from
|
|
26
|
+
`getProviderSettingsSchema(provider, settings).getFieldDefinitions()`, which also
|
|
27
|
+
supports schemas containing custom validators. A provider may select a schema
|
|
28
|
+
from its current settings; changing Redshift deployment type removes fields
|
|
29
|
+
that belong to the other type and shows the new required inputs. The CLI rejects
|
|
30
|
+
mixed fields through that same schema. Clearing optional text omits it
|
|
31
|
+
from the serialized configuration. The parent
|
|
32
|
+
validates with the same provider definitions and saves the returned normalized
|
|
33
|
+
configuration, so forms and CLI edits apply the same defaults and errors.
|
|
34
|
+
For providers with a separately named credential, `apiKeyReferenceLabel`
|
|
35
|
+
customizes the primary reference field's label. Twilio uses **API key secret
|
|
36
|
+
reference** alongside the Account SID and API Key SID settings; the default
|
|
37
|
+
label for other providers remains **API key reference**.
|
|
38
|
+
`apiKeyReferenceHint` customizes the reference hint. PostHog uses it to explain
|
|
39
|
+
that its project token is publishable and cannot read private analytics. The
|
|
40
|
+
saved value still follows the same reference format as CLI configuration.
|
|
41
|
+
|
|
42
|
+
Providers with multiple `authenticationMethods` get an Authentication selector;
|
|
43
|
+
`authenticationLabels` and `authenticationHint` supply provider-specific copy.
|
|
44
|
+
`settingsFields[].authenticationMethods` controls which fields apply. Changing
|
|
45
|
+
mode removes the old authentication references and settings that no longer
|
|
46
|
+
apply. Selecting OAuth creates a new project-owned registration with editable
|
|
47
|
+
client fields and Env references, without replacing existing registrations. The
|
|
48
|
+
App registration selector can then choose an existing registration instead.
|
|
49
|
+
With `apiKeySecretOptional`, clearing the primary reference omits it
|
|
50
|
+
instead of saving an empty string. ClickHouse uses this for an empty database
|
|
51
|
+
password. Its separate `none` mode hides credential fields entirely. These
|
|
52
|
+
choices use the same shared validation as a hand-edited configuration file.
|
|
53
|
+
|
|
54
|
+
`scopesForSettings(settings)` limits the displayed permission choices. Changing
|
|
55
|
+
a setting retains selected scopes that still apply and removes incompatible
|
|
56
|
+
ones, then selects any newly required scopes. Snowflake uses this for its
|
|
57
|
+
optional role; clearing the role restores its required default-role scope.
|
|
58
|
+
Optional choices stay unselected. Slack uses this for its user and bot identities. The shared parser rejects
|
|
59
|
+
an incompatible permission in imported JSON; it does not silently accept a
|
|
60
|
+
permission that the form cannot display. Changing back does not restore removed
|
|
61
|
+
permissions.
|
|
62
|
+
|
|
63
|
+
Scope entries with `required: true` render as disabled checkboxes. The parent
|
|
64
|
+
initializes these through the provider's recommended defaults, and shared
|
|
65
|
+
configuration validation rejects missing required permissions. LinkedIn requires
|
|
66
|
+
OpenID and profile while leaving email and publishing optional.
|
|
67
|
+
|
|
68
|
+
Providers declaring multiple `oauthGrantTypes` get an **OAuth flow** selector.
|
|
69
|
+
`oauthGrantHint` explains the credential choice. Switching to client credentials
|
|
70
|
+
removes the callback field and its saved reference, filters incompatible scopes,
|
|
71
|
+
and changes a personal account mode to the provider's first non-personal mode.
|
|
72
|
+
The client authentication method follows the provider's grant-specific methods.
|
|
73
|
+
Switching back requires entering the callback reference again and explicitly
|
|
74
|
+
selecting the needed permissions. The registration's client ID and secret
|
|
75
|
+
reference remain editable; use the credentials belonging to the chosen flow.
|
|
76
|
+
`getProviderScopes` supplies the same choices used by CLI validation.
|
|
77
|
+
Other integrations sharing that registration are preserved; validation reports
|
|
78
|
+
any newly incompatible configuration instead of silently rewriting those slots.
|
|
79
|
+
|
|
80
|
+
Panels provide details, registration credentials and permissions. The default
|
|
81
|
+
credential field edits a **reference**, suitable for developer tooling. A host
|
|
82
|
+
can use the `credential` slot to provide its secret-entry control; it must save
|
|
83
|
+
the secret through its secret service and keep only a reference in the model.
|
|
84
|
+
The slot receives `reference` and `disabled`. Registrations belong to the
|
|
85
|
+
application and use `source: "own"`. `access` provides a place for the host's
|
|
86
|
+
existing permissions editor.
|
|
87
|
+
|
|
88
|
+
The component handles OAuth configurations with pre-existing slots and
|
|
89
|
+
registrations, API-key configurations and declared no-credential modes. The
|
|
90
|
+
host owns catalogue search, slot creation and persistent file editing; public
|
|
91
|
+
Vibe64 supplies those through its source editor. Account connection and consent
|
|
92
|
+
controls are still pending. The browser fixture is a focused round-trip test,
|
|
93
|
+
not a complete application.
|
|
94
|
+
|
|
95
|
+
Run `npm exec --no -- playwright test --config
|
|
96
|
+
packages/connectors-web/test/playwright.config.js` from the JSKIT checkout.
|
|
97
|
+
Tests cover compact, medium and expanded layouts. The config respects managed
|
|
98
|
+
runner base URLs and temporary authentication state when supplied.
|
|
99
|
+
|
|
100
|
+
Definitions can set `permissionsHint` when permissions represent local operation
|
|
101
|
+
limits rather than an OAuth consent grant, as for S3 read/write choices. These
|
|
102
|
+
controls and statically declared permission choices remain visible in token
|
|
103
|
+
mode. Dynamically discovered OAuth permission choices appear only for OAuth or
|
|
104
|
+
service-account authentication; retained discovery metadata does not display
|
|
105
|
+
OAuth consent choices in API-key mode.
|
|
106
|
+
|
|
107
|
+
`accountModesForSettings(settings)` restricts account choices using the same
|
|
108
|
+
helper as configuration validation. Switching a credential-scoped setting to
|
|
109
|
+
OAuth creates a fresh registration with that setting's supported client
|
|
110
|
+
authentication method. This keeps Notion REST and hosted MCP credentials
|
|
111
|
+
separate; previous registrations remain available for their original slots.
|
|
112
|
+
|
|
113
|
+
Switching away from OAuth discards an unused own-registration draft whose Client ID is still empty. Registrations with a Client ID or another integration referencing them are preserved. This lets a newly added OAuth provider switch to an API key without an invisible empty registration blocking configuration validation.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/connectors-web",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared Vue fields for portable application integration configuration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./client": "./src/client/index.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@jskit-ai/connectors-core": "0.1.1"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"vue": "^3.5.13",
|
|
14
|
+
"vuetify": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"jskit": {
|
|
17
|
+
"kind": "runtime",
|
|
18
|
+
"capabilities": {
|
|
19
|
+
"provides": [],
|
|
20
|
+
"requires": []
|
|
21
|
+
},
|
|
22
|
+
"runtime": {
|
|
23
|
+
"server": {
|
|
24
|
+
"providers": []
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"providers": []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { getProviderAccountModes, getProviderAuthenticationMethods, getProviderClientAuthenticationMethods, getProviderScopes, getProviderSettingsSchema } from "@jskit-ai/connectors-core/shared/configuration";
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
modelValue: { type: Object, required: true },
|
|
7
|
+
integrationId: { type: String, required: true },
|
|
8
|
+
provider: { type: Object, required: true },
|
|
9
|
+
fieldErrors: { type: Object, default: () => ({}) },
|
|
10
|
+
disabled: Boolean,
|
|
11
|
+
callbackUrl: { type: String, default: "" }
|
|
12
|
+
});
|
|
13
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
14
|
+
const panels = ref(["details", "credentials"]);
|
|
15
|
+
const integration = computed(() => props.modelValue.integrations[props.integrationId]);
|
|
16
|
+
const registrationId = computed(() => integration.value?.authentication.registrationRef);
|
|
17
|
+
const registration = computed(() => props.modelValue.registrations[registrationId.value]);
|
|
18
|
+
const grantType = computed(() => registration.value?.grantType || "authorization_code");
|
|
19
|
+
const grantOptions = computed(() => (props.provider.oauthGrantTypes || ["authorization_code"]).map((value) => ({
|
|
20
|
+
value, title: { authorization_code: "User consent", client_credentials: "Service account" }[value] || value
|
|
21
|
+
})));
|
|
22
|
+
const modes = computed(() => getProviderAccountModes(props.provider, integration.value.settings || {}).filter((mode) =>
|
|
23
|
+
integration.value?.authentication.method !== "oauth2" || grantType.value !== "client_credentials" || mode !== "per-user"
|
|
24
|
+
).map((value) => ({
|
|
25
|
+
value,
|
|
26
|
+
title: { shared: "One shared account", "per-user": "Each app user's own account", assistant: "Assistant access" }[value] || value
|
|
27
|
+
})));
|
|
28
|
+
const registrationOptions = computed(() => Object.keys(props.modelValue.registrations));
|
|
29
|
+
const integrationPath = computed(() => `integrations.${props.integrationId}`);
|
|
30
|
+
const registrationPath = computed(() => `registrations.${registrationId.value}`);
|
|
31
|
+
const settingProperties = computed(() => getProviderSettingsSchema(props.provider, integration.value?.settings)?.getFieldDefinitions() || {});
|
|
32
|
+
const authenticationOptions = computed(() => getProviderAuthenticationMethods(props.provider, integration.value.settings).map((value) => ({
|
|
33
|
+
value, title: props.provider.authenticationLabels?.[value] || { "api-key": "API key", "service-account": "Service account", oauth2: "OAuth", none: "No credentials" }[value] || value
|
|
34
|
+
})));
|
|
35
|
+
const visibleSettings = computed(() => (props.provider.settingsFields || []).filter((field) =>
|
|
36
|
+
Object.hasOwn(settingProperties.value, field.name) &&
|
|
37
|
+
(!field.authenticationMethods || field.authenticationMethods.includes(integration.value.authentication.method))
|
|
38
|
+
));
|
|
39
|
+
const visibleScopes = computed(() => getProviderScopes(props.provider, integration.value.settings || {}, grantType.value, integration.value.authentication.method));
|
|
40
|
+
const assistantPermissions = [
|
|
41
|
+
{ value: "ask", title: "Ask each time" }, { value: "always", title: "Always allow" }, { value: "never", title: "Never allow" }
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function editAssistantPolicy(changes) {
|
|
45
|
+
editIntegration({ assistantPolicy: { enabled: true, defaultPermission: "ask", actions: {}, ...integration.value.assistantPolicy, ...changes } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function editIntegration(changes) {
|
|
49
|
+
const next = { ...integration.value, ...changes };
|
|
50
|
+
for (const key of Object.keys(changes)) if (changes[key] === undefined) delete next[key];
|
|
51
|
+
const registrations = { ...props.modelValue.registrations };
|
|
52
|
+
const previousRegistration = registrationId.value;
|
|
53
|
+
if (changes.authentication && next.authentication.method !== "oauth2" && previousRegistration &&
|
|
54
|
+
registrations[previousRegistration]?.source === "own" && registrations[previousRegistration]?.clientId === "" &&
|
|
55
|
+
!Object.entries(props.modelValue.integrations).some(([id, value]) =>
|
|
56
|
+
id !== props.integrationId && value.authentication.registrationRef === previousRegistration)) {
|
|
57
|
+
delete registrations[previousRegistration];
|
|
58
|
+
}
|
|
59
|
+
emit("update:modelValue", {
|
|
60
|
+
...props.modelValue,
|
|
61
|
+
registrations,
|
|
62
|
+
integrations: { ...props.modelValue.integrations, [props.integrationId]: next }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function editSetting(field, value) {
|
|
67
|
+
const settings = { ...integration.value.settings };
|
|
68
|
+
if (value === undefined || value === "") delete settings[field];
|
|
69
|
+
else settings[field] = value;
|
|
70
|
+
if (typeof props.provider.settingsSchema === "function") {
|
|
71
|
+
const fields = getProviderSettingsSchema(props.provider, settings).getFieldDefinitions();
|
|
72
|
+
for (const key of Object.keys(settings)) if (!Object.hasOwn(fields, key)) delete settings[key];
|
|
73
|
+
}
|
|
74
|
+
const available = getProviderScopes(props.provider, settings, grantType.value, integration.value.authentication.method);
|
|
75
|
+
const scopes = integration.value.scopes.filter((value) => available.some((scope) => scope.value === value));
|
|
76
|
+
for (const scope of available) if (scope.required && !scopes.includes(scope.value)) scopes.push(scope.value);
|
|
77
|
+
const methods = getProviderAuthenticationMethods(props.provider, settings);
|
|
78
|
+
const credentialScope = props.provider.settingsFields?.find((item) => item.name === field)?.credentialScope;
|
|
79
|
+
const changedProvider = credentialScope && credentialScope(value) !== credentialScope(integration.value.settings?.[field] ?? settingProperties.value[field]?.defaultTo);
|
|
80
|
+
const authentication = changedProvider || !methods.includes(integration.value.authentication.method)
|
|
81
|
+
? { method: methods[0] } : integration.value.authentication;
|
|
82
|
+
const accountModes = getProviderAccountModes(props.provider, settings);
|
|
83
|
+
const accountMode = accountModes.includes(integration.value.accountMode) ? integration.value.accountMode : accountModes[0];
|
|
84
|
+
if (authentication.method === "oauth2" && !authentication.registrationRef) {
|
|
85
|
+
editAuthentication("oauth2", { ...integration.value, settings, scopes, authentication, accountMode });
|
|
86
|
+
} else editIntegration({ settings, scopes, authentication, accountMode });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function editAuthentication(method, current = integration.value) {
|
|
90
|
+
const settings = { ...current.settings };
|
|
91
|
+
for (const field of props.provider.settingsFields || []) {
|
|
92
|
+
if (field.authenticationMethods && !field.authenticationMethods.includes(method)) delete settings[field.name];
|
|
93
|
+
}
|
|
94
|
+
const availableScopes = getProviderScopes(props.provider, settings, "authorization_code", method);
|
|
95
|
+
const scopes = availableScopes.filter(scope => current.scopes.includes(scope.value) || scope.required || (scope.authenticationMethods && scope.recommended)).map(scope => scope.value);
|
|
96
|
+
if (method === "oauth2") {
|
|
97
|
+
let id = props.integrationId;
|
|
98
|
+
let suffix = 2;
|
|
99
|
+
while (Object.hasOwn(props.modelValue.registrations, id)) id = `${props.integrationId}-${suffix++}`;
|
|
100
|
+
const prefix = id.toUpperCase().replaceAll("-", "_");
|
|
101
|
+
const grant = props.provider.oauthGrantTypes?.[0] || "authorization_code";
|
|
102
|
+
const clientAuthentication = getProviderClientAuthenticationMethods(props.provider, grant, settings)[0];
|
|
103
|
+
emit("update:modelValue", {
|
|
104
|
+
...props.modelValue,
|
|
105
|
+
integrations: { ...props.modelValue.integrations, [props.integrationId]: {
|
|
106
|
+
...current, scopes, authentication: { method, registrationRef: id },
|
|
107
|
+
...(current.settings ? { settings } : {})
|
|
108
|
+
} },
|
|
109
|
+
registrations: { ...props.modelValue.registrations, [id]: {
|
|
110
|
+
source: "own", clientId: "",
|
|
111
|
+
...(grant === "authorization_code" ? { callbackUrlRef: `env:${prefix}_CALLBACK_URL` } : { grantType: grant }),
|
|
112
|
+
...(clientAuthentication !== "client_secret_post" ? { tokenEndpointAuthMethod: clientAuthentication } : {}),
|
|
113
|
+
...(clientAuthentication !== "none" ? { clientSecretRef: `env:${prefix}_CLIENT_SECRET` } : {})
|
|
114
|
+
} }
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
editIntegration({ scopes, authentication: { method }, ...(current.settings ? { settings } : {}) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function editSecretReference(value) {
|
|
122
|
+
const authentication = { ...integration.value.authentication, secretRef: value };
|
|
123
|
+
if (!value && props.provider.apiKeySecretOptional) delete authentication.secretRef;
|
|
124
|
+
editIntegration({ authentication });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function editRegistration(field, value) {
|
|
128
|
+
emit("update:modelValue", {
|
|
129
|
+
...props.modelValue,
|
|
130
|
+
registrations: {
|
|
131
|
+
...props.modelValue.registrations,
|
|
132
|
+
[registrationId.value]: { ...registration.value, [field]: value }
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function editGrantType(value) {
|
|
138
|
+
const nextRegistration = { ...registration.value, grantType: value };
|
|
139
|
+
const methods = getProviderClientAuthenticationMethods(props.provider, value, integration.value.settings || {});
|
|
140
|
+
if (!methods.includes(nextRegistration.tokenEndpointAuthMethod || "client_secret_post")) nextRegistration.tokenEndpointAuthMethod = methods[0];
|
|
141
|
+
if (nextRegistration.tokenEndpointAuthMethod === "none") delete nextRegistration.clientSecretRef;
|
|
142
|
+
if (value === "client_credentials") delete nextRegistration.callbackUrlRef;
|
|
143
|
+
const allowed = getProviderScopes(props.provider, integration.value.settings || {}, value, integration.value.authentication.method).map((scope) => scope.value);
|
|
144
|
+
emit("update:modelValue", {
|
|
145
|
+
...props.modelValue,
|
|
146
|
+
registrations: { ...props.modelValue.registrations, [registrationId.value]: nextRegistration },
|
|
147
|
+
integrations: { ...props.modelValue.integrations, [props.integrationId]: {
|
|
148
|
+
...integration.value,
|
|
149
|
+
accountMode: value === "client_credentials" && integration.value.accountMode === "per-user"
|
|
150
|
+
? props.provider.accountModes.find((mode) => mode !== "per-user") : integration.value.accountMode,
|
|
151
|
+
scopes: integration.value.scopes.filter((scope) => allowed.includes(scope))
|
|
152
|
+
} }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<template>
|
|
158
|
+
<v-sheet v-if="integration" class="integration-fields" :aria-label="`${provider.name} configuration`">
|
|
159
|
+
<v-expansion-panels v-model="panels" multiple variant="accordion" :disabled="disabled">
|
|
160
|
+
<v-expansion-panel value="details" title="Details">
|
|
161
|
+
<v-expansion-panel-text>
|
|
162
|
+
<v-text-field
|
|
163
|
+
label="Display name" :model-value="integration.displayName || ''" :disabled="disabled"
|
|
164
|
+
:placeholder="provider.name" :error-messages="fieldErrors[`${integrationPath}.displayName`]"
|
|
165
|
+
@update:model-value="editIntegration({ displayName: $event || undefined })"
|
|
166
|
+
/>
|
|
167
|
+
<v-select
|
|
168
|
+
label="Account used by the application" :items="modes" :model-value="integration.accountMode" :disabled="disabled"
|
|
169
|
+
:error-messages="fieldErrors[`${integrationPath}.accountMode`]"
|
|
170
|
+
@update:model-value="editIntegration({ accountMode: $event })"
|
|
171
|
+
/>
|
|
172
|
+
</v-expansion-panel-text>
|
|
173
|
+
</v-expansion-panel>
|
|
174
|
+
|
|
175
|
+
<v-expansion-panel value="credentials" title="Credentials">
|
|
176
|
+
<v-expansion-panel-text>
|
|
177
|
+
<v-select
|
|
178
|
+
v-if="authenticationOptions.length > 1"
|
|
179
|
+
label="Authentication" :items="authenticationOptions" :model-value="integration.authentication.method" :disabled="disabled"
|
|
180
|
+
:hint="provider.authenticationHint" :persistent-hint="Boolean(provider.authenticationHint)"
|
|
181
|
+
:error-messages="fieldErrors[`${integrationPath}.authentication.method`]"
|
|
182
|
+
@update:model-value="editAuthentication"
|
|
183
|
+
/>
|
|
184
|
+
<template v-for="field in visibleSettings" :key="field.name">
|
|
185
|
+
<v-autocomplete
|
|
186
|
+
v-if="field.searchable"
|
|
187
|
+
:label="field.label" :items="field.items" :disabled="disabled" auto-select-first
|
|
188
|
+
:model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo"
|
|
189
|
+
:hint="typeof field.hint === 'function' ? field.hint(integration.settings) : field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
|
|
190
|
+
@update:model-value="editSetting(field.name, $event)"
|
|
191
|
+
/>
|
|
192
|
+
<v-select
|
|
193
|
+
v-else-if="field.items"
|
|
194
|
+
:label="field.label" :items="field.items" :disabled="disabled"
|
|
195
|
+
:model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo"
|
|
196
|
+
:hint="field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
|
|
197
|
+
@update:model-value="editSetting(field.name, $event)"
|
|
198
|
+
/>
|
|
199
|
+
<v-text-field
|
|
200
|
+
v-else
|
|
201
|
+
:label="field.label" :disabled="disabled" :placeholder="field.placeholder"
|
|
202
|
+
:model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo ?? ''"
|
|
203
|
+
:hint="field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
|
|
204
|
+
@update:model-value="editSetting(field.name, $event)"
|
|
205
|
+
/>
|
|
206
|
+
</template>
|
|
207
|
+
<template v-if="['api-key', 'service-account'].includes(integration.authentication.method)">
|
|
208
|
+
<slot name="credential" :reference="integration.authentication.secretRef" :disabled="disabled">
|
|
209
|
+
<v-text-field
|
|
210
|
+
:label="integration.authentication.method === 'service-account' ? provider.serviceAccountReferenceLabel || 'Service-account credential reference' : provider.apiKeyReferenceLabel || 'API key reference'" :model-value="integration.authentication.secretRef" :disabled="disabled"
|
|
211
|
+
:hint="integration.authentication.method === 'service-account' ? provider.serviceAccountReferenceHint || 'Store the service credential in your environment or secret store and enter its reference here.' : provider.apiKeyReferenceHint || 'Store the API key in your environment or secret store. Enter its reference here, such as env:SERVICE_API_KEY.'"
|
|
212
|
+
persistent-hint :error-messages="fieldErrors[`${integrationPath}.authentication.secretRef`]"
|
|
213
|
+
@update:model-value="editSecretReference"
|
|
214
|
+
/>
|
|
215
|
+
</slot>
|
|
216
|
+
</template>
|
|
217
|
+
<template v-else-if="integration.authentication.method === 'oauth2'">
|
|
218
|
+
<v-select
|
|
219
|
+
label="App registration" :items="registrationOptions" :model-value="registrationId" :disabled="disabled"
|
|
220
|
+
:error-messages="fieldErrors[`${integrationPath}.authentication.registrationRef`]"
|
|
221
|
+
@update:model-value="editIntegration({ authentication: { ...integration.authentication, registrationRef: $event } })"
|
|
222
|
+
/>
|
|
223
|
+
<template v-if="registration?.source === 'own'">
|
|
224
|
+
<v-select
|
|
225
|
+
v-if="grantOptions.length > 1"
|
|
226
|
+
label="OAuth flow" :items="grantOptions" :model-value="grantType" :disabled="disabled"
|
|
227
|
+
:hint="provider.oauthGrantHint" :persistent-hint="Boolean(provider.oauthGrantHint)"
|
|
228
|
+
:error-messages="fieldErrors[`${registrationPath}.grantType`]"
|
|
229
|
+
@update:model-value="editGrantType"
|
|
230
|
+
/>
|
|
231
|
+
<v-text-field
|
|
232
|
+
:label="provider.clientIdLabel || 'Client ID'" :hint="provider.clientIdHint" :persistent-hint="Boolean(provider.clientIdHint)" :model-value="registration.clientId" autocomplete="off" :disabled="disabled"
|
|
233
|
+
:error-messages="fieldErrors[`${registrationPath}.clientId`]"
|
|
234
|
+
@update:model-value="editRegistration('clientId', $event)"
|
|
235
|
+
/>
|
|
236
|
+
<slot v-if="registration.tokenEndpointAuthMethod !== 'none'" name="credential" :reference="registration.clientSecretRef" :disabled="disabled">
|
|
237
|
+
<v-text-field
|
|
238
|
+
label="Client secret reference" :model-value="registration.clientSecretRef" :disabled="disabled"
|
|
239
|
+
hint="Use an environment reference such as env:GOOGLE_CLIENT_SECRET. Keep the value outside this file."
|
|
240
|
+
persistent-hint :error-messages="fieldErrors[`${registrationPath}.clientSecretRef`]"
|
|
241
|
+
@update:model-value="editRegistration('clientSecretRef', $event)"
|
|
242
|
+
/>
|
|
243
|
+
</slot>
|
|
244
|
+
<v-text-field
|
|
245
|
+
v-if="grantType === 'authorization_code'"
|
|
246
|
+
hint="Reference the callback route implemented by your application and registered with the provider." persistent-hint
|
|
247
|
+
class="mt-4" label="Callback URL reference" :model-value="registration.callbackUrlRef" :disabled="disabled"
|
|
248
|
+
:error-messages="fieldErrors[`${registrationPath}.callbackUrlRef`]"
|
|
249
|
+
@update:model-value="editRegistration('callbackUrlRef', $event)"
|
|
250
|
+
/>
|
|
251
|
+
<v-text-field v-if="callbackUrl && grantType === 'authorization_code'" label="Callback URL" :model-value="callbackUrl" readonly />
|
|
252
|
+
</template>
|
|
253
|
+
</template>
|
|
254
|
+
</v-expansion-panel-text>
|
|
255
|
+
</v-expansion-panel>
|
|
256
|
+
|
|
257
|
+
<v-expansion-panel v-if="visibleScopes.length && (provider.scopes.length || provider.permissionsHint || ['oauth2', 'service-account'].includes(integration.authentication.method))" value="permissions" title="Permissions">
|
|
258
|
+
<v-expansion-panel-text>
|
|
259
|
+
<p class="text-body-medium mb-3">{{ provider.permissionsHint || 'Choose what the application needs. The account owner approves these permissions when connecting.' }}</p>
|
|
260
|
+
<v-checkbox
|
|
261
|
+
v-for="permission in visibleScopes" :key="permission.value"
|
|
262
|
+
:label="permission.label" :value="permission.value" :model-value="integration.scopes" :disabled="disabled || permission.required"
|
|
263
|
+
hide-details @update:model-value="editIntegration({ scopes: $event })"
|
|
264
|
+
/>
|
|
265
|
+
<p v-if="fieldErrors[`${integrationPath}.scopes`]" role="alert" class="text-error text-body-small mt-2">
|
|
266
|
+
{{ fieldErrors[`${integrationPath}.scopes`] }}
|
|
267
|
+
</p>
|
|
268
|
+
</v-expansion-panel-text>
|
|
269
|
+
</v-expansion-panel>
|
|
270
|
+
<v-expansion-panel v-if="provider.assistantActions?.length" value="assistant" title="Assistant permissions">
|
|
271
|
+
<v-expansion-panel-text>
|
|
272
|
+
<p class="text-body-medium mb-3">These choices control assistant actions for this integration. They do not grant access to the provider or replace your application's permissions.</p>
|
|
273
|
+
<v-switch
|
|
274
|
+
label="Allow assistant access" :model-value="integration.assistantPolicy?.enabled ?? true" :disabled="disabled"
|
|
275
|
+
:error-messages="fieldErrors[`${integrationPath}.assistantPolicy.enabled`]"
|
|
276
|
+
@update:model-value="editAssistantPolicy({ enabled: $event })"
|
|
277
|
+
/>
|
|
278
|
+
<v-select
|
|
279
|
+
label="Manage all permissions" :items="assistantPermissions" :model-value="integration.assistantPolicy?.defaultPermission || 'ask'"
|
|
280
|
+
:disabled="disabled || integration.assistantPolicy?.enabled === false"
|
|
281
|
+
hint="Changing this sets every assistant action below to the same choice." persistent-hint
|
|
282
|
+
:error-messages="fieldErrors[`${integrationPath}.assistantPolicy.defaultPermission`]"
|
|
283
|
+
@update:model-value="editAssistantPolicy({ defaultPermission: $event, actions: {} })"
|
|
284
|
+
/>
|
|
285
|
+
<v-select
|
|
286
|
+
v-for="action in provider.assistantActions" :key="action.value" :label="action.label" :items="assistantPermissions"
|
|
287
|
+
:model-value="integration.assistantPolicy?.actions?.[action.value] || integration.assistantPolicy?.defaultPermission || 'ask'"
|
|
288
|
+
:disabled="disabled || integration.assistantPolicy?.enabled === false"
|
|
289
|
+
:error-messages="fieldErrors[`${integrationPath}.assistantPolicy.actions.${action.value}`]"
|
|
290
|
+
@update:model-value="editAssistantPolicy({ actions: { ...integration.assistantPolicy?.actions, [action.value]: $event } })"
|
|
291
|
+
/>
|
|
292
|
+
</v-expansion-panel-text>
|
|
293
|
+
</v-expansion-panel>
|
|
294
|
+
</v-expansion-panels>
|
|
295
|
+
<slot name="access" :integration="integration" />
|
|
296
|
+
</v-sheet>
|
|
297
|
+
</template>
|
|
298
|
+
|
|
299
|
+
<style scoped>
|
|
300
|
+
.integration-fields { min-width: 0; }
|
|
301
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as IntegrationConfigurationFields } from "./IntegrationConfigurationFields.vue";
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
for (const width of [390, 820, 1440]) {
|
|
4
|
+
test(`Snowflake setting changes select required scopes without restoring optional choices at ${width}px`, async ({ page }) => {
|
|
5
|
+
await page.setViewportSize({ width, height: 1000 }); await page.goto("/");
|
|
6
|
+
await page.getByRole("button", { name: "Edit Snowflake", exact: true }).click();
|
|
7
|
+
const role = page.getByRole("textbox", { name: "Role", exact: true });
|
|
8
|
+
const permissions = page.getByRole("button", { name: "Permissions", exact: true });
|
|
9
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
10
|
+
await save.click(); const original = JSON.parse(await page.getByTestId("export").textContent());
|
|
11
|
+
await role.fill("VIBE64_READER"); await permissions.click();
|
|
12
|
+
const named = page.getByRole("checkbox", { name: "Use role VIBE64_READER (required)", exact: true });
|
|
13
|
+
await expect(named).toBeChecked(); await expect(named).toBeDisabled();
|
|
14
|
+
const offline = page.getByRole("checkbox", { name: "Keep access between visits", exact: true });
|
|
15
|
+
await offline.uncheck(); await role.fill("Inventory & stock");
|
|
16
|
+
await expect(named).toHaveCount(0); await expect(offline).not.toBeChecked();
|
|
17
|
+
await expect(page.getByRole("checkbox", { name: "Use role Inventory & stock (required)", exact: true })).toBeChecked();
|
|
18
|
+
await save.click(); const namedFile = JSON.parse(await page.getByTestId("export").textContent());
|
|
19
|
+
expect(namedFile.integrations.snowflake.scopes).toEqual(["session:role-encoded:Inventory%20%26%20stock"]);
|
|
20
|
+
expect(namedFile.integrations.snowflake.extensions).toEqual({ keep: "account" });
|
|
21
|
+
expect(namedFile.integrations.calendar).toEqual(original.integrations.calendar);
|
|
22
|
+
await page.getByRole("button", { name: "Leave configuration" }).click(); await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
23
|
+
await expect(role).toHaveValue("Inventory & stock");
|
|
24
|
+
await page.getByLabel("Lock form").check(); await expect(role).toBeDisabled(); await page.getByLabel("Lock form").uncheck();
|
|
25
|
+
await role.fill(""); await save.click(); const defaults = JSON.parse(await page.getByTestId("export").textContent());
|
|
26
|
+
expect(defaults.integrations.snowflake.scopes).toEqual(["refresh_token"]); expect(defaults.integrations.snowflake.settings.role).toBeUndefined();
|
|
27
|
+
if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
|
|
28
|
+
const requiredOffline = page.getByRole("checkbox", { name: "Keep access between visits (required)", exact: true });
|
|
29
|
+
await expect(requiredOffline).toBeChecked(); await expect(requiredOffline).toBeDisabled();
|
|
30
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(namedFile)); await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
31
|
+
await expect(role).toHaveValue("Inventory & stock"); await expect(offline).not.toBeChecked();
|
|
32
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test(`Databricks OAuth flow changes keep file fields, permissions and account identity consistent at ${width}px`, async ({ page }) => {
|
|
36
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
37
|
+
await page.goto("/");
|
|
38
|
+
await page.getByRole("button", { name: "Edit Databricks", exact: true }).click();
|
|
39
|
+
const flow = page.getByRole("combobox", { name: "OAuth flow", exact: true });
|
|
40
|
+
const account = page.getByRole("combobox", { name: "Account used by the application", exact: true });
|
|
41
|
+
const callback = page.getByRole("textbox", { name: "Callback URL reference", exact: true });
|
|
42
|
+
const client = page.getByRole("textbox", { name: "Client ID", exact: true });
|
|
43
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
44
|
+
const permissions = page.getByRole("button", { name: "Permissions", exact: true });
|
|
45
|
+
await expect(flow).toHaveValue("User consent"); await expect(account).toHaveValue("Each app user's own account");
|
|
46
|
+
await expect(callback).toHaveValue("env:DATABRICKS_CALLBACK");
|
|
47
|
+
await save.click(); const initial = JSON.parse(await page.getByTestId("export").textContent());
|
|
48
|
+
await page.getByText("User consent", { exact: true }).click();
|
|
49
|
+
await page.getByRole("option", { name: "Service account", exact: true }).click();
|
|
50
|
+
await expect(callback).toHaveCount(0); await expect(account).toHaveValue("One shared account");
|
|
51
|
+
await page.getByText("One shared account", { exact: true }).click();
|
|
52
|
+
await expect(page.getByRole("option", { name: "Each app user's own account", exact: true })).toHaveCount(0);
|
|
53
|
+
await page.keyboard.press("Escape");
|
|
54
|
+
await client.fill("service-client");
|
|
55
|
+
await permissions.click();
|
|
56
|
+
const jobs = page.getByRole("checkbox", { name: "Jobs API access for the service account", exact: true });
|
|
57
|
+
await jobs.check();
|
|
58
|
+
await expect(page.getByRole("checkbox", { name: "Keep user access between visits", exact: true })).toHaveCount(0);
|
|
59
|
+
await save.click(); const machine = JSON.parse(await page.getByTestId("export").textContent());
|
|
60
|
+
expect(machine.registrations.databricks).toEqual({ source: "own", clientId: "service-client", clientSecretRef: "env:DATABRICKS_SECRET",
|
|
61
|
+
grantType: "client_credentials", tokenEndpointAuthMethod: "client_secret_basic" });
|
|
62
|
+
expect(machine.integrations.databricks.scopes).toEqual(["all-apis", "jobs"]);
|
|
63
|
+
expect(machine.integrations.databricks.accountMode).toBe("shared");
|
|
64
|
+
expect(machine.integrations.databricks.extensions).toEqual({ keep: "workspace" });
|
|
65
|
+
expect(machine.integrations.calendar).toEqual(initial.integrations.calendar);
|
|
66
|
+
expect(machine.registrations.google).toEqual(initial.registrations.google);
|
|
67
|
+
await page.getByLabel("Lock form").check(); await expect(flow).toBeDisabled(); await expect(jobs).toBeDisabled();
|
|
68
|
+
await page.getByLabel("Lock form").uncheck();
|
|
69
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
70
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
71
|
+
await expect(flow).toHaveValue("Service account"); await expect(callback).toHaveCount(0);
|
|
72
|
+
await page.getByText("Service account", { exact: true }).click();
|
|
73
|
+
await page.getByRole("option", { name: "User consent", exact: true }).click();
|
|
74
|
+
await expect(callback).toHaveValue("");
|
|
75
|
+
await save.click();
|
|
76
|
+
await expect(page.locator(".v-input").filter({ has: callback }).getByText("This value is required.", { exact: true })).toBeVisible();
|
|
77
|
+
await callback.fill("env:USER_CALLBACK"); await client.fill("user-client");
|
|
78
|
+
if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
|
|
79
|
+
await expect(jobs).toHaveCount(0);
|
|
80
|
+
await page.getByRole("checkbox", { name: "Keep user access between visits", exact: true }).check();
|
|
81
|
+
await save.click(); const user = JSON.parse(await page.getByTestId("export").textContent());
|
|
82
|
+
expect(user.registrations.databricks.tokenEndpointAuthMethod).toBe("client_secret_post");
|
|
83
|
+
expect(user.integrations.databricks.scopes).toEqual(["all-apis", "offline_access"]);
|
|
84
|
+
const invalid = structuredClone(machine); invalid.integrations.databricks.accountMode = "per-user";
|
|
85
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(invalid));
|
|
86
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
87
|
+
await expect(page.getByText("A service account cannot connect as each app user.", { exact: true })).toBeVisible();
|
|
88
|
+
await expect(flow).toHaveValue("User consent");
|
|
89
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(machine));
|
|
90
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
91
|
+
await expect(flow).toHaveValue("Service account"); await expect(callback).toHaveCount(0);
|
|
92
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test(`Redshift deployment changes share CLI validation and remove inactive fields at ${width}px`, async ({ page }) => {
|
|
96
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
97
|
+
await page.goto("/");
|
|
98
|
+
await page.getByRole("button", { name: "Edit Redshift", exact: true }).click();
|
|
99
|
+
const mode = page.getByRole("combobox", { name: "Deployment type", exact: true });
|
|
100
|
+
const workgroup = page.getByRole("textbox", { name: "Workgroup name", exact: true });
|
|
101
|
+
const cluster = page.getByRole("textbox", { name: "Cluster identifier", exact: true });
|
|
102
|
+
const database = page.getByRole("textbox", { name: "Database", exact: true });
|
|
103
|
+
const user = page.getByRole("textbox", { name: "Database user (optional)", exact: true });
|
|
104
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
105
|
+
await expect(mode).toHaveValue("Serverless");
|
|
106
|
+
await expect(workgroup).toHaveValue("analytics"); await expect(cluster).toHaveCount(0); await expect(user).toHaveCount(0);
|
|
107
|
+
await save.click();
|
|
108
|
+
await expect(page.getByTestId("export")).toContainText('"workgroup": "analytics"');
|
|
109
|
+
const original = JSON.parse(await page.getByTestId("export").textContent());
|
|
110
|
+
await page.getByText("Serverless", { exact: true }).click();
|
|
111
|
+
await page.getByRole("option", { name: "Provisioned cluster", exact: true }).click();
|
|
112
|
+
await expect(workgroup).toHaveCount(0); await expect(cluster).toHaveValue(""); await expect(database).toHaveValue("dev");
|
|
113
|
+
await save.click();
|
|
114
|
+
await expect(page.getByTestId("export")).toHaveText(JSON.stringify(original, null, 2));
|
|
115
|
+
await cluster.fill("analytics-cluster"); await user.fill("report_reader"); await save.click();
|
|
116
|
+
await expect(page.getByTestId("export")).toContainText('"databaseUser": "report_reader"');
|
|
117
|
+
const provisioned = JSON.parse(await page.getByTestId("export").textContent());
|
|
118
|
+
expect(provisioned.integrations.redshift.settings).toEqual({ deploymentType: "provisioned", region: "us-east-1", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID", clusterIdentifier: "analytics-cluster", databaseUser: "report_reader" });
|
|
119
|
+
expect(provisioned.integrations.redshift.extensions).toEqual({ keep: "redshift" });
|
|
120
|
+
expect(provisioned.integrations.calendar).toEqual(original.integrations.calendar);
|
|
121
|
+
await page.getByRole("button", { name: "Leave configuration", exact: true }).click();
|
|
122
|
+
await page.getByRole("button", { name: "Return to configuration", exact: true }).click();
|
|
123
|
+
await expect(mode).toHaveValue("Provisioned cluster"); await expect(cluster).toHaveValue("analytics-cluster");
|
|
124
|
+
await page.getByLabel("Lock form").check(); await expect(mode).toBeDisabled(); await page.getByLabel("Lock form").uncheck();
|
|
125
|
+
await page.getByText("Provisioned cluster", { exact: true }).click();
|
|
126
|
+
await page.getByRole("option", { name: "Serverless", exact: true }).click();
|
|
127
|
+
await expect(cluster).toHaveCount(0); await expect(user).toHaveCount(0); await expect(workgroup).toHaveValue("");
|
|
128
|
+
await workgroup.fill("restored-group"); await expect(workgroup).toHaveValue("restored-group"); await save.click();
|
|
129
|
+
await expect(page.getByTestId("export")).toContainText('"workgroup": "restored-group"');
|
|
130
|
+
const restored = JSON.parse(await page.getByTestId("export").textContent());
|
|
131
|
+
expect(restored.integrations.redshift.settings).toEqual({ deploymentType: "serverless", region: "us-east-1", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID", workgroup: "restored-group" });
|
|
132
|
+
const mixed = structuredClone(restored); mixed.integrations.redshift.settings.clusterIdentifier = "mixed";
|
|
133
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(mixed));
|
|
134
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
135
|
+
await expect(workgroup).toHaveValue("restored-group"); await expect(cluster).toHaveCount(0);
|
|
136
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
137
|
+
await expect(page.locator(".v-progress-circular")).toHaveCount(0);
|
|
138
|
+
});
|
|
139
|
+
test(`Slack actor changes retain applicable permissions and reject invalid imports at ${width}px`, async ({ page }) => {
|
|
140
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
141
|
+
await page.goto("/");
|
|
142
|
+
await page.getByRole("button", { name: "Edit Slack", exact: true }).click();
|
|
143
|
+
const actor = page.getByRole("combobox", { name: "Act in Slack as", exact: true });
|
|
144
|
+
const permissions = page.getByRole("button", { name: "Permissions", exact: true });
|
|
145
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
146
|
+
const read = page.getByRole("checkbox", { name: "View basic information about public channels in a workspace", exact: true });
|
|
147
|
+
const profile = page.getByRole("checkbox", { name: "Edit a user's profile information and status", exact: true });
|
|
148
|
+
const join = page.getByRole("checkbox", { name: "Join public channels in a workspace", exact: true });
|
|
149
|
+
if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
|
|
150
|
+
await expect(page.locator(".v-expansion-panel-text").getByRole("checkbox")).toHaveCount(52);
|
|
151
|
+
await expect(read).toBeChecked();
|
|
152
|
+
await profile.check();
|
|
153
|
+
await page.getByText("Connected user", { exact: true }).click();
|
|
154
|
+
await page.getByRole("option", { name: "Installed bot", exact: true }).click();
|
|
155
|
+
await expect(page.locator(".v-expansion-panel-text").getByRole("checkbox")).toHaveCount(49);
|
|
156
|
+
await expect(profile).toHaveCount(0);
|
|
157
|
+
await expect(read).toBeChecked();
|
|
158
|
+
await join.check();
|
|
159
|
+
await save.click();
|
|
160
|
+
const bot = JSON.parse(await page.getByTestId("export").textContent());
|
|
161
|
+
expect(bot.integrations.slack.scopes).toEqual(["channels:read", "channels:join"]);
|
|
162
|
+
expect(bot.integrations.slack.settings).toEqual({ actor: "bot" });
|
|
163
|
+
expect(bot.integrations.slack.extensions).toEqual({ keep: "workspace" });
|
|
164
|
+
await page.getByLabel("Lock form").check();
|
|
165
|
+
await expect(actor).toBeDisabled();
|
|
166
|
+
await expect(join).toBeDisabled();
|
|
167
|
+
await page.getByLabel("Lock form").uncheck();
|
|
168
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
169
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
170
|
+
await expect(actor).toHaveValue("Installed bot");
|
|
171
|
+
await page.getByText("Installed bot", { exact: true }).click();
|
|
172
|
+
await page.getByRole("option", { name: "Connected user", exact: true }).click();
|
|
173
|
+
await save.click();
|
|
174
|
+
const user = JSON.parse(await page.getByTestId("export").textContent());
|
|
175
|
+
expect(user.integrations.slack.scopes).toEqual(["channels:read"]);
|
|
176
|
+
expect(user.integrations.calendar).toEqual(bot.integrations.calendar);
|
|
177
|
+
const invalid = structuredClone(bot);
|
|
178
|
+
invalid.integrations.slack.scopes.push("users.profile:write");
|
|
179
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(invalid));
|
|
180
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
181
|
+
if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
|
|
182
|
+
await expect(page.getByText("A selected permission is not supported by this provider configuration.", { exact: true })).toBeVisible();
|
|
183
|
+
await expect(actor).toHaveValue("Connected user");
|
|
184
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(bot));
|
|
185
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
186
|
+
await expect(actor).toHaveValue("Installed bot");
|
|
187
|
+
if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
|
|
188
|
+
await expect(join).toBeChecked();
|
|
189
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test(`ClickHouse authentication modes preserve portable configuration at ${width}px`, async ({ page }) => {
|
|
193
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
194
|
+
await page.goto("/");
|
|
195
|
+
await page.getByRole("button", { name: "Edit ClickHouse", exact: true }).click();
|
|
196
|
+
const password = page.getByRole("textbox", { name: "Password reference (optional)", exact: true });
|
|
197
|
+
const username = page.getByRole("textbox", { name: "Username (optional)", exact: true });
|
|
198
|
+
const endpoint = page.getByRole("textbox", { name: "HTTP Interface URL", exact: true });
|
|
199
|
+
const authentication = page.getByRole("combobox", { name: "Authentication", exact: true });
|
|
200
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
201
|
+
await expect(username).toHaveValue("reader");
|
|
202
|
+
await password.fill("raw-password");
|
|
203
|
+
await save.click();
|
|
204
|
+
await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
|
|
205
|
+
await password.fill("");
|
|
206
|
+
await save.click();
|
|
207
|
+
const value = JSON.parse(await page.getByTestId("export").textContent());
|
|
208
|
+
expect(value.integrations.warehouse.authentication).toEqual({ method: "api-key" });
|
|
209
|
+
await page.getByText("Username and password", { exact: true }).click();
|
|
210
|
+
await page.getByRole("option", { name: "No credentials", exact: true }).click();
|
|
211
|
+
await expect(password).toHaveCount(0);
|
|
212
|
+
await expect(username).toHaveCount(0);
|
|
213
|
+
await save.click();
|
|
214
|
+
const anonymous = JSON.parse(await page.getByTestId("export").textContent());
|
|
215
|
+
expect(anonymous.integrations.warehouse.authentication).toEqual({ method: "none" });
|
|
216
|
+
expect(anonymous.integrations.warehouse.settings).toEqual({ httpUrl: "https://warehouse.example:8443/query/" });
|
|
217
|
+
expect(anonymous.integrations.warehouse.extensions).toEqual({ keep: "database" });
|
|
218
|
+
expect(anonymous.integrations.calendar).toEqual(value.integrations.calendar);
|
|
219
|
+
await page.getByLabel("Lock form").check();
|
|
220
|
+
await expect(authentication).toBeDisabled();
|
|
221
|
+
await expect(endpoint).toBeDisabled();
|
|
222
|
+
await page.getByLabel("Lock form").uncheck();
|
|
223
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
224
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
225
|
+
await expect(page.getByText("No credentials", { exact: true })).toBeVisible();
|
|
226
|
+
await expect(endpoint).toHaveValue("https://warehouse.example:8443/query/");
|
|
227
|
+
await page.getByText("No credentials", { exact: true }).click();
|
|
228
|
+
await page.getByRole("option", { name: "Username and password", exact: true }).click();
|
|
229
|
+
await expect(username).toHaveValue("");
|
|
230
|
+
await expect(password).toHaveValue("");
|
|
231
|
+
value.integrations.warehouse.authentication.secretRef = "env:CLI_PASSWORD";
|
|
232
|
+
value.integrations.warehouse.settings.username = "cli-reader";
|
|
233
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(value));
|
|
234
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
235
|
+
await expect(username).toHaveValue("cli-reader");
|
|
236
|
+
await expect(password).toHaveValue("env:CLI_PASSWORD");
|
|
237
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test(`configuration remains portable and usable at ${width}px`, async ({ page }) => {
|
|
241
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
242
|
+
await page.goto("/");
|
|
243
|
+
await page.getByLabel("Display name").fill("My calendar");
|
|
244
|
+
await page.getByLabel("Client ID", { exact: true }).fill("updated-client");
|
|
245
|
+
await page.getByRole("button", { name: "Permissions", exact: true }).click();
|
|
246
|
+
await page.getByLabel("Read events", { exact: true }).uncheck();
|
|
247
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
248
|
+
await expect(page.getByTestId("export")).toContainText("updated-client");
|
|
249
|
+
const value = JSON.parse(await page.getByTestId("export").textContent());
|
|
250
|
+
expect(value.integrations.calendar.displayName).toBe("My calendar");
|
|
251
|
+
expect(value.integrations.calendar.extensions.businessRule).toBe("keep-me");
|
|
252
|
+
expect(value.integrations.calendar.scopes).toHaveLength(1);
|
|
253
|
+
|
|
254
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
255
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
256
|
+
await expect(page.getByLabel("Display name")).toHaveValue("My calendar");
|
|
257
|
+
|
|
258
|
+
const obsolete = structuredClone(value);
|
|
259
|
+
obsolete.registrations.google.source = "managed";
|
|
260
|
+
obsolete.registrations.google.clientId = "must-not-be-imported";
|
|
261
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(obsolete));
|
|
262
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
263
|
+
await expect(page.getByLabel("Client ID", { exact: true })).toHaveValue("updated-client");
|
|
264
|
+
await expect(page.getByLabel("Managed registration", { exact: true })).toHaveCount(0);
|
|
265
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
266
|
+
expect(JSON.parse(await page.getByTestId("export").textContent()).registrations.google.source).toBe("own");
|
|
267
|
+
|
|
268
|
+
value.registrations.google.clientId = "written-from-cli";
|
|
269
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(value));
|
|
270
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
271
|
+
await expect(page.getByLabel("Client ID", { exact: true })).toHaveValue("written-from-cli");
|
|
272
|
+
|
|
273
|
+
await page.getByLabel("Display name").fill("");
|
|
274
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
275
|
+
expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.calendar).not.toHaveProperty("displayName");
|
|
276
|
+
await page.getByLabel("Lock form").check();
|
|
277
|
+
await expect(page.getByLabel("Client ID", { exact: true })).toBeDisabled();
|
|
278
|
+
await page.getByLabel("Lock form").uncheck();
|
|
279
|
+
|
|
280
|
+
await page.getByLabel("Client secret reference").fill("an-actual-secret-is-not-a-reference");
|
|
281
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
282
|
+
await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
|
|
283
|
+
await page.getByLabel("Client secret reference").fill("env:GOOGLE_SECRET");
|
|
284
|
+
await page.getByLabel("Callback URL reference").fill("https://callback.example.test/oauth");
|
|
285
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
286
|
+
await expect(page.locator(".v-input").filter({ has: page.getByLabel("Callback URL reference") }).getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
|
|
287
|
+
await page.getByLabel("Callback URL reference").fill("env:CALLBACK");
|
|
288
|
+
await page.getByRole("button", { name: "Edit Mailgun", exact: true }).click();
|
|
289
|
+
await expect(page.getByText("United States (api.mailgun.net)", { exact: true })).toBeVisible();
|
|
290
|
+
const region = page.getByRole("combobox", { name: "API region", exact: true });
|
|
291
|
+
await page.getByText("United States (api.mailgun.net)", { exact: true }).click();
|
|
292
|
+
await page.getByRole("option", { name: "European Union (api.eu.mailgun.net)", exact: true }).click();
|
|
293
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
294
|
+
const regional = JSON.parse(await page.getByTestId("export").textContent());
|
|
295
|
+
expect(regional.integrations.mail.settings).toEqual({ region: "eu" });
|
|
296
|
+
expect(regional.integrations.mail.extensions).toEqual({ label: "preserve" });
|
|
297
|
+
await page.getByLabel("Lock form").check();
|
|
298
|
+
await expect(region).toBeDisabled();
|
|
299
|
+
await page.getByLabel("Lock form").uncheck();
|
|
300
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
301
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
302
|
+
await expect(page.getByText("European Union (api.eu.mailgun.net)", { exact: true })).toBeVisible();
|
|
303
|
+
await page.getByRole("button", { name: "Edit Algolia", exact: true }).click();
|
|
304
|
+
const applicationId = page.getByRole("textbox", { name: "Application ID", exact: true });
|
|
305
|
+
const publicKey = page.getByRole("textbox", { name: "Public API key reference (optional)", exact: true });
|
|
306
|
+
await expect(applicationId).toHaveValue("ORIGINALAPP");
|
|
307
|
+
await applicationId.fill("app.invalid");
|
|
308
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
309
|
+
await expect(page.getByText("Enter the application ID using letters and digits.", { exact: true })).toBeVisible();
|
|
310
|
+
await applicationId.fill("");
|
|
311
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
312
|
+
await expect(page.locator(".v-input--error")).toHaveCount(1);
|
|
313
|
+
await applicationId.fill("UPDATEDAPP");
|
|
314
|
+
await publicKey.fill("raw-search-key");
|
|
315
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
316
|
+
await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
|
|
317
|
+
await publicKey.fill("env:ALGOLIA_SEARCH_KEY");
|
|
318
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
319
|
+
const search = JSON.parse(await page.getByTestId("export").textContent());
|
|
320
|
+
expect(search.integrations.search.settings).toEqual({ applicationId: "UPDATEDAPP", publicApiKeyRef: "env:ALGOLIA_SEARCH_KEY" });
|
|
321
|
+
expect(search.integrations.search.authentication.secretRef).toBe("env:ALGOLIA_BACKEND_KEY");
|
|
322
|
+
expect(search.integrations.search.extensions).toEqual({ keep: true });
|
|
323
|
+
await page.getByLabel("Lock form").check();
|
|
324
|
+
await expect(applicationId).toBeDisabled();
|
|
325
|
+
await expect(publicKey).toBeDisabled();
|
|
326
|
+
await page.getByLabel("Lock form").uncheck();
|
|
327
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
328
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
329
|
+
await expect(publicKey).toHaveValue("env:ALGOLIA_SEARCH_KEY");
|
|
330
|
+
await publicKey.fill("");
|
|
331
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
332
|
+
expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.search.settings).toEqual({ applicationId: "UPDATEDAPP" });
|
|
333
|
+
search.integrations.search.settings.applicationId = "CLIAPP";
|
|
334
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(search));
|
|
335
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
336
|
+
await expect(applicationId).toHaveValue("CLIAPP");
|
|
337
|
+
await expect(publicKey).toHaveValue("env:ALGOLIA_SEARCH_KEY");
|
|
338
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
339
|
+
const smallButtons = await page.getByRole("button").evaluateAll((buttons) => buttons.filter((button) => {
|
|
340
|
+
const box = button.getBoundingClientRect();
|
|
341
|
+
return box.width > 0 && box.height > 0 && (box.width < 48 || box.height < 48);
|
|
342
|
+
}).map((button) => button.textContent));
|
|
343
|
+
expect(smallButtons).toEqual([]);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
test("Contentful delivery fields preserve UI and CLI configuration", async ({ page }) => {
|
|
349
|
+
await page.setViewportSize({ width: 390, height: 1000 });
|
|
350
|
+
await page.goto("/");
|
|
351
|
+
await page.getByRole("button", { name: "Edit Contentful", exact: true }).click();
|
|
352
|
+
const space = page.getByRole("textbox", { name: "Space ID", exact: true });
|
|
353
|
+
const environment = page.getByRole("textbox", { name: "Environment ID", exact: true });
|
|
354
|
+
const token = page.getByRole("textbox", { name: "Content Delivery API token reference", exact: true });
|
|
355
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
356
|
+
await expect(space).toHaveValue("original-space");
|
|
357
|
+
await expect(environment).toHaveValue("master");
|
|
358
|
+
await space.fill("");
|
|
359
|
+
await save.click();
|
|
360
|
+
await expect(page.locator(".v-input--error")).toHaveCount(1);
|
|
361
|
+
await space.fill("published-space");
|
|
362
|
+
await environment.fill("release-2");
|
|
363
|
+
await token.fill("raw-token");
|
|
364
|
+
await save.click();
|
|
365
|
+
await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
|
|
366
|
+
await token.fill("env:CONTENTFUL_ROTATED_TOKEN");
|
|
367
|
+
await page.getByText("United States", { exact: true }).click();
|
|
368
|
+
await page.getByRole("option", { name: "Europe", exact: true }).click();
|
|
369
|
+
await save.click();
|
|
370
|
+
const exported = JSON.parse(await page.getByTestId("export").textContent());
|
|
371
|
+
expect(exported.integrations.content.settings).toEqual({ spaceId: "published-space", environmentId: "release-2", region: "eu" });
|
|
372
|
+
expect(exported.integrations.content.authentication.secretRef).toBe("env:CONTENTFUL_ROTATED_TOKEN");
|
|
373
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
374
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
375
|
+
await expect(space).toHaveValue("published-space");
|
|
376
|
+
await expect(environment).toHaveValue("release-2");
|
|
377
|
+
await expect(token).toHaveValue("env:CONTENTFUL_ROTATED_TOKEN");
|
|
378
|
+
exported.integrations.content.settings.spaceId = "cli-space";
|
|
379
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
|
|
380
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
381
|
+
await expect(space).toHaveValue("cli-space");
|
|
382
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
test("Analytics public setting round-trips without credentials", async ({ page }) => {
|
|
387
|
+
await page.setViewportSize({ width: 390, height: 1000 });
|
|
388
|
+
await page.goto("/");
|
|
389
|
+
await page.getByRole("button", { name: "Edit Analytics", exact: true }).click();
|
|
390
|
+
const measurement = page.getByRole("textbox", { name: "Measurement ID", exact: true });
|
|
391
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
392
|
+
await expect(measurement).toHaveValue("G-ORIGINAL1");
|
|
393
|
+
await expect(page.getByText("Public: this value will be visible in your published application. Copy it from your GA4 web stream.", { exact: true })).toBeVisible();
|
|
394
|
+
await expect(page.getByRole("textbox", { name: /secret|callback|client id|API key/i })).toHaveCount(0);
|
|
395
|
+
await measurement.fill("");
|
|
396
|
+
await save.click();
|
|
397
|
+
await expect(page.locator(".v-input--error")).toHaveCount(1);
|
|
398
|
+
await measurement.fill("UA-123-1");
|
|
399
|
+
await save.click();
|
|
400
|
+
await expect(page.locator(".v-input--error")).toHaveCount(1);
|
|
401
|
+
await measurement.fill("G-WEBSITE123");
|
|
402
|
+
await save.click();
|
|
403
|
+
const exported = JSON.parse(await page.getByTestId("export").textContent());
|
|
404
|
+
expect(exported.integrations.analytics).toMatchObject({
|
|
405
|
+
provider: "google-analytics", accountMode: "shared", scopes: [],
|
|
406
|
+
authentication: { method: "none" }, settings: { measurementId: "G-WEBSITE123" }
|
|
407
|
+
});
|
|
408
|
+
expect(exported.integrations.analytics.authentication).toEqual({ method: "none" });
|
|
409
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
410
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
411
|
+
await expect(measurement).toHaveValue("G-WEBSITE123");
|
|
412
|
+
exported.integrations.analytics.settings.measurementId = "G-CLI123";
|
|
413
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
|
|
414
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
415
|
+
await expect(measurement).toHaveValue("G-CLI123");
|
|
416
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
test("Drive captured permissions retain required file access and optional choices", async ({ page }) => {
|
|
421
|
+
await page.goto("/");
|
|
422
|
+
await page.getByRole("button", { name: "Edit Drive", exact: true }).click();
|
|
423
|
+
const required = page.getByRole("checkbox", { name: "Manage files selected for this application", exact: true });
|
|
424
|
+
if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
|
|
425
|
+
await expect(required).toBeChecked();
|
|
426
|
+
await expect(required).toBeDisabled();
|
|
427
|
+
for (const name of ["Read and download files", "Manage application data", "Manage this application’s Drive folder data"]) {
|
|
428
|
+
const checkbox = page.getByRole("checkbox", { name, exact: true });
|
|
429
|
+
await expect(checkbox).toBeChecked();
|
|
430
|
+
await checkbox.uncheck();
|
|
431
|
+
}
|
|
432
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
433
|
+
expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.drive.scopes)
|
|
434
|
+
.toEqual(["https://www.googleapis.com/auth/drive.file"]);
|
|
435
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
436
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
437
|
+
if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
|
|
438
|
+
await expect(required).toBeChecked();
|
|
439
|
+
await expect(page.getByRole("checkbox", { name: "Read and download files", exact: true })).not.toBeChecked();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("Gmail captured permissions allow optional sending permissions to be removed", async ({ page }) => {
|
|
443
|
+
await page.goto("/");
|
|
444
|
+
await page.getByRole("button", { name: "Edit Gmail", exact: true }).click();
|
|
445
|
+
const required = page.getByRole("checkbox", { name: "Read messages", exact: true });
|
|
446
|
+
if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
|
|
447
|
+
await expect(required).toBeChecked();
|
|
448
|
+
await expect(required).toBeDisabled();
|
|
449
|
+
for (const name of ["Send messages", "Manage drafts and send messages", "Read and modify messages"]) {
|
|
450
|
+
const checkbox = page.getByRole("checkbox", { name, exact: true });
|
|
451
|
+
await expect(checkbox).toBeChecked();
|
|
452
|
+
await checkbox.uncheck();
|
|
453
|
+
}
|
|
454
|
+
await page.getByRole("button", { name: "Export configuration", exact: true }).click();
|
|
455
|
+
expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.gmail.scopes)
|
|
456
|
+
.toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
|
|
457
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
458
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
459
|
+
if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
|
|
460
|
+
await expect(required).toBeChecked();
|
|
461
|
+
await expect(page.getByRole("checkbox", { name: "Send messages", exact: true })).not.toBeChecked();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
test("BigQuery query project validates and round-trips through CLI configuration", async ({ page }) => {
|
|
466
|
+
await page.setViewportSize({ width: 390, height: 1000 });
|
|
467
|
+
await page.goto("/");
|
|
468
|
+
await page.getByRole("button", { name: "Edit BigQuery", exact: true }).click();
|
|
469
|
+
const project = page.getByRole("textbox", { name: "Google Cloud project ID", exact: true });
|
|
470
|
+
const save = page.getByRole("button", { name: "Export configuration", exact: true });
|
|
471
|
+
await expect(project).toHaveValue("query-project");
|
|
472
|
+
for (const value of ["", "123456789", "https://example.com"]) {
|
|
473
|
+
await project.fill(value);
|
|
474
|
+
await save.click();
|
|
475
|
+
await expect(page.locator(".v-input--error")).toHaveCount(1);
|
|
476
|
+
}
|
|
477
|
+
await project.fill("billing-project");
|
|
478
|
+
await save.click();
|
|
479
|
+
const exported = JSON.parse(await page.getByTestId("export").textContent());
|
|
480
|
+
expect(exported.integrations.bigquery).toMatchObject({
|
|
481
|
+
settings: { projectId: "billing-project" },
|
|
482
|
+
authentication: { method: "oauth2", registrationRef: "google" },
|
|
483
|
+
scopes: ["https://www.googleapis.com/auth/bigquery"]
|
|
484
|
+
});
|
|
485
|
+
await page.getByRole("button", { name: "Leave configuration" }).click();
|
|
486
|
+
await page.getByRole("button", { name: "Return to configuration" }).click();
|
|
487
|
+
await expect(project).toHaveValue("billing-project");
|
|
488
|
+
exported.integrations.bigquery.settings.projectId = "cli-project";
|
|
489
|
+
await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
|
|
490
|
+
await page.getByRole("button", { name: "Import configuration", exact: true }).click();
|
|
491
|
+
await expect(project).toHaveValue("cli-project");
|
|
492
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
493
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { googleAnalyticsDefinition, googleDriveDefinition, gmailDefinition, bigqueryDefinition } from "../../../connectors-catalog/src/shared/google.js";
|
|
4
|
+
import { IntegrationConfigurationFields } from "../../src/client/index.js";
|
|
5
|
+
import { googleCalendarDefinition } from "../../../connector-google-calendar/src/shared/definition.js";
|
|
6
|
+
import { mailgunDefinition, contentfulDefinition } from "../../../connectors-catalog/src/shared/tokens.js";
|
|
7
|
+
import { algoliaDefinition } from "../../../connectors-catalog/src/shared/algolia.js";
|
|
8
|
+
import { clickhouseDefinition } from "../../../connectors-catalog/src/shared/clickhouse.js";
|
|
9
|
+
import { slackDefinition } from "../../../connectors-catalog/src/shared/slack.js";
|
|
10
|
+
import { amazonRedshiftDefinition } from "../../../connectors-catalog/src/shared/amazon-redshift.js";
|
|
11
|
+
import { snowflakeDefinition } from "../../../connectors-catalog/src/shared/snowflake.js";
|
|
12
|
+
import { databricksDefinition } from "../../../connectors-catalog/src/shared/databricks.js";
|
|
13
|
+
import { parseIntegrationConfiguration, validateIntegrationConfiguration } from "../../../connectors-core/src/shared/configuration.js";
|
|
14
|
+
|
|
15
|
+
const configuration = ref({
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
integrations: { bigquery: {
|
|
18
|
+
provider: "bigquery", accountMode: "shared",
|
|
19
|
+
scopes: bigqueryDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
|
|
20
|
+
authentication: { method: "oauth2", registrationRef: "google" }, settings: { projectId: "query-project" }
|
|
21
|
+
}, gmail: {
|
|
22
|
+
provider: "gmail", accountMode: "shared",
|
|
23
|
+
scopes: gmailDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
|
|
24
|
+
authentication: { method: "oauth2", registrationRef: "google" }
|
|
25
|
+
}, drive: {
|
|
26
|
+
provider: "google-drive", accountMode: "shared",
|
|
27
|
+
scopes: googleDriveDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
|
|
28
|
+
authentication: { method: "oauth2", registrationRef: "google" }
|
|
29
|
+
}, analytics: {
|
|
30
|
+
provider: "google-analytics", accountMode: "shared", scopes: [],
|
|
31
|
+
authentication: { method: "none" }, settings: { measurementId: "G-ORIGINAL1" }
|
|
32
|
+
}, content: {
|
|
33
|
+
provider: "contentful", accountMode: "shared", scopes: [], settings: { spaceId: "original-space" },
|
|
34
|
+
authentication: { method: "api-key", secretRef: "env:CONTENTFUL_DELIVERY_TOKEN" }
|
|
35
|
+
}, calendar: {
|
|
36
|
+
provider: "google-calendar", displayName: "Team calendar", accountMode: "per-user",
|
|
37
|
+
scopes: googleCalendarDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
|
|
38
|
+
authentication: { method: "oauth2", registrationRef: "google" },
|
|
39
|
+
extensions: { businessRule: "keep-me" }
|
|
40
|
+
}, mail: {
|
|
41
|
+
provider: "mailgun", accountMode: "shared", scopes: [],
|
|
42
|
+
authentication: { method: "api-key", secretRef: "env:MAILGUN_KEY" }, extensions: { label: "preserve" }
|
|
43
|
+
}, search: {
|
|
44
|
+
provider: "algolia", accountMode: "shared", scopes: [],
|
|
45
|
+
authentication: { method: "api-key", secretRef: "env:ALGOLIA_BACKEND_KEY" },
|
|
46
|
+
settings: { applicationId: "ORIGINALAPP" }, extensions: { keep: true }
|
|
47
|
+
}, warehouse: {
|
|
48
|
+
provider: "clickhouse", accountMode: "shared", scopes: [],
|
|
49
|
+
authentication: { method: "api-key", secretRef: "env:CLICKHOUSE_PASSWORD" },
|
|
50
|
+
settings: { httpUrl: "https://warehouse.example:8443/query/", username: "reader" }, extensions: { keep: "database" }
|
|
51
|
+
}, slack: {
|
|
52
|
+
provider: "slack", accountMode: "per-user", scopes: ["channels:read"], settings: { actor: "user" },
|
|
53
|
+
authentication: { method: "oauth2", registrationRef: "slack" }, extensions: { keep: "workspace" }
|
|
54
|
+
}, redshift: {
|
|
55
|
+
provider: "amazon-redshift", accountMode: "shared", scopes: [],
|
|
56
|
+
authentication: { method: "api-key", secretRef: "env:AWS_SECRET_ACCESS_KEY" },
|
|
57
|
+
settings: { workgroup: "analytics", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID" }, extensions: { keep: "redshift" }
|
|
58
|
+
}, snowflake: {
|
|
59
|
+
provider: "snowflake", accountMode: "per-user", scopes: ["refresh_token"],
|
|
60
|
+
authentication: { method: "oauth2", registrationRef: "snowflake" },
|
|
61
|
+
settings: { accountUrl: "https://myorg-myaccount.snowflakecomputing.com" }, extensions: { keep: "account" }
|
|
62
|
+
}, databricks: {
|
|
63
|
+
provider: "databricks", accountMode: "per-user", scopes: ["all-apis", "offline_access"],
|
|
64
|
+
authentication: { method: "oauth2", registrationRef: "databricks" },
|
|
65
|
+
settings: { workspaceUrl: "https://dbc-abc123.cloud.databricks.com" }, extensions: { keep: "workspace" }
|
|
66
|
+
} },
|
|
67
|
+
registrations: {
|
|
68
|
+
snowflake: { source: "own", clientId: "snowflake-client", clientSecretRef: "env:SNOWFLAKE_SECRET", callbackUrlRef: "env:SNOWFLAKE_CALLBACK" },
|
|
69
|
+
google: { source: "own", clientId: "original-client", clientSecretRef: "env:GOOGLE_SECRET", callbackUrlRef: "env:CALLBACK" },
|
|
70
|
+
slack: { source: "own", clientId: "slack-client", clientSecretRef: "env:SLACK_SECRET", callbackUrlRef: "env:SLACK_CALLBACK" },
|
|
71
|
+
databricks: { source: "own", clientId: "user-client", clientSecretRef: "env:DATABRICKS_SECRET", callbackUrlRef: "env:DATABRICKS_CALLBACK" }
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const visible = ref(true);
|
|
75
|
+
const errors = ref({});
|
|
76
|
+
const exported = ref("");
|
|
77
|
+
const source = ref("");
|
|
78
|
+
const locked = ref(false);
|
|
79
|
+
const activeId = ref("calendar");
|
|
80
|
+
const providers = [bigqueryDefinition, gmailDefinition, googleDriveDefinition, googleAnalyticsDefinition, contentfulDefinition, snowflakeDefinition, googleCalendarDefinition, mailgunDefinition, algoliaDefinition, clickhouseDefinition, slackDefinition, amazonRedshiftDefinition, databricksDefinition];
|
|
81
|
+
const provider = computed(() => providers.find((entry) => entry.id === configuration.value.integrations[activeId.value].provider));
|
|
82
|
+
function save() {
|
|
83
|
+
try {
|
|
84
|
+
const validated = validateIntegrationConfiguration(configuration.value, { providers });
|
|
85
|
+
errors.value = {};
|
|
86
|
+
exported.value = JSON.stringify(validated, null, 2);
|
|
87
|
+
} catch (error) { errors.value = error.fieldErrors; }
|
|
88
|
+
}
|
|
89
|
+
function importSource() {
|
|
90
|
+
try {
|
|
91
|
+
configuration.value = parseIntegrationConfiguration(source.value, { providers });
|
|
92
|
+
errors.value = {};
|
|
93
|
+
} catch (error) { errors.value = error.fieldErrors; }
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
98
|
+
<v-app>
|
|
99
|
+
<v-main>
|
|
100
|
+
<v-container style="max-width: 900px">
|
|
101
|
+
<v-btn class="mb-4" min-height="48" @click="visible = !visible">{{ visible ? 'Leave configuration' : 'Return to configuration' }}</v-btn>
|
|
102
|
+
<v-switch v-model="locked" label="Lock form" />
|
|
103
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'content'">Edit Contentful</v-btn>
|
|
104
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'analytics'">Edit Analytics</v-btn>
|
|
105
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'drive'">Edit Drive</v-btn>
|
|
106
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'gmail'">Edit Gmail</v-btn>
|
|
107
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'bigquery'">Edit BigQuery</v-btn>
|
|
108
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'mail'">Edit Mailgun</v-btn>
|
|
109
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'search'">Edit Algolia</v-btn>
|
|
110
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'warehouse'">Edit ClickHouse</v-btn>
|
|
111
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'slack'">Edit Slack</v-btn>
|
|
112
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'redshift'">Edit Redshift</v-btn>
|
|
113
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'databricks'">Edit Databricks</v-btn>
|
|
114
|
+
<v-btn class="mb-4" min-height="48" @click="activeId = 'snowflake'">Edit Snowflake</v-btn>
|
|
115
|
+
<IntegrationConfigurationFields
|
|
116
|
+
v-if="visible" v-model="configuration" :integration-id="activeId"
|
|
117
|
+
:provider="provider" :field-errors="errors" :disabled="locked"
|
|
118
|
+
/>
|
|
119
|
+
<v-btn class="my-4" min-height="48" color="primary" @click="save">Export configuration</v-btn>
|
|
120
|
+
<v-textarea v-model="source" label="Import configuration JSON" />
|
|
121
|
+
<v-btn min-height="48" @click="importSource">Import configuration</v-btn>
|
|
122
|
+
<pre data-testid="export" class="mt-4" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ exported }}</pre>
|
|
123
|
+
</v-container>
|
|
124
|
+
</v-main>
|
|
125
|
+
</v-app>
|
|
126
|
+
</template>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import { createVuetify } from "vuetify";
|
|
3
|
+
import * as components from "vuetify/components";
|
|
4
|
+
import * as directives from "vuetify/directives";
|
|
5
|
+
import "vuetify/styles";
|
|
6
|
+
import App from "./App.vue";
|
|
7
|
+
|
|
8
|
+
createApp(App).use(createVuetify({ components, directives })).mount("#app");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const managedUrl = process.env.PLAYWRIGHT_BASE_URL;
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
testDir: ".",
|
|
6
|
+
testMatch: "configuration.spec.js",
|
|
7
|
+
workers: 1,
|
|
8
|
+
use: {
|
|
9
|
+
baseURL: managedUrl || "http://127.0.0.1:4187",
|
|
10
|
+
storageState: process.env.VIBE64_PLAYWRIGHT_STORAGE_STATE || undefined
|
|
11
|
+
},
|
|
12
|
+
webServer: managedUrl ? undefined : {
|
|
13
|
+
command: "npm exec --no -- vite --config packages/connectors-web/test/vite.config.js",
|
|
14
|
+
cwd: new URL("../../../", import.meta.url).pathname,
|
|
15
|
+
url: "http://127.0.0.1:4187",
|
|
16
|
+
reuseExistingServer: false
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL("./fixture", import.meta.url)),
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
server: { host: "127.0.0.1", port: 4187, strictPort: true }
|
|
9
|
+
});
|