Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
270 lines
13 KiB
Plaintext
270 lines
13 KiB
Plaintext
---
|
||
title: 连接
|
||
description: 通过 OAuth 让你的应用代表用户在第三方服务中执行操作。
|
||
icon: plug
|
||
---
|
||
|
||
连接是用户为外部服务(Linear、GitHub、Slack 等)持有的凭据。 你的应用声明**如何**获取这些凭据——即**连接提供程序**——并在运行时使用它们向第三方 API 发起认证调用。
|
||
|
||
目前仅支持 OAuth 2.0。 将来的凭据类型(个人访问令牌、API 密钥、基本身份验证)将接入相同的接口——已经使用 `defineConnectionProvider({ type: 'oauth', ... })` 的应用将无需迁移。
|
||
|
||
<AccordionGroup>
|
||
|
||
<Accordion title="defineConnectionProvider" description="声明你的应用如何获取连接">
|
||
|
||
连接提供程序描述了你的应用所需的 OAuth 握手流程。 用户在你的应用设置中点击"添加连接",完成提供方的授权同意页面后,会在其工作区中创建一条 `ConnectedAccount` 行。
|
||
|
||
一个可用的配置需要**两个文件**——连接提供程序,以及在 `defineApplication` 上与之匹配、用于保存 OAuth 客户端凭据的 `serverVariables` 声明。
|
||
|
||
```ts src/connection-providers/linear-connection.ts
|
||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||
|
||
export default defineConnectionProvider({
|
||
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
|
||
name: 'linear',
|
||
displayName: 'Linear',
|
||
icon: 'IconBrandLinear',
|
||
type: 'oauth',
|
||
oauth: {
|
||
authorizationEndpoint: 'https://linear.app/oauth/authorize',
|
||
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
||
scopes: ['read', 'write'],
|
||
// These must match keys in `defineApplication.serverVariables` below.
|
||
clientIdVariable: 'LINEAR_CLIENT_ID',
|
||
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
|
||
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
|
||
// 'form-urlencoded' for the token request.
|
||
tokenRequestContentType: 'form-urlencoded',
|
||
// Optional: defaults to true. Disable only if the provider rejects PKCE.
|
||
usePkce: false,
|
||
// Optional: extra query params on the authorize URL.
|
||
// authorizationParams: { prompt: 'consent' },
|
||
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
|
||
// revokeEndpoint: 'https://example.com/oauth/revoke',
|
||
},
|
||
// Optional: a logic function in this app to run right after a connection is
|
||
// established. See "Run a logic function on connect".
|
||
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
|
||
// Optional: a logic function in this app to run right after a connection is
|
||
// removed. See "Run a logic function on disconnect".
|
||
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
|
||
});
|
||
```
|
||
|
||
```ts src/application.config.ts
|
||
import { defineApplication } from 'twenty-sdk/define';
|
||
|
||
export default defineApplication({
|
||
universalIdentifier: '...',
|
||
displayName: 'Linear',
|
||
description: 'Connect Linear to Twenty.',
|
||
// OAuth client credentials live on the app registration (one OAuth app per
|
||
// Twenty server, configured by the admin) — not per-workspace. Declare them
|
||
// as serverVariables so the admin can fill them in once for all installs.
|
||
serverVariables: {
|
||
LINEAR_CLIENT_ID: {
|
||
description: 'OAuth client ID from your Linear OAuth application.',
|
||
isSecret: false,
|
||
isRequired: true,
|
||
},
|
||
LINEAR_CLIENT_SECRET: {
|
||
description: 'OAuth client secret from your Linear OAuth application.',
|
||
isSecret: true,
|
||
isRequired: true,
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
关键点:
|
||
|
||
* `name` 是在 `listConnections({ providerName })` 中使用的唯一标识符字符串(短横线命名(kebab-case),必须匹配 `^[a-z][a-z0-9-]*$`)。
|
||
* `displayName` 会显示在每个应用的设置选项卡以及 AI 工具列表中。
|
||
* `clientIdVariable` / `clientSecretVariable` 是**名称**,而不是值——它们必须与 `defineApplication.serverVariables` 中声明的键匹配。 实际的 `client_id` 和 `client_secret` 由服务器管理员通过应用注册 UI 输入,绝不会提交到你的仓库。
|
||
* 请使用 `serverVariables`(而非 `applicationVariables`)——OAuth 凭据是服务器范围的,并且每个 Twenty 服务器只配置一个 OAuth 应用。
|
||
* 在两个 `serverVariables` 都填写之前,每个应用的设置选项卡会显示"需要服务器管理员"的提示,并且"添加连接"按钮将被禁用。
|
||
* `type: 'oauth'` 是目前唯一受支持的取值。 该判别器具备前向兼容性:未来的类型(`'pat'`、`'api-key'` 等) 将会与 `oauth` 并列新增子配置块。
|
||
|
||
你的提供方需要加入白名单的 OAuth 回调 URL 为:
|
||
|
||
```
|
||
https://<your-twenty-server>/auth/apps/callback
|
||
```
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="在连接时运行逻辑函数" description="在连接建立时立即响应">
|
||
|
||
某些提供方会在连接时向你提供数据,而在连接真正可用之前,你需要先持久化这些数据——一个典型示例是 Slack,其中 OAuth 响应会标识工作区的 `team_id`,后续传入事件将会基于该值进行关联。 将 `onConnectLogicFunction` 设置为引用同一应用中的某个逻辑函数(通过其 `universalIdentifier`),该函数会在创建 `ConnectedAccount` 之后立即运行。
|
||
|
||
```ts src/connection-providers/slack-connection.ts
|
||
export default defineConnectionProvider({
|
||
universalIdentifier: '...',
|
||
name: 'slack',
|
||
displayName: 'Slack',
|
||
type: 'oauth',
|
||
oauth: {
|
||
/* ... */
|
||
},
|
||
// Runs claimSlackTeam after every successful Slack connection.
|
||
onConnectLogicFunction: {
|
||
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
|
||
},
|
||
});
|
||
```
|
||
|
||
该钩子**在发起连接的工作区中以异步方式运行**(它会入队执行,而不是被等待),因此缓慢或失败的钩子永远不会阻塞或破坏 OAuth 回调——请确保该钩子是幂等的,并能自行处理重试。 处理程序会接收到:
|
||
|
||
```ts
|
||
type OnConnectPayload = {
|
||
connectionProviderId: string;
|
||
connectionProviderName: string; // e.g. 'slack'
|
||
connectedAccountId: string;
|
||
};
|
||
```
|
||
|
||
接下来,使用 `getConnection(connectedAccountId)` 读取最新的访问令牌,并调用提供商的 API(例如 Slack `auth.test`),或者使用[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 持久化映射。
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="在断开连接时运行逻辑函数" description="在移除连接时进行清理">
|
||
|
||
应用在连接时声明的任何内容,都必须在连接断开时被释放。 例如,一个在连接时声明了 `team_id` 的 Slack 集成,必须在连接结束时释放该声明,以便其他工作区可以连接同一个 Slack 团队。 将 `onDisconnectLogicFunction` 设置为引用同一应用中的某个逻辑函数,该函数会在删除 `ConnectedAccount` 之后立即运行。
|
||
|
||
```ts src/connection-providers/slack-connection.ts
|
||
export default defineConnectionProvider({
|
||
universalIdentifier: '...',
|
||
name: 'slack',
|
||
displayName: 'Slack',
|
||
type: 'oauth',
|
||
oauth: {
|
||
/* ... */
|
||
},
|
||
// Runs releaseSlackTeam after every Slack disconnection.
|
||
onDisconnectLogicFunction: {
|
||
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
|
||
},
|
||
});
|
||
```
|
||
|
||
与 on-connect 钩子类似,它在**正在断开连接的工作区中以异步方式运行**,并且不会阻塞断开过程。 处理程序接收的负载结构相同:
|
||
|
||
```ts
|
||
type OnDisconnectPayload = {
|
||
connectionProviderId: string;
|
||
connectionProviderName: string; // e.g. 'slack'
|
||
connectedAccountId: string;
|
||
};
|
||
```
|
||
|
||
当钩子运行时,`ConnectedAccount` 已经不存在了,因此 `getConnection(connectedAccountId)` 将不再返回结果。 清理所需的任何内容(例如 `team_id`、外部订阅 ID)必须在连接时写入到按 `connectedAccountId` 作为键的[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 中。
|
||
|
||
当连接自身被移除时,该钩子会被触发。 卸载应用会通过数据库级联来删除其连接,因此该钩子在这种情况下不会运行。 在 `defineApplication` 上声明一个 `uninstallLogicFunction` 用于该路径:它在应用的元数据被删除之前运行,因此仍然可以调用 `listConnections` 并清理剩余的内容。
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="listConnections / getConnection" description="在逻辑函数中使用连接">
|
||
|
||
在逻辑函数处理器内,`listConnections({ providerName })` 会返回此应用针对给定提供方的 `ConnectedAccount` 行,并附带已刷新的访问令牌。
|
||
|
||
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
|
||
import { listConnections } from 'twenty-sdk/logic-function';
|
||
|
||
export const createLinearIssueHandler = async (input: {
|
||
teamId?: string;
|
||
title?: string;
|
||
}) => {
|
||
if (!input.teamId || !input.title) {
|
||
return { success: false, error: 'teamId and title are required' };
|
||
}
|
||
|
||
const connections = await listConnections({ providerName: 'linear' });
|
||
|
||
// Workspace-shared credentials win when present; fall back to the first
|
||
// user-visibility one. For HTTP-route triggers you typically pick the
|
||
// request user's connection via event.userWorkspaceId instead.
|
||
const connection =
|
||
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
||
|
||
if (!connection) {
|
||
return {
|
||
success: false,
|
||
error:
|
||
'Linear is not connected. Open the app settings and click "Add connection".',
|
||
};
|
||
}
|
||
|
||
// Use connection.accessToken to call the third-party API.
|
||
const response = await fetch('https://api.linear.app/graphql', {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${connection.accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
|
||
}),
|
||
});
|
||
|
||
return { success: response.ok };
|
||
};
|
||
```
|
||
|
||
每个连接包含:
|
||
|
||
| 字段 | 描述 |
|
||
| ----------------- | -------------------------------------------------------------------------------------------- |
|
||
| `id` | 唯一的行 id;传给 `getConnection(id)` 以重新获取单个连接 |
|
||
| `visibility` | `'user'`(仅对单个工作区成员私有)或 `'workspace'`(与所有成员共享) |
|
||
| `scopes` | 上游提供方授予的 OAuth 权限(不同于 `visibility`——两者不相关) |
|
||
| `userWorkspaceId` | 所有者的 userWorkspace id——在 HTTP 路由触发器中用于选择"请求用户的连接"很有用 |
|
||
| `accessToken` | 最新的 OAuth 访问令牌(若已过期会自动刷新) |
|
||
| `name` | 连接的显示名称(在 OAuth 回调时自动生成,用户可重命名) |
|
||
| `handle` | 已连接的上游帐户电子邮件,每次重新连接时刷新; 从 OIDC `id_token` 返回的 token\` 解决方案在代币交换中返回,回退到连接20个用户的电子邮件,当提供商不返回一个 |
|
||
| `authFailedAt` | 当最近一次刷新失败时会设置;用户必须重新连接 |
|
||
|
||
关键点:
|
||
|
||
* 传入 `{ providerName }` 以按提供方筛选;省略它则可获取此应用在所有提供方上的全部连接。
|
||
* 服务器会在返回前透明地刷新访问令牌。 你的处理器始终会拿到可用的令牌(或已设置 `authFailedAt`)。
|
||
* `getConnection(id)` 是获取单行记录的对应方法。
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="按用户与工作区共享的可见性" description="用户如何在私有与共享凭据之间进行选择">
|
||
|
||
当用户点击"添加连接"时,系统会提示其选择可见性:
|
||
|
||
* **仅限我**——该凭据仅对连接的用户私有。 代表其调用的任何逻辑函数(带有 `isAuthRequired: true` 的 HTTP 路由触发器)都可以看到它;Cron 触发器和数据库事件则不可。
|
||
* **工作区共享**——任何工作区成员都可以使用该凭据。 Cron / 数据库触发器也可以使用它,因为它们没有请求用户。
|
||
|
||
为每个处理器使用合适的类型:
|
||
|
||
```ts
|
||
// HTTP-route trigger — prefer the request user's own connection.
|
||
const conn =
|
||
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
|
||
connections.find((c) => c.visibility === 'workspace');
|
||
|
||
// Cron trigger — no request user; only shared credentials are sensible.
|
||
const conn = connections.find((c) => c.visibility === 'workspace');
|
||
```
|
||
|
||
每个(用户、提供方)允许有多个连接,因此同一用户可以同时拥有"个人 Linear"和"工作 Linear"。
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="一次性提供方设置" description="在第三方服务中注册你的 OAuth 应用">
|
||
|
||
对于每个连接提供方,服务器管理员需要先在第三方注册一个 OAuth 应用。
|
||
|
||
1. 前往提供方的开发者设置(例如 https://linear.app/settings/api/applications/new)。
|
||
2. 将 **Redirect URI** 设置为 `\<SERVER_URL>/auth/apps/callback`。
|
||
3. 复制生成的**Client ID**和**Client Secret**。
|
||
4. 以服务器管理员身份在 Twenty 中打开已安装的应用 → 在相应的 `serverVariables` 上设置这些值。
|
||
5. 之后,工作区成员可以在每个应用的**连接**部分添加连接。
|
||
|
||
</Accordion>
|
||
|
||
</AccordionGroup>
|