mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 23:45:07 -05:00
Compare commits
8 Commits
feat/mult-
...
feat/atlas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b127bb41e4 | ||
|
|
01577a18b4 | ||
|
|
52aff4d60b | ||
|
|
3a3bddd6f8 | ||
|
|
639d50d6b9 | ||
|
|
cec74e09c2 | ||
|
|
d5a756c9f2 | ||
|
|
f3e994baf0 |
@@ -41,9 +41,6 @@ Diese Tastenkombinationen wechseln zwischen den Panel-Tabs auf der rechten Seite
|
|||||||
|
|
||||||
| Tastenkombination | Aktion |
|
| Tastenkombination | Aktion |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | Copilot-Tab fokussieren |
|
|
||||||
| `T` | Toolbar-Tab fokussieren |
|
|
||||||
| `E` | Editor-Tab fokussieren |
|
|
||||||
| `Mod` + `F` | Toolbar-Suche fokussieren |
|
| `Mod` + `F` | Toolbar-Suche fokussieren |
|
||||||
|
|
||||||
## Globale Navigation
|
## Globale Navigation
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ These shortcuts switch between panel tabs on the right side of the canvas.
|
|||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | Focus Copilot tab |
|
|
||||||
| `T` | Focus Toolbar tab |
|
|
||||||
| `E` | Focus Editor tab |
|
|
||||||
| `Mod` + `F` | Focus Toolbar search |
|
| `Mod` + `F` | Focus Toolbar search |
|
||||||
|
|
||||||
## Global Navigation
|
## Global Navigation
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ Update a Confluence page using the Confluence API.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of update |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `success` | boolean | Operation success status |
|
||||||
| `pageId` | string | Confluence page ID |
|
| `pageId` | string | Confluence page ID |
|
||||||
| `title` | string | Updated page title |
|
| `title` | string | Updated page title |
|
||||||
| `status` | string | Page status |
|
| `status` | string | Page status |
|
||||||
@@ -110,7 +111,6 @@ Update a Confluence page using the Confluence API.
|
|||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `url` | string | URL to view the page in Confluence |
|
| `url` | string | URL to view the page in Confluence |
|
||||||
| `success` | boolean | Update operation success status |
|
|
||||||
|
|
||||||
### `confluence_create_page`
|
### `confluence_create_page`
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ Create a new page in a Confluence space.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of creation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `pageId` | string | Created page ID |
|
| `pageId` | string | Created page ID |
|
||||||
| `title` | string | Page title |
|
| `title` | string | Page title |
|
||||||
| `status` | string | Page status |
|
| `status` | string | Page status |
|
||||||
@@ -172,9 +172,9 @@ Delete a Confluence page. By default moves to trash; use purge=true to permanent
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of deletion |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `pageId` | string | Deleted page ID |
|
|
||||||
| `deleted` | boolean | Deletion status |
|
| `deleted` | boolean | Deletion status |
|
||||||
|
| `pageId` | string | Deleted page ID |
|
||||||
|
|
||||||
### `confluence_list_pages_in_space`
|
### `confluence_list_pages_in_space`
|
||||||
|
|
||||||
@@ -358,10 +358,10 @@ List all custom properties (metadata) attached to a Confluence page.
|
|||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `pageId` | string | ID of the page |
|
| `pageId` | string | ID of the page |
|
||||||
| `properties` | array | Array of content properties |
|
| `properties` | array | Array of content properties |
|
||||||
| ↳ `id` | string | Property ID |
|
| ↳ `id` | string | Unique property identifier |
|
||||||
| ↳ `key` | string | Property key |
|
| ↳ `key` | string | Property key/name |
|
||||||
| ↳ `value` | json | Property value \(can be any JSON\) |
|
| ↳ `value` | json | Property value \(can be any JSON\) |
|
||||||
| ↳ `version` | object | Version information |
|
| ↳ `version` | object | Property version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
@@ -388,16 +388,72 @@ Create a new custom property (metadata) on a Confluence page.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `pageId` | string | ID of the page |
|
| `id` | string | Unique property identifier |
|
||||||
| `propertyId` | string | ID of the created property |
|
| `key` | string | Property key/name |
|
||||||
| `key` | string | Property key |
|
| `value` | json | Property value \(can be any JSON\) |
|
||||||
| `value` | json | Property value |
|
| `version` | object | Property version information |
|
||||||
| `version` | object | Version information |
|
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
|
| `pageId` | string | ID of the page |
|
||||||
|
| `propertyId` | string | ID of the created property |
|
||||||
|
|
||||||
|
### `confluence_update_page_property`
|
||||||
|
|
||||||
|
Update an existing content property on a Confluence page.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `pageId` | string | Yes | The ID of the page containing the property |
|
||||||
|
| `propertyId` | string | Yes | The ID of the property to update |
|
||||||
|
| `key` | string | Yes | The key/name of the property |
|
||||||
|
| `value` | json | Yes | The new value for the property \(can be any JSON value\) |
|
||||||
|
| `versionNumber` | number | Yes | The current version number of the property \(for conflict prevention\) |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `id` | string | Unique property identifier |
|
||||||
|
| `key` | string | Property key/name |
|
||||||
|
| `value` | json | Property value \(can be any JSON\) |
|
||||||
|
| `version` | object | Property version information |
|
||||||
|
| ↳ `number` | number | Version number |
|
||||||
|
| ↳ `message` | string | Version message |
|
||||||
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
|
| `pageId` | string | ID of the page |
|
||||||
|
| `propertyId` | string | ID of the updated property |
|
||||||
|
|
||||||
|
### `confluence_delete_page_property`
|
||||||
|
|
||||||
|
Delete a content property from a Confluence page by its property ID.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `pageId` | string | Yes | The ID of the page containing the property |
|
||||||
|
| `propertyId` | string | Yes | The ID of the property to delete |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `pageId` | string | ID of the page |
|
||||||
|
| `propertyId` | string | ID of the deleted property |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
|
||||||
### `confluence_search`
|
### `confluence_search`
|
||||||
|
|
||||||
@@ -416,7 +472,7 @@ Search for content across Confluence pages, blog posts, and other content.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of search |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `results` | array | Array of search results |
|
| `results` | array | Array of search results |
|
||||||
| ↳ `id` | string | Unique content identifier |
|
| ↳ `id` | string | Unique content identifier |
|
||||||
| ↳ `title` | string | Content title |
|
| ↳ `title` | string | Content title |
|
||||||
@@ -490,19 +546,29 @@ List all blog posts across all accessible Confluence spaces.
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `blogPosts` | array | Array of blog posts |
|
| `blogPosts` | array | Array of blog posts |
|
||||||
| ↳ `id` | string | Blog post ID |
|
| ↳ `id` | string | Unique blog post identifier |
|
||||||
| ↳ `title` | string | Blog post title |
|
| ↳ `title` | string | Blog post title |
|
||||||
| ↳ `status` | string | Blog post status |
|
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
|
||||||
| ↳ `spaceId` | string | Space ID |
|
| ↳ `spaceId` | string | ID of the space containing the blog post |
|
||||||
| ↳ `authorId` | string | Author account ID |
|
| ↳ `authorId` | string | Account ID of the blog post author |
|
||||||
| ↳ `createdAt` | string | Creation timestamp |
|
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
||||||
| ↳ `version` | object | Version information |
|
| ↳ `version` | object | Blog post version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| ↳ `webUrl` | string | URL to view the blog post |
|
| ↳ `body` | object | Blog post body content |
|
||||||
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
|
||||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
### `confluence_get_blogpost`
|
### `confluence_get_blogpost`
|
||||||
@@ -523,19 +589,19 @@ Get a specific Confluence blog post by ID, including its content.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `id` | string | Blog post ID |
|
| `id` | string | Unique blog post identifier |
|
||||||
| `title` | string | Blog post title |
|
| `title` | string | Blog post title |
|
||||||
| `status` | string | Blog post status |
|
| `status` | string | Blog post status \(e.g., current, draft\) |
|
||||||
| `spaceId` | string | Space ID |
|
| `spaceId` | string | ID of the space containing the blog post |
|
||||||
| `authorId` | string | Author account ID |
|
| `authorId` | string | Account ID of the blog post author |
|
||||||
| `createdAt` | string | Creation timestamp |
|
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
||||||
| `version` | object | Version information |
|
| `version` | object | Blog post version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `body` | object | Blog post body content in requested format\(s\) |
|
| `body` | object | Blog post body content |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
@@ -545,7 +611,7 @@ Get a specific Confluence blog post by ID, including its content.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
| `webUrl` | string | URL to view the blog post |
|
| `webUrl` | string | URL to view the blog post in Confluence |
|
||||||
|
|
||||||
### `confluence_create_blogpost`
|
### `confluence_create_blogpost`
|
||||||
|
|
||||||
@@ -567,11 +633,18 @@ Create a new blog post in a Confluence space.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `id` | string | Created blog post ID |
|
| `id` | string | Unique blog post identifier |
|
||||||
| `title` | string | Blog post title |
|
| `title` | string | Blog post title |
|
||||||
| `status` | string | Blog post status |
|
| `status` | string | Blog post status \(e.g., current, draft\) |
|
||||||
| `spaceId` | string | Space ID |
|
| `spaceId` | string | ID of the space containing the blog post |
|
||||||
| `authorId` | string | Author account ID |
|
| `authorId` | string | Account ID of the blog post author |
|
||||||
|
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
||||||
|
| `version` | object | Blog post version information |
|
||||||
|
| ↳ `number` | number | Version number |
|
||||||
|
| ↳ `message` | string | Version message |
|
||||||
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `body` | object | Blog post body content |
|
| `body` | object | Blog post body content |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
@@ -582,13 +655,71 @@ Create a new blog post in a Confluence space.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| `webUrl` | string | URL to view the blog post in Confluence |
|
||||||
|
|
||||||
|
### `confluence_update_blogpost`
|
||||||
|
|
||||||
|
Update an existing Confluence blog post title, content, or status.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `blogPostId` | string | Yes | The ID of the blog post to update |
|
||||||
|
| `title` | string | No | New title for the blog post |
|
||||||
|
| `content` | string | No | New content for the blog post in Confluence storage format |
|
||||||
|
| `status` | string | No | Blog post status: current or draft |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `id` | string | Unique blog post identifier |
|
||||||
|
| `title` | string | Blog post title |
|
||||||
|
| `status` | string | Blog post status \(e.g., current, draft\) |
|
||||||
|
| `spaceId` | string | ID of the space containing the blog post |
|
||||||
|
| `authorId` | string | Account ID of the blog post author |
|
||||||
|
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
||||||
| `version` | object | Blog post version information |
|
| `version` | object | Blog post version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `webUrl` | string | URL to view the blog post |
|
| `body` | object | Blog post body content |
|
||||||
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
|
| ↳ `representation` | string | Content representation type |
|
||||||
|
| `webUrl` | string | URL to view the blog post in Confluence |
|
||||||
|
|
||||||
|
### `confluence_delete_blogpost`
|
||||||
|
|
||||||
|
Delete a Confluence blog post.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `blogPostId` | string | Yes | The ID of the blog post to delete |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
| `blogPostId` | string | Deleted blog post ID |
|
||||||
|
|
||||||
### `confluence_list_blogposts_in_space`
|
### `confluence_list_blogposts_in_space`
|
||||||
|
|
||||||
@@ -612,13 +743,13 @@ List all blog posts within a specific Confluence space.
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `blogPosts` | array | Array of blog posts in the space |
|
| `blogPosts` | array | Array of blog posts in the space |
|
||||||
| ↳ `id` | string | Blog post ID |
|
| ↳ `id` | string | Unique blog post identifier |
|
||||||
| ↳ `title` | string | Blog post title |
|
| ↳ `title` | string | Blog post title |
|
||||||
| ↳ `status` | string | Blog post status |
|
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
|
||||||
| ↳ `spaceId` | string | Space ID |
|
| ↳ `spaceId` | string | ID of the space containing the blog post |
|
||||||
| ↳ `authorId` | string | Author account ID |
|
| ↳ `authorId` | string | Account ID of the blog post author |
|
||||||
| ↳ `createdAt` | string | Creation timestamp |
|
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
||||||
| ↳ `version` | object | Version information |
|
| ↳ `version` | object | Blog post version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
@@ -634,7 +765,7 @@ List all blog posts within a specific Confluence space.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
| ↳ `webUrl` | string | URL to view the blog post |
|
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
|
||||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
### `confluence_create_comment`
|
### `confluence_create_comment`
|
||||||
@@ -654,7 +785,7 @@ Add a comment to a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of creation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `commentId` | string | Created comment ID |
|
| `commentId` | string | Created comment ID |
|
||||||
| `pageId` | string | Page ID |
|
| `pageId` | string | Page ID |
|
||||||
|
|
||||||
@@ -715,9 +846,9 @@ Update an existing comment on a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of update |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `commentId` | string | Updated comment ID |
|
|
||||||
| `updated` | boolean | Update status |
|
| `updated` | boolean | Update status |
|
||||||
|
| `commentId` | string | Updated comment ID |
|
||||||
|
|
||||||
### `confluence_delete_comment`
|
### `confluence_delete_comment`
|
||||||
|
|
||||||
@@ -735,9 +866,9 @@ Delete a comment from a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of deletion |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `commentId` | string | Deleted comment ID |
|
|
||||||
| `deleted` | boolean | Deletion status |
|
| `deleted` | boolean | Deletion status |
|
||||||
|
| `commentId` | string | Deleted comment ID |
|
||||||
|
|
||||||
### `confluence_upload_attachment`
|
### `confluence_upload_attachment`
|
||||||
|
|
||||||
@@ -758,7 +889,7 @@ Upload a file as an attachment to a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of upload |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `attachmentId` | string | Uploaded attachment ID |
|
| `attachmentId` | string | Uploaded attachment ID |
|
||||||
| `title` | string | Attachment file name |
|
| `title` | string | Attachment file name |
|
||||||
| `fileSize` | number | File size in bytes |
|
| `fileSize` | number | File size in bytes |
|
||||||
@@ -820,9 +951,9 @@ Delete an attachment from a Confluence page (moves to trash).
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of deletion |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `attachmentId` | string | Deleted attachment ID |
|
|
||||||
| `deleted` | boolean | Deletion status |
|
| `deleted` | boolean | Deletion status |
|
||||||
|
| `attachmentId` | string | Deleted attachment ID |
|
||||||
|
|
||||||
### `confluence_list_labels`
|
### `confluence_list_labels`
|
||||||
|
|
||||||
@@ -842,7 +973,7 @@ List all labels on a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of retrieval |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `labels` | array | Array of labels on the page |
|
| `labels` | array | Array of labels on the page |
|
||||||
| ↳ `id` | string | Unique label identifier |
|
| ↳ `id` | string | Unique label identifier |
|
||||||
| ↳ `name` | string | Label name |
|
| ↳ `name` | string | Label name |
|
||||||
@@ -872,6 +1003,90 @@ Add a label to a Confluence page for organization and categorization.
|
|||||||
| `labelName` | string | Name of the added label |
|
| `labelName` | string | Name of the added label |
|
||||||
| `labelId` | string | ID of the added label |
|
| `labelId` | string | ID of the added label |
|
||||||
|
|
||||||
|
### `confluence_delete_label`
|
||||||
|
|
||||||
|
Remove a label from a Confluence page.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `pageId` | string | Yes | Confluence page ID to remove the label from |
|
||||||
|
| `labelName` | string | Yes | Name of the label to remove |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `pageId` | string | Page ID the label was removed from |
|
||||||
|
| `labelName` | string | Name of the removed label |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
|
||||||
|
### `confluence_get_pages_by_label`
|
||||||
|
|
||||||
|
Retrieve all pages that have a specific label applied.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `labelId` | string | Yes | The ID of the label to get pages for |
|
||||||
|
| `limit` | number | No | Maximum number of pages to return \(default: 50, max: 250\) |
|
||||||
|
| `cursor` | string | No | Pagination cursor from previous response |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `labelId` | string | ID of the label |
|
||||||
|
| `pages` | array | Array of pages with this label |
|
||||||
|
| ↳ `id` | string | Unique page identifier |
|
||||||
|
| ↳ `title` | string | Page title |
|
||||||
|
| ↳ `status` | string | Page status \(e.g., current, archived, trashed, draft\) |
|
||||||
|
| ↳ `spaceId` | string | ID of the space containing the page |
|
||||||
|
| ↳ `parentId` | string | ID of the parent page \(null if top-level\) |
|
||||||
|
| ↳ `authorId` | string | Account ID of the page author |
|
||||||
|
| ↳ `createdAt` | string | ISO 8601 timestamp when the page was created |
|
||||||
|
| ↳ `version` | object | Page version information |
|
||||||
|
| ↳ `number` | number | Version number |
|
||||||
|
| ↳ `message` | string | Version message |
|
||||||
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
|
### `confluence_list_space_labels`
|
||||||
|
|
||||||
|
List all labels associated with a Confluence space.
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `spaceId` | string | Yes | The ID of the Confluence space to list labels from |
|
||||||
|
| `limit` | number | No | Maximum number of labels to return \(default: 25, max: 250\) |
|
||||||
|
| `cursor` | string | No | Pagination cursor from previous response |
|
||||||
|
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
|
| `spaceId` | string | ID of the space |
|
||||||
|
| `labels` | array | Array of labels on the space |
|
||||||
|
| ↳ `id` | string | Unique label identifier |
|
||||||
|
| ↳ `name` | string | Label name |
|
||||||
|
| ↳ `prefix` | string | Label prefix/type \(e.g., global, my, team\) |
|
||||||
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
### `confluence_get_space`
|
### `confluence_get_space`
|
||||||
|
|
||||||
Get details about a specific Confluence space.
|
Get details about a specific Confluence space.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ With Sim’s Jira Service Management integration, you can create, monitor, and u
|
|||||||
|
|
||||||
## Usage Instructions
|
## Usage Instructions
|
||||||
|
|
||||||
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.
|
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +66,31 @@ Get all service desks from Jira Service Management
|
|||||||
| `total` | number | Total number of service desks |
|
| `total` | number | Total number of service desks |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_service_desk`
|
||||||
|
|
||||||
|
Get a specific service desk by ID in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `id` | string | Service desk ID |
|
||||||
|
| `projectId` | string | Associated Jira project ID |
|
||||||
|
| `projectName` | string | Associated project name |
|
||||||
|
| `projectKey` | string | Associated project key |
|
||||||
|
| `name` | string | Service desk name |
|
||||||
|
| `description` | string | Service desk description |
|
||||||
|
| `leadDisplayName` | string | Project lead display name |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
|
||||||
### `jsm_get_request_types`
|
### `jsm_get_request_types`
|
||||||
|
|
||||||
Get request types for a service desk in Jira Service Management
|
Get request types for a service desk in Jira Service Management
|
||||||
@@ -101,6 +126,39 @@ Get request types for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of request types |
|
| `total` | number | Total number of request types |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_request_type_fields`
|
||||||
|
|
||||||
|
Get the fields required to create a request of a specific type in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
|
| `requestTypeId` | string | Request type ID |
|
||||||
|
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
|
||||||
|
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
|
||||||
|
| `requestTypeFields` | array | List of fields for this request type |
|
||||||
|
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
|
||||||
|
| ↳ `name` | string | Human-readable field name |
|
||||||
|
| ↳ `description` | string | Help text for the field |
|
||||||
|
| ↳ `required` | boolean | Whether the field is required |
|
||||||
|
| ↳ `visible` | boolean | Whether the field is visible |
|
||||||
|
| ↳ `validValues` | json | Allowed values for select fields |
|
||||||
|
| ↳ `presetValues` | json | Pre-populated values |
|
||||||
|
| ↳ `defaultValues` | json | Default values for the field |
|
||||||
|
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
|
||||||
|
|
||||||
### `jsm_create_request`
|
### `jsm_create_request`
|
||||||
|
|
||||||
Create a new service request in Jira Service Management
|
Create a new service request in Jira Service Management
|
||||||
@@ -222,6 +280,59 @@ Get multiple service requests from Jira Service Management
|
|||||||
| `total` | number | Total number of requests in current page |
|
| `total` | number | Total number of requests in current page |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_request_status`
|
||||||
|
|
||||||
|
Get status history for a service request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
||||||
|
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `statuses` | array | Status history entries |
|
||||||
|
| ↳ `status` | string | Status name |
|
||||||
|
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
|
||||||
|
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
|
||||||
|
| `total` | number | Total number of status entries |
|
||||||
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_request_attachments`
|
||||||
|
|
||||||
|
Get attachments for a service request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
|
||||||
|
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
||||||
|
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `attachments` | array | List of attachments |
|
||||||
|
| `total` | number | Total number of attachments |
|
||||||
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
|
||||||
|
|
||||||
### `jsm_add_comment`
|
### `jsm_add_comment`
|
||||||
|
|
||||||
Add a comment (public or internal) to a service request in Jira Service Management
|
Add a comment (public or internal) to a service request in Jira Service Management
|
||||||
@@ -341,6 +452,53 @@ Add customers to a service desk in Jira Service Management
|
|||||||
| `serviceDeskId` | string | Service desk ID |
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
| `success` | boolean | Whether customers were added successfully |
|
| `success` | boolean | Whether customers were added successfully |
|
||||||
|
|
||||||
|
### `jsm_remove_customer`
|
||||||
|
|
||||||
|
Remove customers from a service desk in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
| `accountIds` | string | No | Comma-separated Atlassian account IDs to remove |
|
||||||
|
| `emails` | string | No | Comma-separated email addresses to remove |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
|
| `success` | boolean | Whether customers were removed successfully |
|
||||||
|
|
||||||
|
### `jsm_create_customer`
|
||||||
|
|
||||||
|
Create a new customer in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `email` | string | Yes | Email address for the new customer |
|
||||||
|
| `displayName` | string | Yes | Display name for the new customer |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `accountId` | string | Account ID of the created customer |
|
||||||
|
| `displayName` | string | Display name of the created customer |
|
||||||
|
| `emailAddress` | string | Email address of the created customer |
|
||||||
|
| `active` | boolean | Whether the customer account is active |
|
||||||
|
| `timeZone` | string | Customer timezone |
|
||||||
|
| `success` | boolean | Whether the customer was created successfully |
|
||||||
|
|
||||||
### `jsm_get_organizations`
|
### `jsm_get_organizations`
|
||||||
|
|
||||||
Get organizations for a service desk in Jira Service Management
|
Get organizations for a service desk in Jira Service Management
|
||||||
@@ -366,6 +524,26 @@ Get organizations for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of organizations |
|
| `total` | number | Total number of organizations |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_organization`
|
||||||
|
|
||||||
|
Get a specific organization by ID in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to retrieve |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `id` | string | Organization ID |
|
||||||
|
| `name` | string | Organization name |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
|
||||||
### `jsm_create_organization`
|
### `jsm_create_organization`
|
||||||
|
|
||||||
Create a new organization in Jira Service Management
|
Create a new organization in Jira Service Management
|
||||||
@@ -409,6 +587,119 @@ Add an organization to a service desk in Jira Service Management
|
|||||||
| `organizationId` | string | Organization ID added |
|
| `organizationId` | string | Organization ID added |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
|
### `jsm_remove_organization`
|
||||||
|
|
||||||
|
Remove an organization from a service desk in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to remove from the service desk |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `serviceDeskId` | string | Service Desk ID |
|
||||||
|
| `organizationId` | string | Organization ID removed |
|
||||||
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
|
### `jsm_delete_organization`
|
||||||
|
|
||||||
|
Delete an organization in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to delete |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `organizationId` | string | ID of the deleted organization |
|
||||||
|
| `success` | boolean | Whether the organization was deleted |
|
||||||
|
|
||||||
|
### `jsm_get_organization_users`
|
||||||
|
|
||||||
|
Get users in an organization in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to get users from |
|
||||||
|
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
||||||
|
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `organizationId` | string | Organization ID |
|
||||||
|
| `users` | array | List of users in the organization |
|
||||||
|
| ↳ `accountId` | string | Atlassian account ID |
|
||||||
|
| ↳ `displayName` | string | Display name |
|
||||||
|
| ↳ `emailAddress` | string | Email address |
|
||||||
|
| ↳ `active` | boolean | Whether the account is active |
|
||||||
|
| ↳ `timeZone` | string | User timezone |
|
||||||
|
| `total` | number | Total number of users |
|
||||||
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_add_organization_users`
|
||||||
|
|
||||||
|
Add users to an organization in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to add users to |
|
||||||
|
| `accountIds` | string | Yes | Comma-separated account IDs to add to the organization |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `organizationId` | string | Organization ID |
|
||||||
|
| `success` | boolean | Whether users were added successfully |
|
||||||
|
|
||||||
|
### `jsm_remove_organization_users`
|
||||||
|
|
||||||
|
Remove users from an organization in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `organizationId` | string | Yes | Organization ID to remove users from |
|
||||||
|
| `accountIds` | string | Yes | Comma-separated account IDs to remove from the organization |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `organizationId` | string | Organization ID |
|
||||||
|
| `success` | boolean | Whether users were removed successfully |
|
||||||
|
|
||||||
### `jsm_get_queues`
|
### `jsm_get_queues`
|
||||||
|
|
||||||
Get queues for a service desk in Jira Service Management
|
Get queues for a service desk in Jira Service Management
|
||||||
@@ -438,6 +729,51 @@ Get queues for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of queues |
|
| `total` | number | Total number of queues |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
### `jsm_get_queue_issues`
|
||||||
|
|
||||||
|
Get issues in a specific queue for a service desk in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
| `queueId` | string | Yes | Queue ID to get issues from |
|
||||||
|
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
||||||
|
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
|
| `queueId` | string | Queue ID |
|
||||||
|
| `issues` | array | List of issues in the queue |
|
||||||
|
| ↳ `issueId` | string | Jira issue ID |
|
||||||
|
| ↳ `issueKey` | string | Issue key \(e.g., SD-123\) |
|
||||||
|
| ↳ `requestTypeId` | string | Request type ID |
|
||||||
|
| ↳ `serviceDeskId` | string | Service desk ID |
|
||||||
|
| ↳ `createdDate` | json | Creation date with iso8601, friendly, epochMillis |
|
||||||
|
| ↳ `currentStatus` | object | Current request status |
|
||||||
|
| ↳ `status` | string | Status name |
|
||||||
|
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
|
||||||
|
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
|
||||||
|
| ↳ `reporter` | object | Reporter user details |
|
||||||
|
| ↳ `accountId` | string | Atlassian account ID |
|
||||||
|
| ↳ `displayName` | string | User display name |
|
||||||
|
| ↳ `emailAddress` | string | User email address |
|
||||||
|
| ↳ `active` | boolean | Whether the account is active |
|
||||||
|
| ↳ `requestFieldValues` | array | Request field values |
|
||||||
|
| ↳ `fieldId` | string | Field identifier |
|
||||||
|
| ↳ `label` | string | Human-readable field label |
|
||||||
|
| ↳ `value` | json | Field value |
|
||||||
|
| ↳ `renderedValue` | json | HTML-rendered field value |
|
||||||
|
| `total` | number | Total number of issues in the queue |
|
||||||
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_sla`
|
### `jsm_get_sla`
|
||||||
|
|
||||||
Get SLA information for a service request in Jira Service Management
|
Get SLA information for a service request in Jira Service Management
|
||||||
@@ -569,6 +905,32 @@ Add participants to a request in Jira Service Management
|
|||||||
| ↳ `active` | boolean | Whether the account is active |
|
| ↳ `active` | boolean | Whether the account is active |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
|
### `jsm_remove_participants`
|
||||||
|
|
||||||
|
Remove participants from a request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
| `accountIds` | string | Yes | Comma-separated account IDs to remove as participants |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `participants` | array | Remaining participants after removal |
|
||||||
|
| ↳ `accountId` | string | Atlassian account ID |
|
||||||
|
| ↳ `displayName` | string | Display name |
|
||||||
|
| ↳ `emailAddress` | string | Email address |
|
||||||
|
| ↳ `active` | boolean | Whether the account is active |
|
||||||
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
### `jsm_get_approvals`
|
### `jsm_get_approvals`
|
||||||
|
|
||||||
Get approvals for a request in Jira Service Management
|
Get approvals for a request in Jira Service Management
|
||||||
@@ -644,9 +1006,9 @@ Approve or decline an approval request in Jira Service Management
|
|||||||
| `approval` | json | The approval object |
|
| `approval` | json | The approval object |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
### `jsm_get_request_type_fields`
|
### `jsm_get_feedback`
|
||||||
|
|
||||||
Get the fields required to create a request of a specific type in Jira Service Management
|
Get CSAT feedback for a service request in Jira Service Management
|
||||||
|
|
||||||
#### Input
|
#### Input
|
||||||
|
|
||||||
@@ -654,27 +1016,152 @@ Get the fields required to create a request of a specific type in Jira Service M
|
|||||||
| --------- | ---- | -------- | ----------- |
|
| --------- | ---- | -------- | ----------- |
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
|
|
||||||
|
|
||||||
#### Output
|
#### Output
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of the operation |
|
| `ts` | string | Timestamp of the operation |
|
||||||
| `serviceDeskId` | string | Service desk ID |
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
| `requestTypeId` | string | Request type ID |
|
| `rating` | number | CSAT rating \(1-5\) |
|
||||||
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
|
| `comment` | string | Feedback comment |
|
||||||
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
|
| `type` | string | Feedback type \(e.g., csat\) |
|
||||||
| `requestTypeFields` | array | List of fields for this request type |
|
|
||||||
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
|
### `jsm_add_feedback`
|
||||||
| ↳ `name` | string | Human-readable field name |
|
|
||||||
| ↳ `description` | string | Help text for the field |
|
Add CSAT feedback to a service request in Jira Service Management
|
||||||
| ↳ `required` | boolean | Whether the field is required |
|
|
||||||
| ↳ `visible` | boolean | Whether the field is visible |
|
#### Input
|
||||||
| ↳ `validValues` | json | Allowed values for select fields |
|
|
||||||
| ↳ `presetValues` | json | Pre-populated values |
|
| Parameter | Type | Required | Description |
|
||||||
| ↳ `defaultValues` | json | Default values for the field |
|
| --------- | ---- | -------- | ----------- |
|
||||||
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
| `rating` | number | Yes | CSAT rating \(1-5\) |
|
||||||
|
| `comment` | string | No | Optional feedback comment |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `rating` | number | CSAT rating submitted |
|
||||||
|
| `comment` | string | Feedback comment |
|
||||||
|
| `type` | string | Feedback type |
|
||||||
|
| `success` | boolean | Whether feedback was submitted successfully |
|
||||||
|
|
||||||
|
### `jsm_delete_feedback`
|
||||||
|
|
||||||
|
Delete CSAT feedback from a service request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `success` | boolean | Whether feedback was deleted |
|
||||||
|
|
||||||
|
### `jsm_get_notification`
|
||||||
|
|
||||||
|
Get notification subscription status for a request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `subscribed` | boolean | Whether currently subscribed to notifications |
|
||||||
|
|
||||||
|
### `jsm_subscribe_notification`
|
||||||
|
|
||||||
|
Subscribe to notifications for a request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `success` | boolean | Whether subscription was successful |
|
||||||
|
|
||||||
|
### `jsm_unsubscribe_notification`
|
||||||
|
|
||||||
|
Unsubscribe from notifications for a request in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `issueIdOrKey` | string | Issue ID or key |
|
||||||
|
| `success` | boolean | Whether unsubscription was successful |
|
||||||
|
|
||||||
|
### `jsm_search_knowledge_base`
|
||||||
|
|
||||||
|
Search knowledge base articles in Jira Service Management
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
|
| `serviceDeskId` | string | No | Service Desk ID to search within \(optional, searches globally if omitted\) |
|
||||||
|
| `query` | string | Yes | Search query for knowledge base articles |
|
||||||
|
| `highlight` | boolean | No | Whether to highlight matching text in results |
|
||||||
|
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
||||||
|
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `ts` | string | Timestamp of the operation |
|
||||||
|
| `articles` | array | List of knowledge base articles |
|
||||||
|
| ↳ `title` | string | Article title |
|
||||||
|
| ↳ `excerpt` | string | Article excerpt/summary |
|
||||||
|
| ↳ `sourceType` | string | Source type \(e.g., confluence\) |
|
||||||
|
| ↳ `sourcePageId` | string | Source page ID |
|
||||||
|
| ↳ `sourceSpaceKey` | string | Source space key |
|
||||||
|
| ↳ `contentUrl` | string | URL to rendered content |
|
||||||
|
| `total` | number | Total number of articles found |
|
||||||
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ Estos atajos cambian entre las pestañas del panel en el lado derecho del lienzo
|
|||||||
|
|
||||||
| Atajo | Acción |
|
| Atajo | Acción |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | Enfocar pestaña Copilot |
|
|
||||||
| `T` | Enfocar pestaña Barra de herramientas |
|
|
||||||
| `E` | Enfocar pestaña Editor |
|
|
||||||
| `Mod` + `F` | Enfocar búsqueda de Barra de herramientas |
|
| `Mod` + `F` | Enfocar búsqueda de Barra de herramientas |
|
||||||
|
|
||||||
## Navegación global
|
## Navegación global
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ Ces raccourcis permettent de basculer entre les onglets du panneau sur le côté
|
|||||||
|
|
||||||
| Raccourci | Action |
|
| Raccourci | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | Activer l'onglet Copilot |
|
|
||||||
| `T` | Activer l'onglet Barre d'outils |
|
|
||||||
| `E` | Activer l'onglet Éditeur |
|
|
||||||
| `Mod` + `F` | Activer la recherche dans la barre d'outils |
|
| `Mod` + `F` | Activer la recherche dans la barre d'outils |
|
||||||
|
|
||||||
## Navigation globale
|
## Navigation globale
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
|||||||
|
|
||||||
| ショートカット | 操作 |
|
| ショートカット | 操作 |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | Copilotタブにフォーカス |
|
|
||||||
| `T` | Toolbarタブにフォーカス |
|
|
||||||
| `E` | Editorタブにフォーカス |
|
|
||||||
| `Mod` + `F` | Toolbar検索にフォーカス |
|
| `Mod` + `F` | Toolbar検索にフォーカス |
|
||||||
|
|
||||||
## グローバルナビゲーション
|
## グローバルナビゲーション
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
|||||||
|
|
||||||
| 快捷键 | 操作 |
|
| 快捷键 | 操作 |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `C` | 聚焦 Copilot 标签页 |
|
|
||||||
| `T` | 聚焦 Toolbar 标签页 |
|
|
||||||
| `E` | 聚焦 Editor 标签页 |
|
|
||||||
| `Mod` + `F` | 聚焦 Toolbar 搜索 |
|
| `Mod` + `F` | 聚焦 Toolbar 搜索 |
|
||||||
|
|
||||||
## 全局导航
|
## 全局导航
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { account } from '@sim/db/schema'
|
import { account } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, desc, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
@@ -31,13 +31,15 @@ export async function GET(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.from(account)
|
.from(account)
|
||||||
.where(and(...whereConditions))
|
.where(and(...whereConditions))
|
||||||
.orderBy(desc(account.updatedAt))
|
|
||||||
|
// Use the user's email as the display name (consistent with credential selector)
|
||||||
|
const userEmail = session.user.email
|
||||||
|
|
||||||
const accountsWithDisplayName = accounts.map((acc) => ({
|
const accountsWithDisplayName = accounts.map((acc) => ({
|
||||||
id: acc.id,
|
id: acc.id,
|
||||||
accountId: acc.accountId,
|
accountId: acc.accountId,
|
||||||
providerId: acc.providerId,
|
providerId: acc.providerId,
|
||||||
displayName: acc.accountId || acc.providerId,
|
displayName: userEmail || acc.providerId,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({ accounts: accountsWithDisplayName })
|
return NextResponse.json({ accounts: accountsWithDisplayName })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { account, credential, credentialMember, user } from '@sim/db/schema'
|
import { account, user } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { jwtDecode } from 'jwt-decode'
|
import { jwtDecode } from 'jwt-decode'
|
||||||
@@ -7,10 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
|
||||||
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
|
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
|
||||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -20,7 +18,6 @@ const credentialsQuerySchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: z.string().nullish(),
|
provider: z.string().nullish(),
|
||||||
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
|
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
|
||||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
|
|
||||||
credentialId: z
|
credentialId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'Credential ID must not be empty')
|
.min(1, 'Credential ID must not be empty')
|
||||||
@@ -38,79 +35,6 @@ interface GoogleIdToken {
|
|||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCredentialResponse(
|
|
||||||
id: string,
|
|
||||||
displayName: string,
|
|
||||||
providerId: string,
|
|
||||||
updatedAt: Date,
|
|
||||||
scope: string | null
|
|
||||||
) {
|
|
||||||
const storedScope = scope?.trim()
|
|
||||||
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
|
||||||
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
|
|
||||||
const [_, featureType = 'default'] = providerId.split('-')
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: displayName,
|
|
||||||
provider: providerId,
|
|
||||||
lastUsed: updatedAt.toISOString(),
|
|
||||||
isDefault: featureType === 'default',
|
|
||||||
scopes: scopeEvaluation.grantedScopes,
|
|
||||||
canonicalScopes: scopeEvaluation.canonicalScopes,
|
|
||||||
missingScopes: scopeEvaluation.missingScopes,
|
|
||||||
extraScopes: scopeEvaluation.extraScopes,
|
|
||||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFallbackDisplayName(
|
|
||||||
requestId: string,
|
|
||||||
providerParam: string | null | undefined,
|
|
||||||
accountRow: {
|
|
||||||
idToken: string | null
|
|
||||||
accountId: string
|
|
||||||
userId: string
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const providerForParse = (providerParam || 'google') as OAuthProvider
|
|
||||||
const { baseProvider } = parseProvider(providerForParse)
|
|
||||||
|
|
||||||
if (accountRow.idToken) {
|
|
||||||
try {
|
|
||||||
const decoded = jwtDecode<GoogleIdToken>(accountRow.idToken)
|
|
||||||
if (decoded.email) return decoded.email
|
|
||||||
if (decoded.name) return decoded.name
|
|
||||||
} catch (_error) {
|
|
||||||
logger.warn(`[${requestId}] Error decoding ID token`, {
|
|
||||||
accountId: accountRow.accountId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseProvider === 'github') {
|
|
||||||
return `${accountRow.accountId} (GitHub)`
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userRecord = await db
|
|
||||||
.select({ email: user.email })
|
|
||||||
.from(user)
|
|
||||||
.where(eq(user.id, accountRow.userId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (userRecord.length > 0) {
|
|
||||||
return userRecord[0].email
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
logger.warn(`[${requestId}] Error fetching user email`, {
|
|
||||||
userId: accountRow.userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${accountRow.accountId} (${baseProvider})`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get credentials for a specific provider
|
* Get credentials for a specific provider
|
||||||
*/
|
*/
|
||||||
@@ -122,7 +46,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const rawQuery = {
|
const rawQuery = {
|
||||||
provider: searchParams.get('provider'),
|
provider: searchParams.get('provider'),
|
||||||
workflowId: searchParams.get('workflowId'),
|
workflowId: searchParams.get('workflowId'),
|
||||||
workspaceId: searchParams.get('workspaceId'),
|
|
||||||
credentialId: searchParams.get('credentialId'),
|
credentialId: searchParams.get('credentialId'),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +78,7 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
|
const { provider: providerParam, workflowId, credentialId } = parseResult.data
|
||||||
|
|
||||||
// Authenticate requester (supports session and internal JWT)
|
// Authenticate requester (supports session and internal JWT)
|
||||||
const authResult = await checkSessionOrInternalAuth(request)
|
const authResult = await checkSessionOrInternalAuth(request)
|
||||||
@@ -165,7 +88,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const requesterUserId = authResult.userId
|
const requesterUserId = authResult.userId
|
||||||
|
|
||||||
let effectiveWorkspaceId = workspaceId ?? undefined
|
const effectiveUserId = requesterUserId
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||||
workflowId,
|
workflowId,
|
||||||
@@ -183,145 +106,101 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: workflowAuthorization.status }
|
{ status: workflowAuthorization.status }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveWorkspaceId) {
|
// Parse the provider to get base provider and feature type (if provider is present)
|
||||||
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
|
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider)
|
||||||
if (!workspaceAccess.hasAccess) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let accountsData
|
let accountsData
|
||||||
|
|
||||||
if (credentialId) {
|
|
||||||
const [platformCredential] = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
type: credential.type,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
accountProviderId: account.providerId,
|
|
||||||
accountScope: account.scope,
|
|
||||||
accountUpdatedAt: account.updatedAt,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.leftJoin(account, eq(credential.accountId, account.id))
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (platformCredential) {
|
|
||||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
|
||||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowId) {
|
|
||||||
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const [membership] = await db
|
|
||||||
.select({ id: credentialMember.id })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, platformCredential.id),
|
|
||||||
eq(credentialMember.userId, requesterUserId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
|
|
||||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
credentials: [
|
|
||||||
toCredentialResponse(
|
|
||||||
platformCredential.id,
|
|
||||||
platformCredential.displayName,
|
|
||||||
platformCredential.accountProviderId,
|
|
||||||
platformCredential.accountUpdatedAt,
|
|
||||||
platformCredential.accountScope
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveWorkspaceId && providerParam) {
|
|
||||||
await syncWorkspaceOAuthCredentialsForUser({
|
|
||||||
workspaceId: effectiveWorkspaceId,
|
|
||||||
userId: requesterUserId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const credentialsData = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
providerId: account.providerId,
|
|
||||||
scope: account.scope,
|
|
||||||
updatedAt: account.updatedAt,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.innerJoin(account, eq(credential.accountId, account.id))
|
|
||||||
.innerJoin(
|
|
||||||
credentialMember,
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credential.id),
|
|
||||||
eq(credentialMember.userId, requesterUserId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, effectiveWorkspaceId),
|
|
||||||
eq(credential.type, 'oauth'),
|
|
||||||
eq(account.providerId, providerParam)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
credentials: credentialsData.map((row) =>
|
|
||||||
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (credentialId && workflowId) {
|
if (credentialId && workflowId) {
|
||||||
|
// When both workflowId and credentialId are provided, fetch by ID only.
|
||||||
|
// Workspace authorization above already proves access; the credential
|
||||||
|
// may belong to another workspace member (e.g. for display name resolution).
|
||||||
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
|
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
|
||||||
} else if (credentialId) {
|
} else if (credentialId) {
|
||||||
accountsData = await db
|
accountsData = await db
|
||||||
.select()
|
.select()
|
||||||
.from(account)
|
.from(account)
|
||||||
.where(and(eq(account.userId, requesterUserId), eq(account.id, credentialId)))
|
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
|
||||||
} else {
|
} else {
|
||||||
|
// Fetch all credentials for provider and effective user
|
||||||
accountsData = await db
|
accountsData = await db
|
||||||
.select()
|
.select()
|
||||||
.from(account)
|
.from(account)
|
||||||
.where(and(eq(account.userId, requesterUserId), eq(account.providerId, providerParam!)))
|
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform accounts into credentials
|
// Transform accounts into credentials
|
||||||
const credentials = await Promise.all(
|
const credentials = await Promise.all(
|
||||||
accountsData.map(async (acc) => {
|
accountsData.map(async (acc) => {
|
||||||
const displayName = await getFallbackDisplayName(requestId, providerParam, acc)
|
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
|
||||||
return toCredentialResponse(acc.id, displayName, acc.providerId, acc.updatedAt, acc.scope)
|
const [_, featureType = 'default'] = acc.providerId.split('-')
|
||||||
|
|
||||||
|
// Try multiple methods to get a user-friendly display name
|
||||||
|
let displayName = ''
|
||||||
|
|
||||||
|
// Method 1: Try to extract email from ID token (works for Google, etc.)
|
||||||
|
if (acc.idToken) {
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
|
||||||
|
if (decoded.email) {
|
||||||
|
displayName = decoded.email
|
||||||
|
} else if (decoded.name) {
|
||||||
|
displayName = decoded.name
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
logger.warn(`[${requestId}] Error decoding ID token`, {
|
||||||
|
accountId: acc.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: For GitHub, the accountId might be the username
|
||||||
|
if (!displayName && baseProvider === 'github') {
|
||||||
|
displayName = `${acc.accountId} (GitHub)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Try to get the user's email from our database
|
||||||
|
if (!displayName) {
|
||||||
|
try {
|
||||||
|
const userRecord = await db
|
||||||
|
.select({ email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, acc.userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (userRecord.length > 0) {
|
||||||
|
displayName = userRecord[0].email
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
logger.warn(`[${requestId}] Error fetching user email`, {
|
||||||
|
userId: acc.userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Use accountId with provider type as context
|
||||||
|
if (!displayName) {
|
||||||
|
displayName = `${acc.accountId} (${baseProvider})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedScope = acc.scope?.trim()
|
||||||
|
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||||
|
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: acc.id,
|
||||||
|
name: displayName,
|
||||||
|
provider: acc.providerId,
|
||||||
|
lastUsed: acc.updatedAt.toISOString(),
|
||||||
|
isDefault: featureType === 'default',
|
||||||
|
scopes: scopeEvaluation.grantedScopes,
|
||||||
|
canonicalScopes: scopeEvaluation.canonicalScopes,
|
||||||
|
missingScopes: scopeEvaluation.missingScopes,
|
||||||
|
extraScopes: scopeEvaluation.extraScopes,
|
||||||
|
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const logger = createLogger('OAuthDisconnectAPI')
|
|||||||
const disconnectSchema = z.object({
|
const disconnectSchema = z.object({
|
||||||
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
|
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
|
||||||
providerId: z.string().optional(),
|
providerId: z.string().optional(),
|
||||||
accountId: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,20 +50,15 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider, providerId, accountId } = parseResult.data
|
const { provider, providerId } = parseResult.data
|
||||||
|
|
||||||
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
|
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
|
||||||
provider,
|
provider,
|
||||||
hasProviderId: !!providerId,
|
hasProviderId: !!providerId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If a specific account row ID is provided, delete that exact account
|
// If a specific providerId is provided, delete only that account
|
||||||
if (accountId) {
|
if (providerId) {
|
||||||
await db
|
|
||||||
.delete(account)
|
|
||||||
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
|
|
||||||
} else if (providerId) {
|
|
||||||
// If a specific providerId is provided, delete accounts for that provider ID
|
|
||||||
await db
|
await db
|
||||||
.delete(account)
|
.delete(account)
|
||||||
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))
|
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))
|
||||||
|
|||||||
@@ -38,18 +38,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||||
const credential = await getCredential(
|
|
||||||
requestId,
|
|
||||||
resolvedCredentialId,
|
|
||||||
authz.credentialOwnerUserId
|
|
||||||
)
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await refreshAccessTokenIfNeeded(
|
const accessToken = await refreshAccessTokenIfNeeded(
|
||||||
resolvedCredentialId,
|
credentialId,
|
||||||
authz.credentialOwnerUserId,
|
authz.credentialOwnerUserId,
|
||||||
requestId
|
requestId
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,19 +37,14 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||||
const credential = await getCredential(
|
|
||||||
requestId,
|
|
||||||
resolvedCredentialId,
|
|
||||||
authz.credentialOwnerUserId
|
|
||||||
)
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token if needed using the utility function
|
// Refresh access token if needed using the utility function
|
||||||
const accessToken = await refreshAccessTokenIfNeeded(
|
const accessToken = await refreshAccessTokenIfNeeded(
|
||||||
resolvedCredentialId,
|
credentialId,
|
||||||
authz.credentialOwnerUserId,
|
authz.credentialOwnerUserId,
|
||||||
requestId
|
requestId
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,35 +110,23 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
|
|
||||||
|
|
||||||
const authz = await authorizeCredentialUse(request, {
|
const authz = await authorizeCredentialUse(request, {
|
||||||
credentialId,
|
credentialId,
|
||||||
workflowId: workflowId ?? undefined,
|
workflowId: workflowId ?? undefined,
|
||||||
requireWorkflowIdForInternal: false,
|
requireWorkflowIdForInternal: false,
|
||||||
callerUserId,
|
|
||||||
})
|
})
|
||||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||||
const credential = await getCredential(
|
|
||||||
requestId,
|
|
||||||
resolvedCredentialId,
|
|
||||||
authz.credentialOwnerUserId
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessToken } = await refreshTokenIfNeeded(
|
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||||
requestId,
|
|
||||||
credential,
|
|
||||||
resolvedCredentialId
|
|
||||||
)
|
|
||||||
|
|
||||||
let instanceUrl: string | undefined
|
let instanceUrl: string | undefined
|
||||||
if (credential.providerId === 'salesforce' && credential.scope) {
|
if (credential.providerId === 'salesforce' && credential.scope) {
|
||||||
@@ -198,20 +186,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const { credentialId } = parseResult.data
|
const { credentialId } = parseResult.data
|
||||||
|
|
||||||
const authz = await authorizeCredentialUse(request, {
|
// For GET requests, we only support session-based authentication
|
||||||
credentialId,
|
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||||
requireWorkflowIdForInternal: false,
|
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
|
||||||
})
|
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||||
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
|
|
||||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
const credential = await getCredential(requestId, credentialId, auth.userId)
|
||||||
const credential = await getCredential(
|
|
||||||
requestId,
|
|
||||||
resolvedCredentialId,
|
|
||||||
authz.credentialOwnerUserId
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||||
@@ -223,11 +204,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessToken } = await refreshTokenIfNeeded(
|
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||||
requestId,
|
|
||||||
credential,
|
|
||||||
resolvedCredentialId
|
|
||||||
)
|
|
||||||
|
|
||||||
// For Salesforce, extract instanceUrl from the scope field
|
// For Salesforce, extract instanceUrl from the scope field
|
||||||
let instanceUrl: string | undefined
|
let instanceUrl: string | undefined
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('OAuth Utils', () => {
|
|||||||
describe('getCredential', () => {
|
describe('getCredential', () => {
|
||||||
it('should return credential when found', async () => {
|
it('should return credential when found', async () => {
|
||||||
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
||||||
mockDbTyped.limit.mockReturnValueOnce([]).mockReturnValueOnce([mockCredential])
|
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||||
|
|
||||||
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
||||||
|
|
||||||
@@ -59,8 +59,7 @@ describe('OAuth Utils', () => {
|
|||||||
expect(mockDbTyped.where).toHaveBeenCalled()
|
expect(mockDbTyped.where).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
||||||
|
|
||||||
expect(credential).toMatchObject(mockCredential)
|
expect(credential).toEqual(mockCredential)
|
||||||
expect(credential).toMatchObject({ resolvedCredentialId: 'credential-id' })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return undefined when credential is not found', async () => {
|
it('should return undefined when credential is not found', async () => {
|
||||||
@@ -153,7 +152,7 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([]).mockReturnValueOnce([mockCredential])
|
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||||
|
|
||||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([]).mockReturnValueOnce([mockCredential])
|
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||||
|
|
||||||
mockRefreshOAuthToken.mockResolvedValueOnce({
|
mockRefreshOAuthToken.mockResolvedValueOnce({
|
||||||
accessToken: 'new-token',
|
accessToken: 'new-token',
|
||||||
@@ -203,7 +202,7 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([]).mockReturnValueOnce([mockCredential])
|
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
||||||
|
|
||||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { account, credential, credentialSetMember } from '@sim/db/schema'
|
import { account, credentialSetMember } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||||
import { refreshOAuthToken } from '@/lib/oauth'
|
import { refreshOAuthToken } from '@/lib/oauth'
|
||||||
@@ -25,28 +25,6 @@ interface AccountInsertData {
|
|||||||
accessTokenExpiresAt?: Date
|
accessTokenExpiresAt?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOAuthAccountId(
|
|
||||||
credentialId: string
|
|
||||||
): Promise<{ accountId: string; usedCredentialTable: boolean } | null> {
|
|
||||||
const [credentialRow] = await db
|
|
||||||
.select({
|
|
||||||
type: credential.type,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (credentialRow) {
|
|
||||||
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { accountId: credentialRow.accountId, usedCredentialTable: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accountId: credentialId, usedCredentialTable: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely inserts an account record, handling duplicate constraint violations gracefully.
|
* Safely inserts an account record, handling duplicate constraint violations gracefully.
|
||||||
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
|
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
|
||||||
@@ -74,16 +52,10 @@ export async function safeAccountInsert(
|
|||||||
* Get a credential by ID and verify it belongs to the user
|
* Get a credential by ID and verify it belongs to the user
|
||||||
*/
|
*/
|
||||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||||
const resolved = await resolveOAuthAccountId(credentialId)
|
|
||||||
if (!resolved) {
|
|
||||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await db
|
const credentials = await db
|
||||||
.select()
|
.select()
|
||||||
.from(account)
|
.from(account)
|
||||||
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
|
.where(and(eq(account.id, credentialId), eq(account.userId, userId)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!credentials.length) {
|
if (!credentials.length) {
|
||||||
@@ -91,10 +63,7 @@ export async function getCredential(requestId: string, credentialId: string, use
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return credentials[0]
|
||||||
...credentials[0],
|
|
||||||
resolvedCredentialId: resolved.accountId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
||||||
@@ -269,9 +238,7 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the token in the database
|
// Update the token in the database
|
||||||
const resolvedCredentialId =
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
|
|
||||||
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
|
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
|
||||||
return refreshedToken.accessToken
|
return refreshedToken.accessToken
|
||||||
@@ -307,8 +274,6 @@ export async function refreshTokenIfNeeded(
|
|||||||
credential: any,
|
credential: any,
|
||||||
credentialId: string
|
credentialId: string
|
||||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||||
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
|
|
||||||
|
|
||||||
// Decide if we should refresh: token missing OR expired
|
// Decide if we should refresh: token missing OR expired
|
||||||
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||||
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||||
@@ -369,7 +334,7 @@ export async function refreshTokenIfNeeded(
|
|||||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||||
return { accessToken: refreshedToken, refreshed: true }
|
return { accessToken: refreshedToken, refreshed: true }
|
||||||
@@ -378,7 +343,7 @@ export async function refreshTokenIfNeeded(
|
|||||||
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
|
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
|
||||||
)
|
)
|
||||||
|
|
||||||
const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
|
const freshCredential = await getCredential(requestId, credentialId, credential.userId)
|
||||||
if (freshCredential?.accessToken) {
|
if (freshCredential?.accessToken) {
|
||||||
const freshExpiresAt = freshCredential.accessTokenExpiresAt
|
const freshExpiresAt = freshCredential.accessTokenExpiresAt
|
||||||
const stillValid = !freshExpiresAt || freshExpiresAt > new Date()
|
const stillValid = !freshExpiresAt || freshExpiresAt > new Date()
|
||||||
|
|||||||
@@ -48,21 +48,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const shopData = await shopResponse.json()
|
const shopData = await shopResponse.json()
|
||||||
const shopInfo = shopData.shop
|
const shopInfo = shopData.shop
|
||||||
const stableAccountId = shopInfo.id?.toString() || shopDomain
|
|
||||||
|
|
||||||
const existing = await db.query.account.findFirst({
|
const existing = await db.query.account.findFirst({
|
||||||
where: and(
|
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')),
|
||||||
eq(account.userId, session.user.id),
|
|
||||||
eq(account.providerId, 'shopify'),
|
|
||||||
eq(account.accountId, stableAccountId)
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const accountData = {
|
const accountData = {
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
accountId: stableAccountId,
|
accountId: shopInfo.id?.toString() || shopDomain,
|
||||||
scope: scope || '',
|
scope: scope || '',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
idToken: shopDomain,
|
idToken: shopDomain,
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const trelloUser = await userResponse.json()
|
const trelloUser = await userResponse.json()
|
||||||
|
|
||||||
const existing = await db.query.account.findFirst({
|
const existing = await db.query.account.findFirst({
|
||||||
where: and(
|
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')),
|
||||||
eq(account.userId, session.user.id),
|
|
||||||
eq(account.providerId, 'trello'),
|
|
||||||
eq(account.accountId, trelloUser.id)
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const ChatMessageSchema = z.object({
|
|||||||
workflowId: z.string().optional(),
|
workflowId: z.string().optional(),
|
||||||
knowledgeId: z.string().optional(),
|
knowledgeId: z.string().optional(),
|
||||||
blockId: z.string().optional(),
|
blockId: z.string().optional(),
|
||||||
|
blockIds: z.array(z.string()).optional(),
|
||||||
templateId: z.string().optional(),
|
templateId: z.string().optional(),
|
||||||
executionId: z.string().optional(),
|
executionId: z.string().optional(),
|
||||||
// For workflow_block, provide both workflowId and blockId
|
// For workflow_block, provide both workflowId and blockId
|
||||||
@@ -159,6 +160,20 @@ export async function POST(req: NextRequest) {
|
|||||||
commands,
|
commands,
|
||||||
} = ChatMessageSchema.parse(body)
|
} = ChatMessageSchema.parse(body)
|
||||||
|
|
||||||
|
const normalizedContexts = Array.isArray(contexts)
|
||||||
|
? contexts.map((ctx) => {
|
||||||
|
if (ctx.kind !== 'blocks') return ctx
|
||||||
|
if (Array.isArray(ctx.blockIds) && ctx.blockIds.length > 0) return ctx
|
||||||
|
if (ctx.blockId) {
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
blockIds: [ctx.blockId],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
})
|
||||||
|
: contexts
|
||||||
|
|
||||||
// Resolve workflowId - if not provided, use first workflow or find by name
|
// Resolve workflowId - if not provided, use first workflow or find by name
|
||||||
const resolved = await resolveWorkflowIdForUser(
|
const resolved = await resolveWorkflowIdForUser(
|
||||||
authenticatedUserId,
|
authenticatedUserId,
|
||||||
@@ -176,10 +191,10 @@ export async function POST(req: NextRequest) {
|
|||||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||||
try {
|
try {
|
||||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||||
hasContexts: Array.isArray(contexts),
|
hasContexts: Array.isArray(normalizedContexts),
|
||||||
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
|
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||||
contextsPreview: Array.isArray(contexts)
|
contextsPreview: Array.isArray(normalizedContexts)
|
||||||
? contexts.map((c: any) => ({
|
? normalizedContexts.map((c: any) => ({
|
||||||
kind: c?.kind,
|
kind: c?.kind,
|
||||||
chatId: c?.chatId,
|
chatId: c?.chatId,
|
||||||
workflowId: c?.workflowId,
|
workflowId: c?.workflowId,
|
||||||
@@ -191,17 +206,25 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
// Preprocess contexts server-side
|
// Preprocess contexts server-side
|
||||||
let agentContexts: Array<{ type: string; content: string }> = []
|
let agentContexts: Array<{ type: string; content: string }> = []
|
||||||
if (Array.isArray(contexts) && contexts.length > 0) {
|
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
|
||||||
try {
|
try {
|
||||||
const { processContextsServer } = await import('@/lib/copilot/process-contents')
|
const { processContextsServer } = await import('@/lib/copilot/process-contents')
|
||||||
const processed = await processContextsServer(contexts as any, authenticatedUserId, message)
|
const processed = await processContextsServer(
|
||||||
|
normalizedContexts as any,
|
||||||
|
authenticatedUserId,
|
||||||
|
message
|
||||||
|
)
|
||||||
agentContexts = processed
|
agentContexts = processed
|
||||||
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
|
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
|
||||||
processedCount: agentContexts.length,
|
processedCount: agentContexts.length,
|
||||||
kinds: agentContexts.map((c) => c.type),
|
kinds: agentContexts.map((c) => c.type),
|
||||||
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
|
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
|
||||||
})
|
})
|
||||||
if (Array.isArray(contexts) && contexts.length > 0 && agentContexts.length === 0) {
|
if (
|
||||||
|
Array.isArray(normalizedContexts) &&
|
||||||
|
normalizedContexts.length > 0 &&
|
||||||
|
agentContexts.length === 0
|
||||||
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
|
`[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
|
||||||
)
|
)
|
||||||
@@ -246,11 +269,13 @@ export async function POST(req: NextRequest) {
|
|||||||
mode,
|
mode,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
provider,
|
provider,
|
||||||
|
conversationId: effectiveConversationId,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
contexts: agentContexts,
|
contexts: agentContexts,
|
||||||
fileAttachments,
|
fileAttachments,
|
||||||
commands,
|
commands,
|
||||||
chatId: actualChatId,
|
chatId: actualChatId,
|
||||||
|
prefetch,
|
||||||
implicitFeedback,
|
implicitFeedback,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -432,10 +457,15 @@ export async function POST(req: NextRequest) {
|
|||||||
content: message,
|
content: message,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
|
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
|
||||||
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
|
...(Array.isArray(normalizedContexts) &&
|
||||||
...(Array.isArray(contexts) &&
|
normalizedContexts.length > 0 && {
|
||||||
contexts.length > 0 && {
|
contexts: normalizedContexts,
|
||||||
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
|
}),
|
||||||
|
...(Array.isArray(normalizedContexts) &&
|
||||||
|
normalizedContexts.length > 0 && {
|
||||||
|
contentBlocks: [
|
||||||
|
{ type: 'contexts', contexts: normalizedContexts as any, timestamp: Date.now() },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { credential, credentialMember, user } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
|
|
||||||
const logger = createLogger('CredentialMembersAPI')
|
|
||||||
|
|
||||||
interface RouteContext {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requireAdminMembership(credentialId: string, userId: string) {
|
|
||||||
const [membership] = await db
|
|
||||||
.select({ role: credentialMember.role, status: credentialMember.status })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return membership
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: credentialId } = await context.params
|
|
||||||
|
|
||||||
const [cred] = await db
|
|
||||||
.select({ id: credential.id })
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!cred) {
|
|
||||||
return NextResponse.json({ members: [] }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = await db
|
|
||||||
.select({
|
|
||||||
id: credentialMember.id,
|
|
||||||
userId: credentialMember.userId,
|
|
||||||
role: credentialMember.role,
|
|
||||||
status: credentialMember.status,
|
|
||||||
joinedAt: credentialMember.joinedAt,
|
|
||||||
userName: user.name,
|
|
||||||
userEmail: user.email,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.innerJoin(user, eq(credentialMember.userId, user.id))
|
|
||||||
.where(eq(credentialMember.credentialId, credentialId))
|
|
||||||
|
|
||||||
return NextResponse.json({ members })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch credential members', { error })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addMemberSchema = z.object({
|
|
||||||
userId: z.string().min(1),
|
|
||||||
role: z.enum(['admin', 'member']).default('member'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest, context: RouteContext) {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: credentialId } = await context.params
|
|
||||||
|
|
||||||
const admin = await requireAdminMembership(credentialId, session.user.id)
|
|
||||||
if (!admin) {
|
|
||||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const parsed = addMemberSchema.safeParse(body)
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId, role } = parsed.data
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: credentialMember.id, status: credentialMember.status })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({ role, status: 'active', updatedAt: now })
|
|
||||||
.where(eq(credentialMember.id, existing.id))
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId,
|
|
||||||
role,
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: session.user.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 201 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to add credential member', { error })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest, context: RouteContext) {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: credentialId } = await context.params
|
|
||||||
const targetUserId = new URL(request.url).searchParams.get('userId')
|
|
||||||
if (!targetUserId) {
|
|
||||||
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = await requireAdminMembership(credentialId, session.user.id)
|
|
||||||
if (!admin) {
|
|
||||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const [target] = await db
|
|
||||||
.select({
|
|
||||||
id: credentialMember.id,
|
|
||||||
role: credentialMember.role,
|
|
||||||
status: credentialMember.status,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
eq(credentialMember.userId, targetUserId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.role === 'admin') {
|
|
||||||
const activeAdmins = await db
|
|
||||||
.select({ id: credentialMember.id })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
eq(credentialMember.role, 'admin'),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (activeAdmins.length <= 1) {
|
|
||||||
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({ status: 'revoked', updatedAt: new Date() })
|
|
||||||
.where(eq(credentialMember.id, target.id))
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to remove credential member', { error })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
|
||||||
import {
|
|
||||||
syncPersonalEnvCredentialsForUser,
|
|
||||||
syncWorkspaceEnvCredentials,
|
|
||||||
} from '@/lib/credentials/environment'
|
|
||||||
|
|
||||||
const logger = createLogger('CredentialByIdAPI')
|
|
||||||
|
|
||||||
const updateCredentialSchema = z
|
|
||||||
.object({
|
|
||||||
displayName: z.string().trim().min(1).max(255).optional(),
|
|
||||||
description: z.string().trim().max(500).nullish(),
|
|
||||||
accountId: z.string().trim().min(1).optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.refine(
|
|
||||||
(data) =>
|
|
||||||
data.displayName !== undefined ||
|
|
||||||
data.description !== undefined ||
|
|
||||||
data.accountId !== undefined,
|
|
||||||
{
|
|
||||||
message: 'At least one field must be provided',
|
|
||||||
path: ['displayName'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async function getCredentialResponse(credentialId: string, userId: string) {
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
type: credential.type,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
description: credential.description,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
envKey: credential.envKey,
|
|
||||||
envOwnerUserId: credential.envOwnerUserId,
|
|
||||||
createdBy: credential.createdBy,
|
|
||||||
createdAt: credential.createdAt,
|
|
||||||
updatedAt: credential.updatedAt,
|
|
||||||
role: credentialMember.role,
|
|
||||||
status: credentialMember.status,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.innerJoin(
|
|
||||||
credentialMember,
|
|
||||||
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
|
|
||||||
)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return row ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
try {
|
|
||||||
const access = await getCredentialActorContext(id, session.user.id)
|
|
||||||
if (!access.credential) {
|
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (!access.hasWorkspaceAccess || !access.member) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await getCredentialResponse(id, session.user.id)
|
|
||||||
return NextResponse.json({ credential: row }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch credential', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parseResult = updateCredentialSchema.safeParse(await request.json())
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const access = await getCredentialActorContext(id, session.user.id)
|
|
||||||
if (!access.credential) {
|
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
|
||||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (parseResult.data.description !== undefined) {
|
|
||||||
updates.description = parseResult.data.description ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
|
|
||||||
updates.displayName = parseResult.data.displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
if (access.credential.type === 'oauth') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'No updatable fields provided.',
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.updatedAt = new Date()
|
|
||||||
await db.update(credential).set(updates).where(eq(credential.id, id))
|
|
||||||
|
|
||||||
const row = await getCredentialResponse(id, session.user.id)
|
|
||||||
return NextResponse.json({ credential: row }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to update credential', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
try {
|
|
||||||
const access = await getCredentialActorContext(id, session.user.id)
|
|
||||||
if (!access.credential) {
|
|
||||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
|
||||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (access.credential.type === 'env_personal' && access.credential.envKey) {
|
|
||||||
const ownerUserId = access.credential.envOwnerUserId
|
|
||||||
if (!ownerUserId) {
|
|
||||||
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const [personalRow] = await db
|
|
||||||
.select({ variables: environment.variables })
|
|
||||||
.from(environment)
|
|
||||||
.where(eq(environment.userId, ownerUserId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
|
||||||
string,
|
|
||||||
string
|
|
||||||
>
|
|
||||||
if (access.credential.envKey in current) {
|
|
||||||
delete current[access.credential.envKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(environment)
|
|
||||||
.values({
|
|
||||||
id: ownerUserId,
|
|
||||||
userId: ownerUserId,
|
|
||||||
variables: current,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [environment.userId],
|
|
||||||
set: { variables: current, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
|
|
||||||
await syncPersonalEnvCredentialsForUser({
|
|
||||||
userId: ownerUserId,
|
|
||||||
envKeys: Object.keys(current),
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
|
|
||||||
const [workspaceRow] = await db
|
|
||||||
.select({
|
|
||||||
id: workspaceEnvironment.id,
|
|
||||||
createdAt: workspaceEnvironment.createdAt,
|
|
||||||
variables: workspaceEnvironment.variables,
|
|
||||||
})
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
|
||||||
string,
|
|
||||||
string
|
|
||||||
>
|
|
||||||
if (access.credential.envKey in current) {
|
|
||||||
delete current[access.credential.envKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(workspaceEnvironment)
|
|
||||||
.values({
|
|
||||||
id: workspaceRow?.id || crypto.randomUUID(),
|
|
||||||
workspaceId: access.credential.workspaceId,
|
|
||||||
variables: current,
|
|
||||||
createdAt: workspaceRow?.createdAt || new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [workspaceEnvironment.workspaceId],
|
|
||||||
set: { variables: current, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId: access.credential.workspaceId,
|
|
||||||
envKeys: Object.keys(current),
|
|
||||||
actingUserId: session.user.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(credential).where(eq(credential.id, id))
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to delete credential', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { pendingCredentialDraft } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq, lt } from 'drizzle-orm'
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
|
|
||||||
const logger = createLogger('CredentialDraftAPI')
|
|
||||||
|
|
||||||
const DRAFT_TTL_MS = 15 * 60 * 1000
|
|
||||||
|
|
||||||
const createDraftSchema = z.object({
|
|
||||||
workspaceId: z.string().min(1),
|
|
||||||
providerId: z.string().min(1),
|
|
||||||
displayName: z.string().min(1),
|
|
||||||
description: z.string().trim().max(500).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const parsed = createDraftSchema.safeParse(body)
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId, providerId, displayName, description } = parsed.data
|
|
||||||
const userId = session.user.id
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(pendingCredentialDraft)
|
|
||||||
.where(
|
|
||||||
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
|
|
||||||
)
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(pendingCredentialDraft)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
providerId,
|
|
||||||
displayName,
|
|
||||||
description: description || null,
|
|
||||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
pendingCredentialDraft.userId,
|
|
||||||
pendingCredentialDraft.providerId,
|
|
||||||
pendingCredentialDraft.workspaceId,
|
|
||||||
],
|
|
||||||
set: {
|
|
||||||
displayName,
|
|
||||||
description: description || null,
|
|
||||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('Credential draft saved', { userId, workspaceId, providerId, displayName })
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save credential draft', { error })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { credential, credentialMember } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
|
|
||||||
const logger = createLogger('CredentialMembershipsAPI')
|
|
||||||
|
|
||||||
const leaveCredentialSchema = z.object({
|
|
||||||
credentialId: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const memberships = await db
|
|
||||||
.select({
|
|
||||||
membershipId: credentialMember.id,
|
|
||||||
credentialId: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
type: credential.type,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
role: credentialMember.role,
|
|
||||||
status: credentialMember.status,
|
|
||||||
joinedAt: credentialMember.joinedAt,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
|
|
||||||
.where(eq(credentialMember.userId, session.user.id))
|
|
||||||
|
|
||||||
return NextResponse.json({ memberships }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to list credential memberships', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parseResult = leaveCredentialSchema.safeParse({
|
|
||||||
credentialId: new URL(request.url).searchParams.get('credentialId'),
|
|
||||||
})
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { credentialId } = parseResult.data
|
|
||||||
const [membership] = await db
|
|
||||||
.select()
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
eq(credentialMember.userId, session.user.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.status !== 'active') {
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (membership.role === 'admin') {
|
|
||||||
const activeAdmins = await db
|
|
||||||
.select({ id: credentialMember.id })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
eq(credentialMember.role, 'admin'),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (activeAdmins.length <= 1) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Cannot leave credential as the last active admin' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({
|
|
||||||
status: 'revoked',
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(credentialMember.id, membership.id))
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to leave credential', error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
|
||||||
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
|
|
||||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
|
||||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
|
||||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
||||||
import { isValidEnvVarName } from '@/executor/constants'
|
|
||||||
|
|
||||||
const logger = createLogger('CredentialsAPI')
|
|
||||||
|
|
||||||
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
|
|
||||||
|
|
||||||
function normalizeEnvKeyInput(raw: string): string {
|
|
||||||
const trimmed = raw.trim()
|
|
||||||
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
|
|
||||||
return wrappedMatch ? wrappedMatch[1] : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
const listCredentialsSchema = z.object({
|
|
||||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
|
||||||
type: credentialTypeSchema.optional(),
|
|
||||||
providerId: z.string().optional(),
|
|
||||||
credentialId: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createCredentialSchema = z
|
|
||||||
.object({
|
|
||||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
|
||||||
type: credentialTypeSchema,
|
|
||||||
displayName: z.string().trim().min(1).max(255).optional(),
|
|
||||||
description: z.string().trim().max(500).optional(),
|
|
||||||
providerId: z.string().trim().min(1).optional(),
|
|
||||||
accountId: z.string().trim().min(1).optional(),
|
|
||||||
envKey: z.string().trim().min(1).optional(),
|
|
||||||
envOwnerUserId: z.string().trim().min(1).optional(),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
if (data.type === 'oauth') {
|
|
||||||
if (!data.accountId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'accountId is required for oauth credentials',
|
|
||||||
path: ['accountId'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!data.providerId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'providerId is required for oauth credentials',
|
|
||||||
path: ['providerId'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!data.displayName) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'displayName is required for oauth credentials',
|
|
||||||
path: ['displayName'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
|
|
||||||
if (!normalizedEnvKey) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'envKey is required for env credentials',
|
|
||||||
path: ['envKey'],
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidEnvVarName(normalizedEnvKey)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'envKey must contain only letters, numbers, and underscores',
|
|
||||||
path: ['envKey'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
interface ExistingCredentialSourceParams {
|
|
||||||
workspaceId: string
|
|
||||||
type: 'oauth' | 'env_workspace' | 'env_personal'
|
|
||||||
accountId?: string | null
|
|
||||||
envKey?: string | null
|
|
||||||
envOwnerUserId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
|
|
||||||
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
|
|
||||||
|
|
||||||
if (type === 'oauth' && accountId) {
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'oauth'),
|
|
||||||
eq(credential.accountId, accountId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
return row ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'env_workspace' && envKey) {
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_workspace'),
|
|
||||||
eq(credential.envKey, envKey)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
return row ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'env_personal' && envKey && envOwnerUserId) {
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_personal'),
|
|
||||||
eq(credential.envKey, envKey),
|
|
||||||
eq(credential.envOwnerUserId, envOwnerUserId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
return row ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const requestId = generateRequestId()
|
|
||||||
const session = await getSession()
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const rawWorkspaceId = searchParams.get('workspaceId')
|
|
||||||
const rawType = searchParams.get('type')
|
|
||||||
const rawProviderId = searchParams.get('providerId')
|
|
||||||
const rawCredentialId = searchParams.get('credentialId')
|
|
||||||
const parseResult = listCredentialsSchema.safeParse({
|
|
||||||
workspaceId: rawWorkspaceId?.trim(),
|
|
||||||
type: rawType?.trim() || undefined,
|
|
||||||
providerId: rawProviderId?.trim() || undefined,
|
|
||||||
credentialId: rawCredentialId?.trim() || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
logger.warn(`[${requestId}] Invalid credential list request`, {
|
|
||||||
workspaceId: rawWorkspaceId,
|
|
||||||
type: rawType,
|
|
||||||
providerId: rawProviderId,
|
|
||||||
errors: parseResult.error.errors,
|
|
||||||
})
|
|
||||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
|
||||||
|
|
||||||
if (!workspaceAccess.hasAccess) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lookupCredentialId) {
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
type: credential.type,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return NextResponse.json({ credential: row ?? null })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!type || type === 'oauth') {
|
|
||||||
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClauses = [
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credentialMember.userId, session.user.id),
|
|
||||||
eq(credentialMember.status, 'active'),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (type) {
|
|
||||||
whereClauses.push(eq(credential.type, type))
|
|
||||||
}
|
|
||||||
if (providerId) {
|
|
||||||
whereClauses.push(eq(credential.providerId, providerId))
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
type: credential.type,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
description: credential.description,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
envKey: credential.envKey,
|
|
||||||
envOwnerUserId: credential.envOwnerUserId,
|
|
||||||
createdBy: credential.createdBy,
|
|
||||||
createdAt: credential.createdAt,
|
|
||||||
updatedAt: credential.updatedAt,
|
|
||||||
role: credentialMember.role,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.innerJoin(
|
|
||||||
credentialMember,
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credential.id),
|
|
||||||
eq(credentialMember.userId, session.user.id),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(and(...whereClauses))
|
|
||||||
|
|
||||||
return NextResponse.json({ credentials })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[${requestId}] Failed to list credentials`, error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const requestId = generateRequestId()
|
|
||||||
const session = await getSession()
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const parseResult = createCredentialSchema.safeParse(body)
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
displayName,
|
|
||||||
description,
|
|
||||||
providerId,
|
|
||||||
accountId,
|
|
||||||
envKey,
|
|
||||||
envOwnerUserId,
|
|
||||||
} = parseResult.data
|
|
||||||
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
|
||||||
if (!workspaceAccess.canWrite) {
|
|
||||||
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedDisplayName = displayName?.trim() ?? ''
|
|
||||||
const resolvedDescription = description?.trim() || null
|
|
||||||
let resolvedProviderId: string | null = providerId ?? null
|
|
||||||
let resolvedAccountId: string | null = accountId ?? null
|
|
||||||
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
|
|
||||||
let resolvedEnvOwnerUserId: string | null = null
|
|
||||||
|
|
||||||
if (type === 'oauth') {
|
|
||||||
const [accountRow] = await db
|
|
||||||
.select({
|
|
||||||
id: account.id,
|
|
||||||
userId: account.userId,
|
|
||||||
providerId: account.providerId,
|
|
||||||
accountId: account.accountId,
|
|
||||||
})
|
|
||||||
.from(account)
|
|
||||||
.where(eq(account.id, accountId!))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!accountRow) {
|
|
||||||
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountRow.userId !== session.user.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Only account owners can create oauth credentials for an account' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerId !== accountRow.providerId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'providerId does not match the selected OAuth account' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!resolvedDisplayName) {
|
|
||||||
resolvedDisplayName =
|
|
||||||
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
|
|
||||||
}
|
|
||||||
} else if (type === 'env_personal') {
|
|
||||||
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
|
|
||||||
if (resolvedEnvOwnerUserId !== session.user.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Only the current user can create personal env credentials for themselves' },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
resolvedProviderId = null
|
|
||||||
resolvedAccountId = null
|
|
||||||
resolvedDisplayName = resolvedEnvKey || ''
|
|
||||||
} else {
|
|
||||||
resolvedProviderId = null
|
|
||||||
resolvedAccountId = null
|
|
||||||
resolvedEnvOwnerUserId = null
|
|
||||||
resolvedDisplayName = resolvedEnvKey || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedDisplayName) {
|
|
||||||
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingCredential = await findExistingCredentialBySource({
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
accountId: resolvedAccountId,
|
|
||||||
envKey: resolvedEnvKey,
|
|
||||||
envOwnerUserId: resolvedEnvOwnerUserId,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingCredential) {
|
|
||||||
const [membership] = await db
|
|
||||||
.select({
|
|
||||||
id: credentialMember.id,
|
|
||||||
status: credentialMember.status,
|
|
||||||
role: credentialMember.role,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, existingCredential.id),
|
|
||||||
eq(credentialMember.userId, session.user.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership || membership.status !== 'active') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'A credential with this source already exists in this workspace' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canUpdateExistingCredential = membership.role === 'admin'
|
|
||||||
const shouldUpdateDisplayName =
|
|
||||||
type === 'oauth' &&
|
|
||||||
resolvedDisplayName &&
|
|
||||||
resolvedDisplayName !== existingCredential.displayName
|
|
||||||
const shouldUpdateDescription =
|
|
||||||
typeof description !== 'undefined' &&
|
|
||||||
(existingCredential.description ?? null) !== resolvedDescription
|
|
||||||
|
|
||||||
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
|
|
||||||
await db
|
|
||||||
.update(credential)
|
|
||||||
.set({
|
|
||||||
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
|
|
||||||
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(credential.id, existingCredential.id))
|
|
||||||
|
|
||||||
const [updatedCredential] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, existingCredential.id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ credential: updatedCredential ?? existingCredential },
|
|
||||||
{ status: 200 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ credential: existingCredential }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const credentialId = crypto.randomUUID()
|
|
||||||
const [workspaceRow] = await db
|
|
||||||
.select({ ownerId: workspace.ownerId })
|
|
||||||
.from(workspace)
|
|
||||||
.where(eq(workspace.id, workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx.insert(credential).values({
|
|
||||||
id: credentialId,
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
displayName: resolvedDisplayName,
|
|
||||||
description: resolvedDescription,
|
|
||||||
providerId: resolvedProviderId,
|
|
||||||
accountId: resolvedAccountId,
|
|
||||||
envKey: resolvedEnvKey,
|
|
||||||
envOwnerUserId: resolvedEnvOwnerUserId,
|
|
||||||
createdBy: session.user.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (type === 'env_workspace' && workspaceRow?.ownerId) {
|
|
||||||
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
|
|
||||||
if (workspaceUserIds.length > 0) {
|
|
||||||
for (const memberUserId of workspaceUserIds) {
|
|
||||||
await tx.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId: memberUserId,
|
|
||||||
role: memberUserId === workspaceRow.ownerId ? 'admin' : 'member',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: session.user.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await tx.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId: session.user.id,
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: session.user.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const [created] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return NextResponse.json({ credential: created }, { status: 201 })
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.code === '23505') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'A credential with this source already exists' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (error?.code === '23503') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid credential reference or membership target' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (error?.code === '23514') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Credential source data failed validation checks' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
logger.error(`[${requestId}] Credential create failure details`, {
|
|
||||||
code: error?.code,
|
|
||||||
detail: error?.detail,
|
|
||||||
constraint: error?.constraint,
|
|
||||||
table: error?.table,
|
|
||||||
message: error?.message,
|
|
||||||
})
|
|
||||||
logger.error(`[${requestId}] Failed to create credential`, error)
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
|
|
||||||
import type { EnvironmentVariable } from '@/stores/settings/environment'
|
import type { EnvironmentVariable } from '@/stores/settings/environment'
|
||||||
|
|
||||||
const logger = createLogger('EnvironmentAPI')
|
const logger = createLogger('EnvironmentAPI')
|
||||||
@@ -54,11 +53,6 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await syncPersonalEnvCredentialsForUser({
|
|
||||||
userId: session.user.id,
|
|
||||||
envKeys: Object.keys(variables),
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
if (validationError instanceof z.ZodError) {
|
if (validationError instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
user,
|
user,
|
||||||
userStats,
|
userStats,
|
||||||
type WorkspaceInvitationStatus,
|
type WorkspaceInvitationStatus,
|
||||||
workspaceEnvironment,
|
|
||||||
workspaceInvitation,
|
workspaceInvitation,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
@@ -24,7 +23,6 @@ import { hasAccessControlAccess } from '@/lib/billing'
|
|||||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||||
|
|
||||||
const logger = createLogger('OrganizationInvitation')
|
const logger = createLogger('OrganizationInvitation')
|
||||||
@@ -497,34 +495,6 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (status === 'accepted') {
|
|
||||||
const acceptedWsInvitations = await db
|
|
||||||
.select({ workspaceId: workspaceInvitation.workspaceId })
|
|
||||||
.from(workspaceInvitation)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
|
||||||
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const wsInv of acceptedWsInvitations) {
|
|
||||||
const [wsEnvRow] = await db
|
|
||||||
.select({ variables: workspaceEnvironment.variables })
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
|
||||||
if (wsEnvKeys.length > 0) {
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId: wsInv.workspaceId,
|
|
||||||
envKeys: wsEnvKeys,
|
|
||||||
actingUserId: session.user.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Pro subscription cancellation after transaction commits
|
// Handle Pro subscription cancellation after transaction commits
|
||||||
if (personalProToCancel) {
|
if (personalProToCancel) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,6 +38,45 @@ const createBlogPostSchema = z.object({
|
|||||||
status: z.enum(['current', 'draft']).optional(),
|
status: z.enum(['current', 'draft']).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateBlogPostSchema = z
|
||||||
|
.object({
|
||||||
|
domain: z.string().min(1, 'Domain is required'),
|
||||||
|
accessToken: z.string().min(1, 'Access token is required'),
|
||||||
|
cloudId: z.string().optional(),
|
||||||
|
blogPostId: z.string().min(1, 'Blog post ID is required'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
status: z.enum(['current', 'draft']).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||||
|
return validation.isValid
|
||||||
|
},
|
||||||
|
(data) => {
|
||||||
|
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||||
|
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteBlogPostSchema = z
|
||||||
|
.object({
|
||||||
|
domain: z.string().min(1, 'Domain is required'),
|
||||||
|
accessToken: z.string().min(1, 'Access token is required'),
|
||||||
|
cloudId: z.string().optional(),
|
||||||
|
blogPostId: z.string().min(1, 'Blog post ID is required'),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||||
|
return validation.isValid
|
||||||
|
},
|
||||||
|
(data) => {
|
||||||
|
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||||
|
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all blog posts or get a specific blog post
|
* List all blog posts or get a specific blog post
|
||||||
*/
|
*/
|
||||||
@@ -283,3 +322,174 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a blog post
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await checkSessionOrInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const validation = updateBlogPostSchema.safeParse(body)
|
||||||
|
if (!validation.success) {
|
||||||
|
const firstError = validation.error.errors[0]
|
||||||
|
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: providedCloudId,
|
||||||
|
blogPostId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
status,
|
||||||
|
} = validation.data
|
||||||
|
|
||||||
|
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogPostUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
|
||||||
|
|
||||||
|
const currentResponse = await fetch(blogPostUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentResponse.ok) {
|
||||||
|
const errorData = await currentResponse.json().catch(() => null)
|
||||||
|
const errorMessage =
|
||||||
|
errorData?.message || `Failed to fetch blog post for update (${currentResponse.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: currentResponse.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPost = await currentResponse.json()
|
||||||
|
const currentVersion = currentPost.version.number
|
||||||
|
|
||||||
|
const updateBody: Record<string, unknown> = {
|
||||||
|
id: blogPostId,
|
||||||
|
version: {
|
||||||
|
number: currentVersion + 1,
|
||||||
|
message: 'Updated via Sim',
|
||||||
|
},
|
||||||
|
status: status || currentPost.status || 'current',
|
||||||
|
title: title || currentPost.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
updateBody.body = {
|
||||||
|
representation: 'storage',
|
||||||
|
value: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(blogPostUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
logger.error('Confluence API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: JSON.stringify(errorData, null, 2),
|
||||||
|
})
|
||||||
|
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json({
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
status: data.status ?? null,
|
||||||
|
spaceId: data.spaceId ?? null,
|
||||||
|
authorId: data.authorId ?? null,
|
||||||
|
createdAt: data.createdAt ?? null,
|
||||||
|
version: data.version ?? null,
|
||||||
|
body: data.body ?? null,
|
||||||
|
webUrl: data._links?.webui ?? null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating blog post:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a blog post
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await checkSessionOrInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const validation = deleteBlogPostSchema.safeParse(body)
|
||||||
|
if (!validation.success) {
|
||||||
|
const firstError = validation.error.errors[0]
|
||||||
|
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { domain, accessToken, cloudId: providedCloudId, blogPostId } = validation.data
|
||||||
|
|
||||||
|
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
logger.error('Confluence API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: JSON.stringify(errorData, null, 2),
|
||||||
|
})
|
||||||
|
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ blogPostId, deleted: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting blog post:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,3 +191,84 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete a label from a page
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await checkSessionOrInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: providedCloudId,
|
||||||
|
pageId,
|
||||||
|
labelName,
|
||||||
|
} = await request.json()
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageId) {
|
||||||
|
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labelName) {
|
||||||
|
return NextResponse.json({ error: 'Label name is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||||
|
if (!pageIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedLabel = encodeURIComponent(labelName.trim())
|
||||||
|
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/label?name=${encodedLabel}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
logger.error('Confluence API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: JSON.stringify(errorData, null, 2),
|
||||||
|
})
|
||||||
|
const errorMessage =
|
||||||
|
errorData?.message || `Failed to delete Confluence label (${response.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pageId,
|
||||||
|
labelName,
|
||||||
|
deleted: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting Confluence label:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
103
apps/sim/app/api/tools/confluence/pages-by-label/route.ts
Normal file
103
apps/sim/app/api/tools/confluence/pages-by-label/route.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
|
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('ConfluencePagesByLabelAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await checkSessionOrInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const domain = searchParams.get('domain')
|
||||||
|
const accessToken = searchParams.get('accessToken')
|
||||||
|
const labelId = searchParams.get('labelId')
|
||||||
|
const providedCloudId = searchParams.get('cloudId')
|
||||||
|
const limit = searchParams.get('limit') || '50'
|
||||||
|
const cursor = searchParams.get('cursor')
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labelId) {
|
||||||
|
return NextResponse.json({ error: 'Label ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
|
||||||
|
if (!labelIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: labelIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||||
|
if (cursor) {
|
||||||
|
queryParams.append('cursor', cursor)
|
||||||
|
}
|
||||||
|
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/labels/${labelId}/pages?${queryParams.toString()}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
logger.error('Confluence API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: JSON.stringify(errorData, null, 2),
|
||||||
|
})
|
||||||
|
const errorMessage = errorData?.message || `Failed to get pages by label (${response.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const pages = (data.results || []).map((page: any) => ({
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
status: page.status ?? null,
|
||||||
|
spaceId: page.spaceId ?? null,
|
||||||
|
parentId: page.parentId ?? null,
|
||||||
|
authorId: page.authorId ?? null,
|
||||||
|
createdAt: page.createdAt ?? null,
|
||||||
|
version: page.version ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pages,
|
||||||
|
labelId,
|
||||||
|
nextCursor: data._links?.next
|
||||||
|
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting pages by label:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/sim/app/api/tools/confluence/space-labels/route.ts
Normal file
98
apps/sim/app/api/tools/confluence/space-labels/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
|
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('ConfluenceSpaceLabelsAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await checkSessionOrInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const domain = searchParams.get('domain')
|
||||||
|
const accessToken = searchParams.get('accessToken')
|
||||||
|
const spaceId = searchParams.get('spaceId')
|
||||||
|
const providedCloudId = searchParams.get('cloudId')
|
||||||
|
const limit = searchParams.get('limit') || '25'
|
||||||
|
const cursor = searchParams.get('cursor')
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spaceId) {
|
||||||
|
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
|
||||||
|
if (!spaceIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||||
|
if (cursor) {
|
||||||
|
queryParams.append('cursor', cursor)
|
||||||
|
}
|
||||||
|
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/labels?${queryParams.toString()}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
logger.error('Confluence API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: JSON.stringify(errorData, null, 2),
|
||||||
|
})
|
||||||
|
const errorMessage = errorData?.message || `Failed to list space labels (${response.status})`
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const labels = (data.results || []).map((label: any) => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
prefix: label.prefix || 'global',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
labels,
|
||||||
|
spaceId,
|
||||||
|
nextCursor: data._links?.next
|
||||||
|
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error listing space labels:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
apps/sim/app/api/tools/jsm/attachments/route.ts
Normal file
152
apps/sim/app/api/tools/jsm/attachments/route.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
|
||||||
|
import {
|
||||||
|
downloadJsmAttachments,
|
||||||
|
getJiraCloudId,
|
||||||
|
getJsmApiBaseUrl,
|
||||||
|
getJsmHeaders,
|
||||||
|
} from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmAttachmentsAPI')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
issueIdOrKey,
|
||||||
|
includeAttachments,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issueIdOrKey) {
|
||||||
|
logger.error('Missing issueIdOrKey in request')
|
||||||
|
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
||||||
|
if (!issueIdOrKeyValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.append('start', start)
|
||||||
|
if (limit) params.append('limit', limit)
|
||||||
|
|
||||||
|
const url = `${baseUrl}/request/${issueIdOrKey}/attachment${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
|
|
||||||
|
logger.info('Fetching request attachments from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const rawAttachments = data.values || []
|
||||||
|
|
||||||
|
const attachments = rawAttachments.map((att: Record<string, unknown>) => ({
|
||||||
|
filename: att.filename ?? '',
|
||||||
|
author: att.author
|
||||||
|
? {
|
||||||
|
accountId: (att.author as Record<string, unknown>).accountId ?? '',
|
||||||
|
displayName: (att.author as Record<string, unknown>).displayName ?? '',
|
||||||
|
active: (att.author as Record<string, unknown>).active ?? true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
created: att.created ?? null,
|
||||||
|
size: att.size ?? 0,
|
||||||
|
mimeType: att.mimeType ?? '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
|
||||||
|
|
||||||
|
if (includeAttachments && rawAttachments.length > 0) {
|
||||||
|
const downloadable = rawAttachments
|
||||||
|
.filter((att: Record<string, unknown>) => {
|
||||||
|
const links = att._links as Record<string, string> | undefined
|
||||||
|
return links?.content
|
||||||
|
})
|
||||||
|
.map((att: Record<string, unknown>) => ({
|
||||||
|
contentUrl: (att._links as Record<string, string>).content as string,
|
||||||
|
filename: (att.filename as string) ?? '',
|
||||||
|
mimeType: (att.mimeType as string) ?? '',
|
||||||
|
size: (att.size as number) ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (downloadable.length > 0) {
|
||||||
|
files = await downloadJsmAttachments(downloadable, accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
attachments,
|
||||||
|
total: data.size || 0,
|
||||||
|
isLastPage: data.isLastPage ?? true,
|
||||||
|
...(files && files.length > 0 ? { files } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching attachments:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/sim/app/api/tools/jsm/customer/route.ts
Normal file
101
apps/sim/app/api/tools/jsm/customer/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmCustomerAPI')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { domain, accessToken, cloudId: cloudIdParam, email, displayName } = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
logger.error('Missing email in request')
|
||||||
|
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayName) {
|
||||||
|
logger.error('Missing displayName in request')
|
||||||
|
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const url = `${baseUrl}/customer`
|
||||||
|
|
||||||
|
logger.info('Creating customer:', { email, displayName })
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
body: JSON.stringify({ email, displayName }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
accountId: data.accountId ?? '',
|
||||||
|
displayName: data.displayName ?? '',
|
||||||
|
emailAddress: data.emailAddress ?? '',
|
||||||
|
active: data.active ?? true,
|
||||||
|
timeZone: data.timeZone ?? null,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating customer:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
|
||||||
|
const { action: customerAction } = body
|
||||||
|
|
||||||
const rawIds = accountIds || emails
|
const rawIds = accountIds || emails
|
||||||
const parsedAccountIds = rawIds
|
const parsedAccountIds = rawIds
|
||||||
? typeof rawIds === 'string'
|
? typeof rawIds === 'string'
|
||||||
@@ -69,7 +71,50 @@ export async function POST(request: NextRequest) {
|
|||||||
: []
|
: []
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const isAddOperation = parsedAccountIds.length > 0
|
const isRemoveOperation = customerAction === 'remove'
|
||||||
|
const isAddOperation = !isRemoveOperation && parsedAccountIds.length > 0
|
||||||
|
|
||||||
|
if (isRemoveOperation) {
|
||||||
|
if (parsedAccountIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account IDs or emails are required for removal' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
||||||
|
|
||||||
|
logger.info('Removing customers from:', url, { accountIds: parsedAccountIds })
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
serviceDeskId,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isAddOperation) {
|
if (isAddOperation) {
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
||||||
|
|||||||
219
apps/sim/app/api/tools/jsm/feedback/route.ts
Normal file
219
apps/sim/app/api/tools/jsm/feedback/route.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import {
|
||||||
|
validateEnum,
|
||||||
|
validateJiraCloudId,
|
||||||
|
validateJiraIssueKey,
|
||||||
|
} from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmFeedbackAPI')
|
||||||
|
|
||||||
|
const VALID_ACTIONS = ['get', 'add', 'delete'] as const
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
action,
|
||||||
|
issueIdOrKey,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issueIdOrKey) {
|
||||||
|
logger.error('Missing issueIdOrKey in request')
|
||||||
|
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
logger.error('Missing action in request')
|
||||||
|
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
||||||
|
if (!actionValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
||||||
|
if (!issueIdOrKeyValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const url = `${baseUrl}/request/${issueIdOrKey}/feedback`
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
logger.info('Fetching feedback from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
rating: data.rating ?? null,
|
||||||
|
comment: data.comment?.body ?? null,
|
||||||
|
type: data.type ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'add') {
|
||||||
|
if (rating === undefined || rating === null) {
|
||||||
|
logger.error('Missing rating in request')
|
||||||
|
return NextResponse.json({ error: 'Rating is required (1-5)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Adding feedback to:', url, { rating })
|
||||||
|
|
||||||
|
const feedbackBody: Record<string, unknown> = {
|
||||||
|
rating: Number(rating),
|
||||||
|
type: 'csat',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment) {
|
||||||
|
feedbackBody.comment = { body: comment }
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
body: JSON.stringify(feedbackBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
rating: data.rating ?? Number(rating),
|
||||||
|
comment: data.comment?.body ?? comment ?? null,
|
||||||
|
type: data.type ?? 'csat',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
logger.info('Deleting feedback from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in feedback operation:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
127
apps/sim/app/api/tools/jsm/knowledgebase/route.ts
Normal file
127
apps/sim/app/api/tools/jsm/knowledgebase/route.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmKnowledgeBaseAPI')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
serviceDeskId,
|
||||||
|
query,
|
||||||
|
highlight,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
logger.error('Missing query in request')
|
||||||
|
return NextResponse.json({ error: 'Search query is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceDeskId) {
|
||||||
|
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
||||||
|
if (!serviceDeskIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('query', query)
|
||||||
|
if (highlight !== undefined) params.append('highlight', String(highlight))
|
||||||
|
if (start) params.append('start', start)
|
||||||
|
if (limit) params.append('limit', limit)
|
||||||
|
|
||||||
|
const basePath = serviceDeskId
|
||||||
|
? `${baseUrl}/servicedesk/${serviceDeskId}/knowledgebase/article`
|
||||||
|
: `${baseUrl}/knowledgebase/article`
|
||||||
|
|
||||||
|
const url = `${basePath}?${params.toString()}`
|
||||||
|
|
||||||
|
logger.info('Searching knowledge base:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const articles = (data.values || []).map((article: Record<string, unknown>) => ({
|
||||||
|
title: (article.title as string) ?? '',
|
||||||
|
excerpt: (article.excerpt as string) ?? '',
|
||||||
|
sourceType: (article.source as Record<string, unknown>)?.type ?? '',
|
||||||
|
sourcePageId: (article.source as Record<string, unknown>)?.pageId ?? null,
|
||||||
|
sourceSpaceKey: (article.source as Record<string, unknown>)?.spaceKey ?? null,
|
||||||
|
contentUrl: (article.content as Record<string, unknown>)?.iframeSrc ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
articles,
|
||||||
|
total: data.size || 0,
|
||||||
|
isLastPage: data.isLastPage ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error searching knowledge base:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
apps/sim/app/api/tools/jsm/notification/route.ts
Normal file
189
apps/sim/app/api/tools/jsm/notification/route.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import {
|
||||||
|
validateEnum,
|
||||||
|
validateJiraCloudId,
|
||||||
|
validateJiraIssueKey,
|
||||||
|
} from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmNotificationAPI')
|
||||||
|
|
||||||
|
const VALID_ACTIONS = ['get', 'subscribe', 'unsubscribe'] as const
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { domain, accessToken, cloudId: cloudIdParam, action, issueIdOrKey } = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issueIdOrKey) {
|
||||||
|
logger.error('Missing issueIdOrKey in request')
|
||||||
|
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
logger.error('Missing action in request')
|
||||||
|
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
||||||
|
if (!actionValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
||||||
|
if (!issueIdOrKeyValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const url = `${baseUrl}/request/${issueIdOrKey}/notification`
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
logger.info('Fetching notification status from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
subscribed: data.subscribed ?? false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'subscribe') {
|
||||||
|
logger.info('Subscribing to notifications:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'unsubscribe') {
|
||||||
|
logger.info('Unsubscribing from notifications:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in notification operation:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,13 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
const logger = createLogger('JsmOrganizationAPI')
|
const logger = createLogger('JsmOrganizationAPI')
|
||||||
|
|
||||||
const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const
|
const VALID_ACTIONS = [
|
||||||
|
'create',
|
||||||
|
'add_to_service_desk',
|
||||||
|
'remove_from_service_desk',
|
||||||
|
'delete',
|
||||||
|
'get',
|
||||||
|
] as const
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const auth = await checkInternalAuth(request)
|
const auth = await checkInternalAuth(request)
|
||||||
@@ -159,6 +165,152 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'remove_from_service_desk') {
|
||||||
|
if (!serviceDeskId) {
|
||||||
|
logger.error('Missing serviceDeskId in request')
|
||||||
|
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
logger.error('Missing organizationId in request')
|
||||||
|
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
||||||
|
if (!serviceDeskIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
||||||
|
if (!organizationIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}/organization`
|
||||||
|
|
||||||
|
logger.info('Removing organization from service desk:', { serviceDeskId, organizationId })
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
body: JSON.stringify({ organizationId: Number.parseInt(organizationId, 10) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
serviceDeskId,
|
||||||
|
organizationId,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
if (!organizationId) {
|
||||||
|
logger.error('Missing organizationId in request')
|
||||||
|
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
||||||
|
if (!organizationIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/organization/${organizationId}`
|
||||||
|
|
||||||
|
logger.info('Deleting organization:', { organizationId })
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
organizationId,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
if (!organizationId) {
|
||||||
|
logger.error('Missing organizationId in request')
|
||||||
|
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
||||||
|
if (!organizationIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/organization/${organizationId}`
|
||||||
|
|
||||||
|
logger.info('Fetching organization:', { organizationId })
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
id: data.id ?? '',
|
||||||
|
name: data.name ?? '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in organization operation:', {
|
logger.error('Error in organization operation:', {
|
||||||
|
|||||||
190
apps/sim/app/api/tools/jsm/organizationusers/route.ts
Normal file
190
apps/sim/app/api/tools/jsm/organizationusers/route.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import {
|
||||||
|
validateAlphanumericId,
|
||||||
|
validateEnum,
|
||||||
|
validateJiraCloudId,
|
||||||
|
} from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmOrganizationUsersAPI')
|
||||||
|
|
||||||
|
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
action,
|
||||||
|
organizationId,
|
||||||
|
accountIds,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
logger.error('Missing organizationId in request')
|
||||||
|
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
logger.error('Missing action in request')
|
||||||
|
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
||||||
|
if (!actionValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
||||||
|
if (!organizationIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const url = `${baseUrl}/organization/${organizationId}/user`
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.append('start', start)
|
||||||
|
if (limit) params.append('limit', limit)
|
||||||
|
|
||||||
|
const getUrl = `${url}${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
|
|
||||||
|
logger.info('Fetching organization users from:', getUrl)
|
||||||
|
|
||||||
|
const response = await fetch(getUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
organizationId,
|
||||||
|
users: data.values || [],
|
||||||
|
total: data.size || 0,
|
||||||
|
isLastPage: data.isLastPage ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'add' || action === 'remove') {
|
||||||
|
if (!accountIds) {
|
||||||
|
logger.error('Missing accountIds in request')
|
||||||
|
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAccountIds =
|
||||||
|
typeof accountIds === 'string'
|
||||||
|
? accountIds
|
||||||
|
.split(',')
|
||||||
|
.map((id: string) => id.trim())
|
||||||
|
.filter((id: string) => id)
|
||||||
|
: accountIds
|
||||||
|
|
||||||
|
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} organization users:`, {
|
||||||
|
organizationId,
|
||||||
|
accountIds: parsedAccountIds,
|
||||||
|
})
|
||||||
|
|
||||||
|
const method = action === 'add' ? 'POST' : 'DELETE'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
organizationId,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `JSM API error: ${response.status} ${response.statusText}`,
|
||||||
|
details: errorText,
|
||||||
|
},
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in organization users operation:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
const logger = createLogger('JsmParticipantsAPI')
|
const logger = createLogger('JsmParticipantsAPI')
|
||||||
|
|
||||||
const VALID_ACTIONS = ['get', 'add'] as const
|
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const auth = await checkInternalAuth(request)
|
const auth = await checkInternalAuth(request)
|
||||||
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (action === 'add') {
|
if (action === 'add' || action === 'remove') {
|
||||||
if (!accountIds) {
|
if (!accountIds) {
|
||||||
logger.error('Missing accountIds in request')
|
logger.error('Missing accountIds in request')
|
||||||
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
||||||
@@ -128,16 +128,19 @@ export async function POST(request: NextRequest) {
|
|||||||
: accountIds
|
: accountIds
|
||||||
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/participant`
|
const url = `${baseUrl}/request/${issueIdOrKey}/participant`
|
||||||
|
const method = action === 'add' ? 'POST' : 'DELETE'
|
||||||
|
|
||||||
logger.info('Adding participants to:', url, { accountIds: parsedAccountIds })
|
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} participants:`, url, {
|
||||||
|
accountIds: parsedAccountIds,
|
||||||
|
})
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method,
|
||||||
headers: getJsmHeaders(accessToken),
|
headers: getJsmHeaders(accessToken),
|
||||||
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok && response.status !== 204) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
logger.error('JSM API error:', {
|
logger.error('JSM API error:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -151,14 +154,22 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
let participants: unknown[] = []
|
||||||
|
if (response.status !== 204) {
|
||||||
|
try {
|
||||||
|
const data = await response.json()
|
||||||
|
participants = data.values || []
|
||||||
|
} catch {
|
||||||
|
// DELETE may return empty body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
output: {
|
output: {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
issueIdOrKey,
|
issueIdOrKey,
|
||||||
participants: data.values || [],
|
participants,
|
||||||
success: true,
|
success: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
121
apps/sim/app/api/tools/jsm/queueissues/route.ts
Normal file
121
apps/sim/app/api/tools/jsm/queueissues/route.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmQueueIssuesAPI')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
serviceDeskId,
|
||||||
|
queueId,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serviceDeskId) {
|
||||||
|
logger.error('Missing serviceDeskId in request')
|
||||||
|
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueId) {
|
||||||
|
logger.error('Missing queueId in request')
|
||||||
|
return NextResponse.json({ error: 'Queue ID is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
||||||
|
if (!serviceDeskIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueIdValidation = validateAlphanumericId(queueId, 'queueId')
|
||||||
|
if (!queueIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: queueIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.append('start', start)
|
||||||
|
if (limit) params.append('limit', limit)
|
||||||
|
|
||||||
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}/queue/${queueId}/issue${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
|
|
||||||
|
logger.info('Fetching queue issues from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
serviceDeskId,
|
||||||
|
queueId,
|
||||||
|
issues: data.values || [],
|
||||||
|
total: data.size || 0,
|
||||||
|
isLastPage: data.isLastPage ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching queue issues:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/sim/app/api/tools/jsm/requeststatus/route.ts
Normal file
102
apps/sim/app/api/tools/jsm/requeststatus/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
|
||||||
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const logger = createLogger('JsmRequestStatusAPI')
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await checkInternalAuth(request)
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.error('Missing domain in request')
|
||||||
|
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.error('Missing access token in request')
|
||||||
|
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issueIdOrKey) {
|
||||||
|
logger.error('Missing issueIdOrKey in request')
|
||||||
|
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
||||||
|
if (!issueIdOrKeyValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
||||||
|
|
||||||
|
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||||
|
if (!cloudIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (start) params.append('start', start)
|
||||||
|
if (limit) params.append('limit', limit)
|
||||||
|
|
||||||
|
const url = `${baseUrl}/request/${issueIdOrKey}/status${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
|
|
||||||
|
logger.info('Fetching request status history from:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
issueIdOrKey,
|
||||||
|
statuses: data.values || [],
|
||||||
|
total: data.size || 0,
|
||||||
|
isLastPage: data.isLastPage ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching request status:', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -16,7 +16,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { domain, accessToken, cloudId: cloudIdParam, expand, start, limit } = body
|
const {
|
||||||
|
domain,
|
||||||
|
accessToken,
|
||||||
|
cloudId: cloudIdParam,
|
||||||
|
expand,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
serviceDeskId,
|
||||||
|
action,
|
||||||
|
} = body
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
logger.error('Missing domain in request')
|
logger.error('Missing domain in request')
|
||||||
@@ -37,6 +46,52 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
|
||||||
|
if (action === 'get' && serviceDeskId) {
|
||||||
|
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
||||||
|
if (!serviceDeskIdValidation.isValid) {
|
||||||
|
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}`
|
||||||
|
|
||||||
|
logger.info('Fetching service desk:', url)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getJsmHeaders(accessToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
logger.error('JSM API error:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
id: data.id ?? '',
|
||||||
|
projectId: data.projectId ?? '',
|
||||||
|
projectName: data.projectName ?? '',
|
||||||
|
projectKey: data.projectKey ?? '',
|
||||||
|
name: data.projectName ?? '',
|
||||||
|
description: data.description ?? null,
|
||||||
|
leadDisplayName: data.leadDisplayName ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (expand) params.append('expand', expand)
|
if (expand) params.append('expand', expand)
|
||||||
if (start) params.append('start', start)
|
if (start) params.append('start', start)
|
||||||
|
|||||||
@@ -32,10 +32,9 @@
|
|||||||
|
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
import { permissions, user, workspace } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, count, eq } from 'drizzle-orm'
|
import { and, count, eq } from 'drizzle-orm'
|
||||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||||
import {
|
import {
|
||||||
badRequestResponse,
|
badRequestResponse,
|
||||||
@@ -233,20 +232,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
|||||||
permissionId,
|
permissionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [wsEnvRow] = await db
|
|
||||||
.select({ variables: workspaceEnvironment.variables })
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
|
||||||
if (wsEnvKeys.length > 0) {
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId,
|
|
||||||
envKeys: wsEnvKeys,
|
|
||||||
actingUserId: body.userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return singleResponse({
|
return singleResponse({
|
||||||
id: permissionId,
|
id: permissionId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@@ -536,7 +536,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
useDraftState: shouldUseDraftState,
|
useDraftState: shouldUseDraftState,
|
||||||
startTime: new Date().toISOString(),
|
startTime: new Date().toISOString(),
|
||||||
isClientSession,
|
isClientSession,
|
||||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
|
||||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +875,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
useDraftState: shouldUseDraftState,
|
useDraftState: shouldUseDraftState,
|
||||||
startTime: new Date().toISOString(),
|
startTime: new Date().toISOString(),
|
||||||
isClientSession,
|
isClientSession,
|
||||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
|
||||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { workspaceEnvironment } from '@sim/db/schema'
|
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
|
||||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
const logger = createLogger('WorkspaceEnvironmentAPI')
|
const logger = createLogger('WorkspaceEnvironmentAPI')
|
||||||
@@ -46,10 +44,44 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
|
// Workspace env (encrypted)
|
||||||
userId,
|
const wsEnvRow = await db
|
||||||
workspaceId
|
.select()
|
||||||
)
|
.from(workspaceEnvironment)
|
||||||
|
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
|
||||||
|
|
||||||
|
// Personal env (encrypted)
|
||||||
|
const personalRow = await db
|
||||||
|
.select()
|
||||||
|
.from(environment)
|
||||||
|
.where(eq(environment.userId, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
|
||||||
|
|
||||||
|
// Decrypt both for UI
|
||||||
|
const decryptAll = async (src: Record<string, string>) => {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
try {
|
||||||
|
const { decrypted } = await decryptSecret(v)
|
||||||
|
out[k] = decrypted
|
||||||
|
} catch {
|
||||||
|
out[k] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
|
||||||
|
decryptAll(wsEncrypted),
|
||||||
|
decryptAll(personalEncrypted),
|
||||||
|
])
|
||||||
|
|
||||||
|
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -124,12 +156,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
set: { variables: merged, updatedAt: new Date() },
|
set: { variables: merged, updatedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId,
|
|
||||||
envKeys: Object.keys(merged),
|
|
||||||
actingUserId: userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||||
@@ -196,12 +222,6 @@ export async function DELETE(
|
|||||||
set: { variables: current, updatedAt: new Date() },
|
set: { variables: current, updatedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId,
|
|
||||||
envKeys: Object.keys(current),
|
|
||||||
actingUserId: userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Workspace env DELETE error`, error)
|
logger.error(`[${requestId}] Workspace env DELETE error`, error)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
|
import { permissions, workspace } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
import {
|
import {
|
||||||
getUsersWithPermissions,
|
getUsersWithPermissions,
|
||||||
hasWorkspaceAdminAccess,
|
hasWorkspaceAdminAccess,
|
||||||
@@ -155,20 +154,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const [wsEnvRow] = await db
|
|
||||||
.select({ variables: workspaceEnvironment.variables })
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
|
||||||
if (wsEnvKeys.length > 0) {
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId,
|
|
||||||
envKeys: wsEnvKeys,
|
|
||||||
actingUserId: session.user.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
user,
|
user,
|
||||||
type WorkspaceInvitationStatus,
|
type WorkspaceInvitationStatus,
|
||||||
workspace,
|
workspace,
|
||||||
workspaceEnvironment,
|
|
||||||
workspaceInvitation,
|
workspaceInvitation,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
@@ -15,7 +14,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -164,20 +162,6 @@ export async function GET(
|
|||||||
.where(eq(workspaceInvitation.id, invitation.id))
|
.where(eq(workspaceInvitation.id, invitation.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
const [wsEnvRow] = await db
|
|
||||||
.select({ variables: workspaceEnvironment.variables })
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
|
||||||
if (wsEnvKeys.length > 0) {
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId: invitation.workspaceId,
|
|
||||||
envKeys: wsEnvKeys,
|
|
||||||
actingUserId: session.user.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ export type CommandId =
|
|||||||
| 'goto-logs'
|
| 'goto-logs'
|
||||||
| 'open-search'
|
| 'open-search'
|
||||||
| 'run-workflow'
|
| 'run-workflow'
|
||||||
| 'focus-copilot-tab'
|
|
||||||
| 'focus-toolbar-tab'
|
|
||||||
| 'focus-editor-tab'
|
|
||||||
| 'clear-terminal-console'
|
| 'clear-terminal-console'
|
||||||
| 'focus-toolbar-search'
|
| 'focus-toolbar-search'
|
||||||
| 'clear-notifications'
|
| 'clear-notifications'
|
||||||
@@ -75,21 +72,6 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
|||||||
shortcut: 'Mod+Enter',
|
shortcut: 'Mod+Enter',
|
||||||
allowInEditable: false,
|
allowInEditable: false,
|
||||||
},
|
},
|
||||||
'focus-copilot-tab': {
|
|
||||||
id: 'focus-copilot-tab',
|
|
||||||
shortcut: 'C',
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
'focus-toolbar-tab': {
|
|
||||||
id: 'focus-toolbar-tab',
|
|
||||||
shortcut: 'T',
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
'focus-editor-tab': {
|
|
||||||
id: 'focus-editor-tab',
|
|
||||||
shortcut: 'E',
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
'clear-terminal-console': {
|
'clear-terminal-console': {
|
||||||
id: 'clear-terminal-console',
|
id: 'clear-terminal-console',
|
||||||
shortcut: 'Mod+D',
|
shortcut: 'Mod+D',
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ export function useChangeDetection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (block.triggerMode) {
|
||||||
|
const triggerConfigValue = blockSubValues?.triggerConfig
|
||||||
|
if (
|
||||||
|
triggerConfigValue &&
|
||||||
|
typeof triggerConfigValue === 'object' &&
|
||||||
|
!subBlocks.triggerConfig
|
||||||
|
) {
|
||||||
|
subBlocks.triggerConfig = {
|
||||||
|
id: 'triggerConfig',
|
||||||
|
type: 'short-input',
|
||||||
|
value: triggerConfigValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
blocksWithSubBlocks[blockId] = {
|
blocksWithSubBlocks[blockId] = {
|
||||||
...block,
|
...block,
|
||||||
subBlocks,
|
subBlocks,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export interface OAuthRequiredModalProps {
|
|||||||
requiredScopes?: string[]
|
requiredScopes?: string[]
|
||||||
serviceId: string
|
serviceId: string
|
||||||
newScopes?: string[]
|
newScopes?: string[]
|
||||||
onConnect?: () => Promise<void> | void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||||
@@ -140,6 +139,46 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|||||||
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
|
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
|
||||||
'write:issue-link:jira': 'Create links between Jira issues',
|
'write:issue-link:jira': 'Create links between Jira issues',
|
||||||
'delete:issue-link:jira': 'Delete links between Jira issues',
|
'delete:issue-link:jira': 'Delete links between Jira issues',
|
||||||
|
'manage:jira-project': 'Manage Jira project components and versions',
|
||||||
|
'read:board-scope:jira-software': 'View Jira boards',
|
||||||
|
'write:board-scope:jira-software': 'Manage Jira boards and backlog',
|
||||||
|
'read:sprint:jira-software': 'View Jira sprints',
|
||||||
|
'write:sprint:jira-software': 'Create and manage Jira sprints',
|
||||||
|
'delete:sprint:jira-software': 'Delete Jira sprints',
|
||||||
|
'read:servicedesk:jira-service-management': 'View JSM service desks',
|
||||||
|
'read:requesttype:jira-service-management': 'View JSM request types',
|
||||||
|
'read:request:jira-service-management': 'View JSM service requests',
|
||||||
|
'write:request:jira-service-management': 'Create and update JSM service requests',
|
||||||
|
'read:request.comment:jira-service-management': 'View comments on JSM requests',
|
||||||
|
'write:request.comment:jira-service-management': 'Add comments to JSM requests',
|
||||||
|
'read:customer:jira-service-management': 'View JSM customers',
|
||||||
|
'write:customer:jira-service-management': 'Create and manage JSM customers',
|
||||||
|
'read:servicedesk.customer:jira-service-management': 'View service desk customers',
|
||||||
|
'write:servicedesk.customer:jira-service-management': 'Add customers to service desks',
|
||||||
|
'delete:servicedesk.customer:jira-service-management': 'Remove customers from service desks',
|
||||||
|
'read:organization:jira-service-management': 'View JSM organizations',
|
||||||
|
'write:organization:jira-service-management': 'Create and manage JSM organizations',
|
||||||
|
'delete:organization:jira-service-management': 'Delete JSM organizations',
|
||||||
|
'read:servicedesk.organization:jira-service-management': 'View service desk organizations',
|
||||||
|
'write:servicedesk.organization:jira-service-management': 'Add organizations to service desks',
|
||||||
|
'read:organization.user:jira-service-management': 'View organization users',
|
||||||
|
'write:organization.user:jira-service-management': 'Add users to organizations',
|
||||||
|
'read:queue:jira-service-management': 'View JSM queues and queue issues',
|
||||||
|
'read:request.sla:jira-service-management': 'View request SLA information',
|
||||||
|
'read:request.status:jira-service-management': 'View request status history',
|
||||||
|
'write:request.status:jira-service-management': 'Transition request status',
|
||||||
|
'read:request.participant:jira-service-management': 'View request participants',
|
||||||
|
'write:request.participant:jira-service-management': 'Add request participants',
|
||||||
|
'read:request.approval:jira-service-management': 'View request approvals',
|
||||||
|
'write:request.approval:jira-service-management': 'Respond to request approvals',
|
||||||
|
'read:request.feedback:jira-service-management': 'View request feedback',
|
||||||
|
'write:request.feedback:jira-service-management': 'Add request feedback',
|
||||||
|
'delete:request.feedback:jira-service-management': 'Delete request feedback',
|
||||||
|
'read:request.notification:jira-service-management': 'View request notification status',
|
||||||
|
'write:request.notification:jira-service-management': 'Subscribe to request notifications',
|
||||||
|
'delete:request.notification:jira-service-management': 'Unsubscribe from request notifications',
|
||||||
|
'read:request.attachment:jira-service-management': 'View request attachments',
|
||||||
|
'read:knowledgebase:jira-service-management': 'Search knowledge base articles',
|
||||||
'User.Read': 'Read Microsoft user',
|
'User.Read': 'Read Microsoft user',
|
||||||
'Chat.Read': 'Read Microsoft chats',
|
'Chat.Read': 'Read Microsoft chats',
|
||||||
'Chat.ReadWrite': 'Write to Microsoft chats',
|
'Chat.ReadWrite': 'Write to Microsoft chats',
|
||||||
@@ -315,7 +354,6 @@ export function OAuthRequiredModal({
|
|||||||
requiredScopes = [],
|
requiredScopes = [],
|
||||||
serviceId,
|
serviceId,
|
||||||
newScopes = [],
|
newScopes = [],
|
||||||
onConnect,
|
|
||||||
}: OAuthRequiredModalProps) {
|
}: OAuthRequiredModalProps) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { baseProvider } = parseProvider(provider)
|
const { baseProvider } = parseProvider(provider)
|
||||||
@@ -361,12 +399,6 @@ export function OAuthRequiredModal({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (onConnect) {
|
|
||||||
await onConnect()
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerId = getProviderIdFromServiceId(serviceId)
|
const providerId = getProviderIdFromServiceId(serviceId)
|
||||||
|
|
||||||
logger.info('Linking OAuth2:', {
|
logger.info('Linking OAuth2:', {
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { ExternalLink, Users } from 'lucide-react'
|
import { ExternalLink, Users } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button, Combobox } from '@/components/emcn/components'
|
import { Button, Combobox } from '@/components/emcn/components'
|
||||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
|
||||||
import {
|
import {
|
||||||
getCanonicalScopesForProvider,
|
getCanonicalScopesForProvider,
|
||||||
getProviderIdFromServiceId,
|
getProviderIdFromServiceId,
|
||||||
@@ -20,9 +18,9 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
|
|||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
import { CREDENTIAL_SET } from '@/executor/constants'
|
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
|
||||||
import { useCredentialSets } from '@/hooks/queries/credential-sets'
|
import { useCredentialSets } from '@/hooks/queries/credential-sets'
|
||||||
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||||
import { useOrganizations } from '@/hooks/queries/organization'
|
import { useOrganizations } from '@/hooks/queries/organization'
|
||||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||||
@@ -48,8 +46,6 @@ export function CredentialSelector({
|
|||||||
previewValue,
|
previewValue,
|
||||||
previewContextValues,
|
previewContextValues,
|
||||||
}: CredentialSelectorProps) {
|
}: CredentialSelectorProps) {
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = (params?.workspaceId as string) || ''
|
|
||||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||||
const [editingValue, setEditingValue] = useState('')
|
const [editingValue, setEditingValue] = useState('')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
@@ -100,68 +96,64 @@ export function CredentialSelector({
|
|||||||
data: credentials = [],
|
data: credentials = [],
|
||||||
isFetching: credentialsLoading,
|
isFetching: credentialsLoading,
|
||||||
refetch: refetchCredentials,
|
refetch: refetchCredentials,
|
||||||
} = useOAuthCredentials(effectiveProviderId, {
|
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||||
enabled: Boolean(effectiveProviderId),
|
|
||||||
workspaceId,
|
|
||||||
workflowId: activeWorkflowId || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedCredential = useMemo(
|
const selectedCredential = useMemo(
|
||||||
() => credentials.find((cred) => cred.id === selectedId),
|
() => credentials.find((cred) => cred.id === selectedId),
|
||||||
[credentials, selectedId]
|
[credentials, selectedId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const shouldFetchForeignMeta =
|
||||||
|
Boolean(selectedId) &&
|
||||||
|
!selectedCredential &&
|
||||||
|
Boolean(activeWorkflowId) &&
|
||||||
|
Boolean(effectiveProviderId)
|
||||||
|
|
||||||
|
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
|
||||||
|
useOAuthCredentialDetail(
|
||||||
|
shouldFetchForeignMeta ? selectedId : undefined,
|
||||||
|
activeWorkflowId || undefined,
|
||||||
|
shouldFetchForeignMeta
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasForeignMeta = foreignCredentials.length > 0
|
||||||
|
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
|
||||||
|
|
||||||
const selectedCredentialSet = useMemo(
|
const selectedCredentialSet = useMemo(
|
||||||
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
|
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
|
||||||
[credentialSets, selectedCredentialSetId]
|
[credentialSets, selectedCredentialSetId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
|
||||||
setInaccessibleCredentialName(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
|
||||||
)
|
|
||||||
if (!response.ok || cancelled) return
|
|
||||||
const data = await response.json()
|
|
||||||
if (!cancelled && data.credential?.displayName) {
|
|
||||||
setInaccessibleCredentialName(data.credential.displayName)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore fetch errors
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
|
||||||
|
|
||||||
const resolvedLabel = useMemo(() => {
|
const resolvedLabel = useMemo(() => {
|
||||||
if (selectedCredentialSet) return selectedCredentialSet.name
|
if (selectedCredentialSet) return selectedCredentialSet.name
|
||||||
|
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
|
||||||
if (selectedCredential) return selectedCredential.name
|
if (selectedCredential) return selectedCredential.name
|
||||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
|
||||||
if (selectedId && !credentialsLoading) return 'Credential (no access)'
|
|
||||||
return ''
|
return ''
|
||||||
}, [
|
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
|
||||||
selectedCredentialSet,
|
|
||||||
selectedCredential,
|
|
||||||
inaccessibleCredentialName,
|
|
||||||
selectedId,
|
|
||||||
credentialsLoading,
|
|
||||||
])
|
|
||||||
|
|
||||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||||
|
|
||||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
const invalidSelection =
|
||||||
|
!isPreview &&
|
||||||
|
Boolean(selectedId) &&
|
||||||
|
!selectedCredential &&
|
||||||
|
!hasForeignMeta &&
|
||||||
|
!credentialsLoading &&
|
||||||
|
!foreignMetaLoading
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invalidSelection) return
|
||||||
|
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||||
|
selectedId,
|
||||||
|
provider: effectiveProviderId,
|
||||||
|
})
|
||||||
|
setStoreValue('')
|
||||||
|
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
|
||||||
|
|
||||||
|
useCredentialRefreshTriggers(refetchCredentials)
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(isOpen: boolean) => {
|
(isOpen: boolean) => {
|
||||||
@@ -203,18 +195,8 @@ export function CredentialSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleAddCredential = useCallback(() => {
|
const handleAddCredential = useCallback(() => {
|
||||||
writePendingCredentialCreateRequest({
|
setShowOAuthModal(true)
|
||||||
workspaceId,
|
}, [])
|
||||||
type: 'oauth',
|
|
||||||
providerId: effectiveProviderId,
|
|
||||||
displayName: '',
|
|
||||||
serviceId,
|
|
||||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
|
||||||
requestedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
|
|
||||||
}, [workspaceId, effectiveProviderId, serviceId])
|
|
||||||
|
|
||||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||||
const { baseProvider } = parseProvider(providerName)
|
const { baseProvider } = parseProvider(providerName)
|
||||||
@@ -269,18 +251,23 @@ export function CredentialSelector({
|
|||||||
label: cred.name,
|
label: cred.name,
|
||||||
value: cred.id,
|
value: cred.id,
|
||||||
}))
|
}))
|
||||||
credentialItems.push({
|
|
||||||
label:
|
|
||||||
credentials.length > 0
|
|
||||||
? `Connect another ${getProviderName(provider)} account`
|
|
||||||
: `Connect ${getProviderName(provider)} account`,
|
|
||||||
value: '__connect_account__',
|
|
||||||
})
|
|
||||||
|
|
||||||
groups.push({
|
if (credentialItems.length > 0) {
|
||||||
section: 'Personal Credential',
|
groups.push({
|
||||||
items: credentialItems,
|
section: 'Personal Credential',
|
||||||
})
|
items: credentialItems,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
section: 'Personal Credential',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: `Connect ${getProviderName(provider)} account`,
|
||||||
|
value: '__connect_account__',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { comboboxOptions: [], comboboxGroups: groups }
|
return { comboboxOptions: [], comboboxGroups: groups }
|
||||||
}
|
}
|
||||||
@@ -290,13 +277,12 @@ export function CredentialSelector({
|
|||||||
value: cred.id,
|
value: cred.id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
options.push({
|
if (credentials.length === 0) {
|
||||||
label:
|
options.push({
|
||||||
credentials.length > 0
|
label: `Connect ${getProviderName(provider)} account`,
|
||||||
? `Connect another ${getProviderName(provider)} account`
|
value: '__connect_account__',
|
||||||
: `Connect ${getProviderName(provider)} account`,
|
})
|
||||||
value: '__connect_account__',
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return { comboboxOptions: options, comboboxGroups: undefined }
|
return { comboboxOptions: options, comboboxGroups: undefined }
|
||||||
}, [
|
}, [
|
||||||
@@ -382,7 +368,7 @@ export function CredentialSelector({
|
|||||||
}
|
}
|
||||||
disabled={effectiveDisabled}
|
disabled={effectiveDisabled}
|
||||||
editable={true}
|
editable={true}
|
||||||
filterOptions={true}
|
filterOptions={!isForeign && !isForeignCredentialSet}
|
||||||
isLoading={credentialsLoading}
|
isLoading={credentialsLoading}
|
||||||
overlayContent={overlayContent}
|
overlayContent={overlayContent}
|
||||||
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
|
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
|
||||||
@@ -394,13 +380,15 @@ export function CredentialSelector({
|
|||||||
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
||||||
Additional permissions required
|
Additional permissions required
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!isForeign && (
|
||||||
variant='active'
|
<Button
|
||||||
onClick={() => setShowOAuthModal(true)}
|
variant='active'
|
||||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
onClick={() => setShowOAuthModal(true)}
|
||||||
>
|
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||||
Update access
|
>
|
||||||
</Button>
|
Update access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -419,11 +407,7 @@ export function CredentialSelector({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCredentialRefreshTriggers(
|
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
|
||||||
refetchCredentials: () => Promise<unknown>,
|
|
||||||
providerId: string,
|
|
||||||
workspaceId: string
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void refetchCredentials()
|
void refetchCredentials()
|
||||||
@@ -441,29 +425,12 @@ function useCredentialRefreshTriggers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCredentialsUpdated = (
|
|
||||||
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
|
|
||||||
) => {
|
|
||||||
if (event.detail?.providerId && event.detail.providerId !== providerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
window.addEventListener('pageshow', handlePageShow)
|
window.addEventListener('pageshow', handlePageShow)
|
||||||
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
window.removeEventListener('pageshow', handlePageShow)
|
window.removeEventListener('pageshow', handlePageShow)
|
||||||
window.removeEventListener(
|
|
||||||
'oauth-credentials-updated',
|
|
||||||
handleCredentialsUpdated as EventListener
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, [providerId, workspaceId, refetchCredentials])
|
}, [refetchCredentials])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
PopoverSection,
|
PopoverSection,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
|
||||||
import {
|
import {
|
||||||
usePersonalEnvironment,
|
usePersonalEnvironment,
|
||||||
useWorkspaceEnvironment,
|
useWorkspaceEnvironment,
|
||||||
@@ -169,15 +168,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
|||||||
}, [searchTerm])
|
}, [searchTerm])
|
||||||
|
|
||||||
const openEnvironmentSettings = () => {
|
const openEnvironmentSettings = () => {
|
||||||
if (workspaceId) {
|
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
|
||||||
writePendingCredentialCreateRequest({
|
|
||||||
workspaceId,
|
|
||||||
type: 'env_personal',
|
|
||||||
envKey: searchTerm.trim(),
|
|
||||||
requestedAt: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
|
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +302,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className='h-3 w-3' />
|
<Plus className='h-3 w-3' />
|
||||||
<span>Create Secret</span>
|
<span>Create environment variable</span>
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
</PopoverScrollArea>
|
</PopoverScrollArea>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
|
|||||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
|
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
@@ -124,6 +125,8 @@ export function FileSelectorInput({
|
|||||||
const serviceId = subBlock.serviceId || ''
|
const serviceId = subBlock.serviceId || ''
|
||||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||||
|
|
||||||
|
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
|
||||||
|
|
||||||
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
||||||
return resolveSelectorForSubBlock(subBlock, {
|
return resolveSelectorForSubBlock(subBlock, {
|
||||||
workflowId: workflowIdFromUrl,
|
workflowId: workflowIdFromUrl,
|
||||||
@@ -165,6 +168,7 @@ export function FileSelectorInput({
|
|||||||
|
|
||||||
const disabledReason =
|
const disabledReason =
|
||||||
finalDisabled ||
|
finalDisabled ||
|
||||||
|
isForeignCredential ||
|
||||||
missingCredential ||
|
missingCredential ||
|
||||||
missingDomain ||
|
missingDomain ||
|
||||||
missingProject ||
|
missingProject ||
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
|
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
@@ -46,6 +47,10 @@ export function FolderSelectorInput({
|
|||||||
subBlock.canonicalParamId === 'copyDestinationId' ||
|
subBlock.canonicalParamId === 'copyDestinationId' ||
|
||||||
subBlock.id === 'copyDestinationFolder' ||
|
subBlock.id === 'copyDestinationFolder' ||
|
||||||
subBlock.id === 'manualCopyDestinationFolder'
|
subBlock.id === 'manualCopyDestinationFolder'
|
||||||
|
const { isForeignCredential } = useForeignCredential(
|
||||||
|
effectiveProviderId,
|
||||||
|
(connectedCredential as string) || ''
|
||||||
|
)
|
||||||
|
|
||||||
// Central dependsOn gating
|
// Central dependsOn gating
|
||||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||||
@@ -114,7 +119,9 @@ export function FolderSelectorInput({
|
|||||||
selectorContext={
|
selectorContext={
|
||||||
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
|
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
|
||||||
}
|
}
|
||||||
disabled={finalDisabled || missingCredential || !selectorResolution?.key}
|
disabled={
|
||||||
|
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
|
||||||
|
}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue ?? null}
|
previewValue={previewValue ?? null}
|
||||||
placeholder={subBlock.placeholder || 'Select folder'}
|
placeholder={subBlock.placeholder || 'Select folder'}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
|
|||||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
|
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
@@ -72,6 +73,11 @@ export function ProjectSelectorInput({
|
|||||||
|
|
||||||
const serviceId = subBlock.serviceId || ''
|
const serviceId = subBlock.serviceId || ''
|
||||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||||
|
|
||||||
|
const { isForeignCredential } = useForeignCredential(
|
||||||
|
effectiveProviderId,
|
||||||
|
(connectedCredential as string) || ''
|
||||||
|
)
|
||||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||||
disabled,
|
disabled,
|
||||||
@@ -117,7 +123,7 @@ export function ProjectSelectorInput({
|
|||||||
subBlock={subBlock}
|
subBlock={subBlock}
|
||||||
selectorKey={selectorResolution.key}
|
selectorKey={selectorResolution.key}
|
||||||
selectorContext={selectorResolution.context}
|
selectorContext={selectorResolution.context}
|
||||||
disabled={finalDisabled || missingCredential}
|
disabled={finalDisabled || isForeignCredential || missingCredential}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue ?? null}
|
previewValue={previewValue ?? null}
|
||||||
placeholder={subBlock.placeholder || 'Select project'}
|
placeholder={subBlock.placeholder || 'Select project'}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
|
|||||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
|
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||||
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
@@ -86,6 +87,8 @@ export function SheetSelectorInput({
|
|||||||
const serviceId = subBlock.serviceId || ''
|
const serviceId = subBlock.serviceId || ''
|
||||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||||
|
|
||||||
|
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
|
||||||
|
|
||||||
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
const selectorResolution = useMemo<SelectorResolution | null>(() => {
|
||||||
return resolveSelectorForSubBlock(subBlock, {
|
return resolveSelectorForSubBlock(subBlock, {
|
||||||
workflowId: workflowIdFromUrl,
|
workflowId: workflowIdFromUrl,
|
||||||
@@ -98,7 +101,11 @@ export function SheetSelectorInput({
|
|||||||
const missingSpreadsheet = !normalizedSpreadsheetId
|
const missingSpreadsheet = !normalizedSpreadsheetId
|
||||||
|
|
||||||
const disabledReason =
|
const disabledReason =
|
||||||
finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key
|
finalDisabled ||
|
||||||
|
isForeignCredential ||
|
||||||
|
missingCredential ||
|
||||||
|
missingSpreadsheet ||
|
||||||
|
!selectorResolution?.key
|
||||||
|
|
||||||
if (!selectorResolution?.key) {
|
if (!selectorResolution?.key) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Tooltip } from '@/components/emcn'
|
|||||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
|
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
@@ -84,6 +85,11 @@ export function SlackSelectorInput({
|
|||||||
? (effectiveBotToken as string) || ''
|
? (effectiveBotToken as string) || ''
|
||||||
: (effectiveCredential as string) || ''
|
: (effectiveCredential as string) || ''
|
||||||
|
|
||||||
|
const { isForeignCredential } = useForeignCredential(
|
||||||
|
effectiveProviderId,
|
||||||
|
(effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || ''
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'string') {
|
||||||
@@ -93,7 +99,7 @@ export function SlackSelectorInput({
|
|||||||
|
|
||||||
const requiresCredential = dependsOn.includes('credential')
|
const requiresCredential = dependsOn.includes('credential')
|
||||||
const missingCredential = !credential || credential.trim().length === 0
|
const missingCredential = !credential || credential.trim().length === 0
|
||||||
const shouldForceDisable = requiresCredential && missingCredential
|
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
|
||||||
|
|
||||||
const context: SelectorContext = useMemo(
|
const context: SelectorContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -130,7 +136,7 @@ export function SlackSelectorInput({
|
|||||||
subBlock={subBlock}
|
subBlock={subBlock}
|
||||||
selectorKey={config.selectorKey}
|
selectorKey={config.selectorKey}
|
||||||
selectorContext={context}
|
selectorContext={context}
|
||||||
disabled={finalDisabled || shouldForceDisable}
|
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue ?? null}
|
previewValue={previewValue ?? null}
|
||||||
placeholder={subBlock.placeholder || config.placeholder}
|
placeholder={subBlock.placeholder || config.placeholder}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { ExternalLink } from 'lucide-react'
|
import { ExternalLink } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button, Combobox } from '@/components/emcn/components'
|
import { Button, Combobox } from '@/components/emcn/components'
|
||||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
|
||||||
import {
|
import {
|
||||||
getCanonicalScopesForProvider,
|
getCanonicalScopesForProvider,
|
||||||
getProviderIdFromServiceId,
|
getProviderIdFromServiceId,
|
||||||
@@ -12,7 +10,8 @@ import {
|
|||||||
parseProvider,
|
parseProvider,
|
||||||
} from '@/lib/oauth'
|
} from '@/lib/oauth'
|
||||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||||
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
import { CREDENTIAL } from '@/executor/constants'
|
||||||
|
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
@@ -55,12 +54,10 @@ export function ToolCredentialSelector({
|
|||||||
onChange,
|
onChange,
|
||||||
provider,
|
provider,
|
||||||
requiredScopes = [],
|
requiredScopes = [],
|
||||||
label = 'Select credential',
|
label = 'Select account',
|
||||||
serviceId,
|
serviceId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ToolCredentialSelectorProps) {
|
}: ToolCredentialSelectorProps) {
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = (params?.workspaceId as string) || ''
|
|
||||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||||
const [editingInputValue, setEditingInputValue] = useState('')
|
const [editingInputValue, setEditingInputValue] = useState('')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
@@ -74,56 +71,50 @@ export function ToolCredentialSelector({
|
|||||||
data: credentials = [],
|
data: credentials = [],
|
||||||
isFetching: credentialsLoading,
|
isFetching: credentialsLoading,
|
||||||
refetch: refetchCredentials,
|
refetch: refetchCredentials,
|
||||||
} = useOAuthCredentials(effectiveProviderId, {
|
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||||
enabled: Boolean(effectiveProviderId),
|
|
||||||
workspaceId,
|
|
||||||
workflowId: activeWorkflowId || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedCredential = useMemo(
|
const selectedCredential = useMemo(
|
||||||
() => credentials.find((cred) => cred.id === selectedId),
|
() => credentials.find((cred) => cred.id === selectedId),
|
||||||
[credentials, selectedId]
|
[credentials, selectedId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
const shouldFetchForeignMeta =
|
||||||
|
Boolean(selectedId) &&
|
||||||
|
!selectedCredential &&
|
||||||
|
Boolean(activeWorkflowId) &&
|
||||||
|
Boolean(effectiveProviderId)
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
|
||||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
useOAuthCredentialDetail(
|
||||||
setInaccessibleCredentialName(null)
|
shouldFetchForeignMeta ? selectedId : undefined,
|
||||||
return
|
activeWorkflowId || undefined,
|
||||||
}
|
shouldFetchForeignMeta
|
||||||
|
)
|
||||||
|
|
||||||
let cancelled = false
|
const hasForeignMeta = foreignCredentials.length > 0
|
||||||
;(async () => {
|
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
|
||||||
)
|
|
||||||
if (!response.ok || cancelled) return
|
|
||||||
const data = await response.json()
|
|
||||||
if (!cancelled && data.credential?.displayName) {
|
|
||||||
setInaccessibleCredentialName(data.credential.displayName)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore fetch errors
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
|
||||||
|
|
||||||
const resolvedLabel = useMemo(() => {
|
const resolvedLabel = useMemo(() => {
|
||||||
if (selectedCredential) return selectedCredential.name
|
if (selectedCredential) return selectedCredential.name
|
||||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
|
||||||
if (selectedId && !credentialsLoading) return 'Credential (no access)'
|
|
||||||
return ''
|
return ''
|
||||||
}, [selectedCredential, inaccessibleCredentialName, selectedId, credentialsLoading])
|
}, [selectedCredential, isForeign])
|
||||||
|
|
||||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||||
|
|
||||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
const invalidSelection =
|
||||||
|
Boolean(selectedId) &&
|
||||||
|
!selectedCredential &&
|
||||||
|
!hasForeignMeta &&
|
||||||
|
!credentialsLoading &&
|
||||||
|
!foreignMetaLoading
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invalidSelection) return
|
||||||
|
onChange('')
|
||||||
|
}, [invalidSelection, onChange])
|
||||||
|
|
||||||
|
useCredentialRefreshTriggers(refetchCredentials)
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(isOpen: boolean) => {
|
(isOpen: boolean) => {
|
||||||
@@ -151,18 +142,8 @@ export function ToolCredentialSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleAddCredential = useCallback(() => {
|
const handleAddCredential = useCallback(() => {
|
||||||
writePendingCredentialCreateRequest({
|
setShowOAuthModal(true)
|
||||||
workspaceId,
|
}, [])
|
||||||
type: 'oauth',
|
|
||||||
providerId: effectiveProviderId,
|
|
||||||
displayName: '',
|
|
||||||
serviceId,
|
|
||||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
|
||||||
requestedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
|
|
||||||
}, [workspaceId, effectiveProviderId, serviceId])
|
|
||||||
|
|
||||||
const comboboxOptions = useMemo(() => {
|
const comboboxOptions = useMemo(() => {
|
||||||
const options = credentials.map((cred) => ({
|
const options = credentials.map((cred) => ({
|
||||||
@@ -170,13 +151,12 @@ export function ToolCredentialSelector({
|
|||||||
value: cred.id,
|
value: cred.id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
options.push({
|
if (credentials.length === 0) {
|
||||||
label:
|
options.push({
|
||||||
credentials.length > 0
|
label: `Connect ${getProviderName(provider)} account`,
|
||||||
? `Connect another ${getProviderName(provider)} account`
|
value: '__connect_account__',
|
||||||
: `Connect ${getProviderName(provider)} account`,
|
})
|
||||||
value: '__connect_account__',
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}, [credentials, provider])
|
}, [credentials, provider])
|
||||||
@@ -226,7 +206,7 @@ export function ToolCredentialSelector({
|
|||||||
placeholder={label}
|
placeholder={label}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
editable={true}
|
editable={true}
|
||||||
filterOptions={true}
|
filterOptions={!isForeign}
|
||||||
isLoading={credentialsLoading}
|
isLoading={credentialsLoading}
|
||||||
overlayContent={overlayContent}
|
overlayContent={overlayContent}
|
||||||
className={selectedId ? 'pl-[28px]' : ''}
|
className={selectedId ? 'pl-[28px]' : ''}
|
||||||
@@ -238,13 +218,15 @@ export function ToolCredentialSelector({
|
|||||||
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
||||||
Additional permissions required
|
Additional permissions required
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!isForeign && (
|
||||||
variant='active'
|
<Button
|
||||||
onClick={() => setShowOAuthModal(true)}
|
variant='active'
|
||||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
onClick={() => setShowOAuthModal(true)}
|
||||||
>
|
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||||
Update access
|
>
|
||||||
</Button>
|
Update access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -263,11 +245,7 @@ export function ToolCredentialSelector({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCredentialRefreshTriggers(
|
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
|
||||||
refetchCredentials: () => Promise<unknown>,
|
|
||||||
providerId: string,
|
|
||||||
workspaceId: string
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void refetchCredentials()
|
void refetchCredentials()
|
||||||
@@ -285,29 +263,12 @@ function useCredentialRefreshTriggers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCredentialsUpdated = (
|
|
||||||
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
|
|
||||||
) => {
|
|
||||||
if (event.detail?.providerId && event.detail.providerId !== providerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
window.addEventListener('pageshow', handlePageShow)
|
window.addEventListener('pageshow', handlePageShow)
|
||||||
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
window.removeEventListener('pageshow', handlePageShow)
|
window.removeEventListener('pageshow', handlePageShow)
|
||||||
window.removeEventListener(
|
|
||||||
'oauth-credentials-updated',
|
|
||||||
handleCredentialsUpdated as EventListener
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, [providerId, workspaceId, refetchCredentials])
|
}, [refetchCredentials])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export function useForeignCredential(
|
||||||
|
provider: string | undefined,
|
||||||
|
credentialId: string | undefined
|
||||||
|
) {
|
||||||
|
const [isForeign, setIsForeign] = useState<boolean>(false)
|
||||||
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
|
||||||
|
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function check() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (!normalizedProvider || !normalizedCredentialId) {
|
||||||
|
if (!cancelled) setIsForeign(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!cancelled) setIsForeign(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
|
||||||
|
if (!cancelled) setIsForeign(!isOwn)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsForeign(true)
|
||||||
|
setError((e as Error).message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void check()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [normalizedProvider, normalizedCredentialId])
|
||||||
|
|
||||||
|
return { isForeignCredential: isForeign, loading, error }
|
||||||
|
}
|
||||||
@@ -340,13 +340,7 @@ export const Panel = memo(function Panel() {
|
|||||||
* Register global keyboard shortcuts using the central commands registry.
|
* Register global keyboard shortcuts using the central commands registry.
|
||||||
*
|
*
|
||||||
* - Mod+Enter: Run / cancel workflow (matches the Run button behavior)
|
* - Mod+Enter: Run / cancel workflow (matches the Run button behavior)
|
||||||
* - C: Focus Copilot tab
|
|
||||||
* - T: Focus Toolbar tab
|
|
||||||
* - E: Focus Editor tab
|
|
||||||
* - Mod+F: Focus Toolbar tab and search input
|
* - Mod+F: Focus Toolbar tab and search input
|
||||||
*
|
|
||||||
* The tab-switching commands are disabled inside editable elements so typing
|
|
||||||
* in inputs or textareas is not interrupted.
|
|
||||||
*/
|
*/
|
||||||
useRegisterGlobalCommands(() =>
|
useRegisterGlobalCommands(() =>
|
||||||
createCommands([
|
createCommands([
|
||||||
@@ -363,33 +357,6 @@ export const Panel = memo(function Panel() {
|
|||||||
allowInEditable: false,
|
allowInEditable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'focus-copilot-tab',
|
|
||||||
handler: () => {
|
|
||||||
setActiveTab('copilot')
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'focus-toolbar-tab',
|
|
||||||
handler: () => {
|
|
||||||
setActiveTab('toolbar')
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'focus-editor-tab',
|
|
||||||
handler: () => {
|
|
||||||
setActiveTab('editor')
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
allowInEditable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'focus-toolbar-search',
|
id: 'focus-toolbar-search',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
@@ -255,54 +255,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
|
|
||||||
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
|
|
||||||
if (!pending) return
|
|
||||||
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
displayName,
|
|
||||||
providerId,
|
|
||||||
preCount,
|
|
||||||
workspaceId: wsId,
|
|
||||||
} = JSON.parse(pending) as {
|
|
||||||
displayName: string
|
|
||||||
providerId: string
|
|
||||||
preCount: number
|
|
||||||
workspaceId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
|
|
||||||
)
|
|
||||||
const data = response.ok ? await response.json() : { credentials: [] }
|
|
||||||
const oauthCredentials = (data.credentials ?? []) as Array<{
|
|
||||||
displayName: string
|
|
||||||
providerId: string | null
|
|
||||||
}>
|
|
||||||
|
|
||||||
if (oauthCredentials.length > preCount) {
|
|
||||||
addNotification({
|
|
||||||
level: 'info',
|
|
||||||
message: `"${displayName}" credential connected successfully.`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const existing = oauthCredentials.find((c) => c.providerId === providerId)
|
|
||||||
const existingName = existing?.displayName || displayName
|
|
||||||
addNotification({
|
|
||||||
level: 'info',
|
|
||||||
message: `This account is already connected as "${existingName}".`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed sessionStorage data
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workflows,
|
workflows,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ function ConnectionsSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secrets */}
|
{/* Environment Variables */}
|
||||||
{envVars.length > 0 && (
|
{envVars.length > 0 && (
|
||||||
<div className='mb-[2px] last:mb-0'>
|
<div className='mb-[2px] last:mb-0'>
|
||||||
<div
|
<div
|
||||||
@@ -489,7 +489,7 @@ function ConnectionsSection({
|
|||||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
|
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Secrets
|
Environment Variables
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager'
|
|
||||||
|
|
||||||
interface CredentialsProps {
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
|
||||||
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Credentials(_props: CredentialsProps) {
|
|
||||||
return (
|
|
||||||
<div className='h-full min-h-0'>
|
|
||||||
<CredentialsManager />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -134,7 +134,7 @@ function WorkspaceVariableRow({
|
|||||||
<Trash />
|
<Trash />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
<Tooltip.Content>Delete environment variable</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
|||||||
<Trash />
|
<Trash />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
<Tooltip.Content>Delete environment variable</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
|||||||
filteredWorkspaceEntries.length === 0 &&
|
filteredWorkspaceEntries.length === 0 &&
|
||||||
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
|
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
|
||||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||||
No secrets found matching "{searchTerm}"
|
No environment variables found matching "{searchTerm}"
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { ApiKeys } from './api-keys/api-keys'
|
|||||||
export { BYOK } from './byok/byok'
|
export { BYOK } from './byok/byok'
|
||||||
export { Copilot } from './copilot/copilot'
|
export { Copilot } from './copilot/copilot'
|
||||||
export { CredentialSets } from './credential-sets/credential-sets'
|
export { CredentialSets } from './credential-sets/credential-sets'
|
||||||
export { Credentials } from './credentials/credentials'
|
|
||||||
export { CustomTools } from './custom-tools/custom-tools'
|
export { CustomTools } from './custom-tools/custom-tools'
|
||||||
export { Debug } from './debug/debug'
|
export { Debug } from './debug/debug'
|
||||||
export { EnvironmentVariables } from './environment/environment'
|
export { EnvironmentVariables } from './environment/environment'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Connections,
|
Connections,
|
||||||
|
FolderCode,
|
||||||
HexSimple,
|
HexSimple,
|
||||||
Key,
|
Key,
|
||||||
SModal,
|
SModal,
|
||||||
@@ -44,11 +45,12 @@ import {
|
|||||||
BYOK,
|
BYOK,
|
||||||
Copilot,
|
Copilot,
|
||||||
CredentialSets,
|
CredentialSets,
|
||||||
Credentials,
|
|
||||||
CustomTools,
|
CustomTools,
|
||||||
Debug,
|
Debug,
|
||||||
|
EnvironmentVariables,
|
||||||
FileUploads,
|
FileUploads,
|
||||||
General,
|
General,
|
||||||
|
Integrations,
|
||||||
MCP,
|
MCP,
|
||||||
Skills,
|
Skills,
|
||||||
Subscription,
|
Subscription,
|
||||||
@@ -78,7 +80,6 @@ interface SettingsModalProps {
|
|||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'credentials'
|
|
||||||
| 'environment'
|
| 'environment'
|
||||||
| 'template-profile'
|
| 'template-profile'
|
||||||
| 'integrations'
|
| 'integrations'
|
||||||
@@ -155,10 +156,11 @@ const allNavigationItems: NavigationItem[] = [
|
|||||||
requiresHosted: true,
|
requiresHosted: true,
|
||||||
requiresTeam: true,
|
requiresTeam: true,
|
||||||
},
|
},
|
||||||
{ id: 'credentials', label: 'Credentials', icon: Connections, section: 'tools' },
|
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||||
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
|
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
|
||||||
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
|
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
|
||||||
|
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||||
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
|
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
|
||||||
{
|
{
|
||||||
@@ -254,6 +256,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
|
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (item.id === 'files' && permissionConfig.hideFilesTab) {
|
if (item.id === 'files' && permissionConfig.hideFilesTab) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -319,9 +324,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||||
return 'general'
|
return 'general'
|
||||||
}
|
}
|
||||||
if (activeSection === 'environment' || activeSection === 'integrations') {
|
|
||||||
return 'credentials'
|
|
||||||
}
|
|
||||||
return activeSection
|
return activeSection
|
||||||
}, [activeSection])
|
}, [activeSection])
|
||||||
|
|
||||||
@@ -340,7 +342,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
(sectionId: SettingsSection) => {
|
(sectionId: SettingsSection) => {
|
||||||
if (sectionId === effectiveActiveSection) return
|
if (sectionId === effectiveActiveSection) return
|
||||||
|
|
||||||
if (effectiveActiveSection === 'credentials' && environmentBeforeLeaveHandler.current) {
|
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -368,11 +370,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||||
if (event.detail.tab === 'environment' || event.detail.tab === 'integrations') {
|
setActiveSection(event.detail.tab)
|
||||||
setActiveSection('credentials')
|
|
||||||
} else {
|
|
||||||
setActiveSection(event.detail.tab)
|
|
||||||
}
|
|
||||||
onOpenChange(true)
|
onOpenChange(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,19 +479,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||||
if (
|
if (
|
||||||
!newOpen &&
|
!newOpen &&
|
||||||
effectiveActiveSection === 'credentials' &&
|
effectiveActiveSection === 'environment' &&
|
||||||
environmentBeforeLeaveHandler.current
|
environmentBeforeLeaveHandler.current
|
||||||
) {
|
) {
|
||||||
environmentBeforeLeaveHandler.current(() => {
|
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
|
||||||
if (integrationsCloseHandler.current) {
|
|
||||||
integrationsCloseHandler.current(newOpen)
|
|
||||||
} else {
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (
|
} else if (
|
||||||
!newOpen &&
|
!newOpen &&
|
||||||
effectiveActiveSection === 'credentials' &&
|
effectiveActiveSection === 'integrations' &&
|
||||||
integrationsCloseHandler.current
|
integrationsCloseHandler.current
|
||||||
) {
|
) {
|
||||||
integrationsCloseHandler.current(newOpen)
|
integrationsCloseHandler.current(newOpen)
|
||||||
@@ -510,7 +502,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
</VisuallyHidden.Root>
|
</VisuallyHidden.Root>
|
||||||
<VisuallyHidden.Root>
|
<VisuallyHidden.Root>
|
||||||
<DialogPrimitive.Description>
|
<DialogPrimitive.Description>
|
||||||
Configure your workspace settings, credentials, and preferences
|
Configure your workspace settings, environment variables, integrations, and preferences
|
||||||
</DialogPrimitive.Description>
|
</DialogPrimitive.Description>
|
||||||
</VisuallyHidden.Root>
|
</VisuallyHidden.Root>
|
||||||
|
|
||||||
@@ -547,14 +539,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
</SModalMainHeader>
|
</SModalMainHeader>
|
||||||
<SModalMainBody>
|
<SModalMainBody>
|
||||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||||
{effectiveActiveSection === 'credentials' && (
|
{effectiveActiveSection === 'environment' && (
|
||||||
<Credentials
|
<EnvironmentVariables
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
|
||||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||||
|
{effectiveActiveSection === 'integrations' && (
|
||||||
|
<Integrations
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||||
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
||||||
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||||
|
|||||||
@@ -589,6 +589,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
|||||||
|
|
||||||
export const scheduleExecution = task({
|
export const scheduleExecution = task({
|
||||||
id: 'schedule-execution',
|
id: 'schedule-execution',
|
||||||
|
machine: 'medium-1x',
|
||||||
retry: {
|
retry: {
|
||||||
maxAttempts: 1,
|
maxAttempts: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ async function executeWebhookJobInternal(
|
|||||||
|
|
||||||
export const webhookExecution = task({
|
export const webhookExecution = task({
|
||||||
id: 'webhook-execution',
|
id: 'webhook-execution',
|
||||||
|
machine: 'medium-1x',
|
||||||
retry: {
|
retry: {
|
||||||
maxAttempts: 1,
|
maxAttempts: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,5 +197,6 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
|||||||
|
|
||||||
export const workflowExecutionTask = task({
|
export const workflowExecutionTask = task({
|
||||||
id: 'workflow-execution',
|
id: 'workflow-execution',
|
||||||
|
machine: 'medium-1x',
|
||||||
run: executeWorkflowJob,
|
run: executeWorkflowJob,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BlockConfig } from '@/blocks/types'
|
|||||||
import { AuthMode } from '@/blocks/types'
|
import { AuthMode } from '@/blocks/types'
|
||||||
import { normalizeFileInput } from '@/blocks/utils'
|
import { normalizeFileInput } from '@/blocks/utils'
|
||||||
import type { ConfluenceResponse } from '@/tools/confluence/types'
|
import type { ConfluenceResponse } from '@/tools/confluence/types'
|
||||||
|
import { getTrigger } from '@/triggers'
|
||||||
|
|
||||||
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||||
type: 'confluence',
|
type: 'confluence',
|
||||||
@@ -394,6 +395,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Page Property Operations
|
// Page Property Operations
|
||||||
{ label: 'List Page Properties', id: 'list_page_properties' },
|
{ label: 'List Page Properties', id: 'list_page_properties' },
|
||||||
{ label: 'Create Page Property', id: 'create_page_property' },
|
{ label: 'Create Page Property', id: 'create_page_property' },
|
||||||
|
{ label: 'Update Page Property', id: 'update_page_property' },
|
||||||
|
{ label: 'Delete Page Property', id: 'delete_page_property' },
|
||||||
// Search Operations
|
// Search Operations
|
||||||
{ label: 'Search Content', id: 'search' },
|
{ label: 'Search Content', id: 'search' },
|
||||||
{ label: 'Search in Space', id: 'search_in_space' },
|
{ label: 'Search in Space', id: 'search_in_space' },
|
||||||
@@ -401,6 +404,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
{ label: 'List Blog Posts', id: 'list_blogposts' },
|
{ label: 'List Blog Posts', id: 'list_blogposts' },
|
||||||
{ label: 'Get Blog Post', id: 'get_blogpost' },
|
{ label: 'Get Blog Post', id: 'get_blogpost' },
|
||||||
{ label: 'Create Blog Post', id: 'create_blogpost' },
|
{ label: 'Create Blog Post', id: 'create_blogpost' },
|
||||||
|
{ label: 'Update Blog Post', id: 'update_blogpost' },
|
||||||
|
{ label: 'Delete Blog Post', id: 'delete_blogpost' },
|
||||||
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
|
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
|
||||||
// Comment Operations
|
// Comment Operations
|
||||||
{ label: 'Create Comment', id: 'create_comment' },
|
{ label: 'Create Comment', id: 'create_comment' },
|
||||||
@@ -414,6 +419,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Label Operations
|
// Label Operations
|
||||||
{ label: 'List Labels', id: 'list_labels' },
|
{ label: 'List Labels', id: 'list_labels' },
|
||||||
{ label: 'Add Label', id: 'add_label' },
|
{ label: 'Add Label', id: 'add_label' },
|
||||||
|
{ label: 'Delete Label', id: 'delete_label' },
|
||||||
|
{ label: 'Get Pages by Label', id: 'get_pages_by_label' },
|
||||||
|
{ label: 'List Space Labels', id: 'list_space_labels' },
|
||||||
// Space Operations
|
// Space Operations
|
||||||
{ label: 'Get Space', id: 'get_space' },
|
{ label: 'Get Space', id: 'get_space' },
|
||||||
{ label: 'List Spaces', id: 'list_spaces' },
|
{ label: 'List Spaces', id: 'list_spaces' },
|
||||||
@@ -480,11 +488,16 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_pages_in_space',
|
'list_pages_in_space',
|
||||||
'list_blogposts',
|
'list_blogposts',
|
||||||
'get_blogpost',
|
'get_blogpost',
|
||||||
|
'create_blogpost',
|
||||||
|
'update_blogpost',
|
||||||
|
'delete_blogpost',
|
||||||
'list_blogposts_in_space',
|
'list_blogposts_in_space',
|
||||||
'search',
|
'search',
|
||||||
'search_in_space',
|
'search_in_space',
|
||||||
'get_space',
|
'get_space',
|
||||||
'list_spaces',
|
'list_spaces',
|
||||||
|
'get_pages_by_label',
|
||||||
|
'list_space_labels',
|
||||||
],
|
],
|
||||||
not: true,
|
not: true,
|
||||||
},
|
},
|
||||||
@@ -500,6 +513,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_labels',
|
'list_labels',
|
||||||
'upload_attachment',
|
'upload_attachment',
|
||||||
'add_label',
|
'add_label',
|
||||||
|
'delete_label',
|
||||||
|
'delete_page_property',
|
||||||
|
'update_page_property',
|
||||||
'get_page_children',
|
'get_page_children',
|
||||||
'get_page_ancestors',
|
'get_page_ancestors',
|
||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
@@ -522,11 +538,16 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_pages_in_space',
|
'list_pages_in_space',
|
||||||
'list_blogposts',
|
'list_blogposts',
|
||||||
'get_blogpost',
|
'get_blogpost',
|
||||||
|
'create_blogpost',
|
||||||
|
'update_blogpost',
|
||||||
|
'delete_blogpost',
|
||||||
'list_blogposts_in_space',
|
'list_blogposts_in_space',
|
||||||
'search',
|
'search',
|
||||||
'search_in_space',
|
'search_in_space',
|
||||||
'get_space',
|
'get_space',
|
||||||
'list_spaces',
|
'list_spaces',
|
||||||
|
'get_pages_by_label',
|
||||||
|
'list_space_labels',
|
||||||
],
|
],
|
||||||
not: true,
|
not: true,
|
||||||
},
|
},
|
||||||
@@ -542,6 +563,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_labels',
|
'list_labels',
|
||||||
'upload_attachment',
|
'upload_attachment',
|
||||||
'add_label',
|
'add_label',
|
||||||
|
'delete_label',
|
||||||
|
'delete_page_property',
|
||||||
|
'update_page_property',
|
||||||
'get_page_children',
|
'get_page_children',
|
||||||
'get_page_ancestors',
|
'get_page_ancestors',
|
||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
@@ -566,6 +590,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'search_in_space',
|
'search_in_space',
|
||||||
'create_blogpost',
|
'create_blogpost',
|
||||||
'list_blogposts_in_space',
|
'list_blogposts_in_space',
|
||||||
|
'list_space_labels',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -575,7 +600,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter blog post ID',
|
placeholder: 'Enter blog post ID',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: 'get_blogpost' },
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['get_blogpost', 'update_blogpost', 'delete_blogpost'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'versionNumber',
|
id: 'versionNumber',
|
||||||
@@ -591,7 +619,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter property key/name',
|
placeholder: 'Enter property key/name',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: 'create_page_property' },
|
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'propertyValue',
|
id: 'propertyValue',
|
||||||
@@ -599,21 +627,46 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'long-input',
|
type: 'long-input',
|
||||||
placeholder: 'Enter property value (JSON supported)',
|
placeholder: 'Enter property value (JSON supported)',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: 'create_page_property' },
|
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'propertyId',
|
||||||
|
title: 'Property ID',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Enter property ID',
|
||||||
|
required: true,
|
||||||
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['delete_page_property', 'update_page_property'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'propertyVersionNumber',
|
||||||
|
title: 'Property Version Number',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Enter current version number of the property',
|
||||||
|
required: true,
|
||||||
|
condition: { field: 'operation', value: 'update_page_property' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'title',
|
id: 'title',
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter title',
|
placeholder: 'Enter title',
|
||||||
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'content',
|
id: 'content',
|
||||||
title: 'Content',
|
title: 'Content',
|
||||||
type: 'long-input',
|
type: 'long-input',
|
||||||
placeholder: 'Enter content',
|
placeholder: 'Enter content',
|
||||||
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'parentId',
|
id: 'parentId',
|
||||||
@@ -694,7 +747,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter label name',
|
placeholder: 'Enter label name',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: 'add_label' },
|
condition: { field: 'operation', value: ['add_label', 'delete_label'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'labelPrefix',
|
id: 'labelPrefix',
|
||||||
@@ -709,6 +762,14 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
value: () => 'global',
|
value: () => 'global',
|
||||||
condition: { field: 'operation', value: 'add_label' },
|
condition: { field: 'operation', value: 'add_label' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'labelId',
|
||||||
|
title: 'Label ID',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Enter label ID',
|
||||||
|
required: true,
|
||||||
|
condition: { field: 'operation', value: 'get_pages_by_label' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'blogPostStatus',
|
id: 'blogPostStatus',
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
@@ -718,7 +779,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
{ label: 'Draft', id: 'draft' },
|
{ label: 'Draft', id: 'draft' },
|
||||||
],
|
],
|
||||||
value: () => 'current',
|
value: () => 'current',
|
||||||
condition: { field: 'operation', value: 'create_blogpost' },
|
condition: { field: 'operation', value: ['create_blogpost', 'update_blogpost'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'purge',
|
id: 'purge',
|
||||||
@@ -759,6 +820,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
'list_page_properties',
|
'list_page_properties',
|
||||||
'list_labels',
|
'list_labels',
|
||||||
|
'get_pages_by_label',
|
||||||
|
'list_space_labels',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -780,10 +843,51 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
'list_page_properties',
|
'list_page_properties',
|
||||||
'list_labels',
|
'list_labels',
|
||||||
|
'get_pages_by_label',
|
||||||
|
'list_space_labels',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Trigger subBlocks
|
||||||
|
...getTrigger('confluence_page_created').subBlocks,
|
||||||
|
...getTrigger('confluence_page_updated').subBlocks,
|
||||||
|
...getTrigger('confluence_page_removed').subBlocks,
|
||||||
|
...getTrigger('confluence_page_moved').subBlocks,
|
||||||
|
...getTrigger('confluence_comment_created').subBlocks,
|
||||||
|
...getTrigger('confluence_comment_removed').subBlocks,
|
||||||
|
...getTrigger('confluence_blog_created').subBlocks,
|
||||||
|
...getTrigger('confluence_blog_updated').subBlocks,
|
||||||
|
...getTrigger('confluence_blog_removed').subBlocks,
|
||||||
|
...getTrigger('confluence_attachment_created').subBlocks,
|
||||||
|
...getTrigger('confluence_attachment_removed').subBlocks,
|
||||||
|
...getTrigger('confluence_space_created').subBlocks,
|
||||||
|
...getTrigger('confluence_space_updated').subBlocks,
|
||||||
|
...getTrigger('confluence_label_added').subBlocks,
|
||||||
|
...getTrigger('confluence_label_removed').subBlocks,
|
||||||
|
...getTrigger('confluence_webhook').subBlocks,
|
||||||
],
|
],
|
||||||
|
triggers: {
|
||||||
|
enabled: true,
|
||||||
|
available: [
|
||||||
|
'confluence_page_created',
|
||||||
|
'confluence_page_updated',
|
||||||
|
'confluence_page_removed',
|
||||||
|
'confluence_page_moved',
|
||||||
|
'confluence_comment_created',
|
||||||
|
'confluence_comment_removed',
|
||||||
|
'confluence_blog_created',
|
||||||
|
'confluence_blog_updated',
|
||||||
|
'confluence_blog_removed',
|
||||||
|
'confluence_attachment_created',
|
||||||
|
'confluence_attachment_removed',
|
||||||
|
'confluence_space_created',
|
||||||
|
'confluence_space_updated',
|
||||||
|
'confluence_label_added',
|
||||||
|
'confluence_label_removed',
|
||||||
|
'confluence_webhook',
|
||||||
|
],
|
||||||
|
},
|
||||||
tools: {
|
tools: {
|
||||||
access: [
|
access: [
|
||||||
// Page Tools
|
// Page Tools
|
||||||
@@ -800,6 +904,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Property Tools
|
// Property Tools
|
||||||
'confluence_list_page_properties',
|
'confluence_list_page_properties',
|
||||||
'confluence_create_page_property',
|
'confluence_create_page_property',
|
||||||
|
'confluence_update_page_property',
|
||||||
|
'confluence_delete_page_property',
|
||||||
// Search Tools
|
// Search Tools
|
||||||
'confluence_search',
|
'confluence_search',
|
||||||
'confluence_search_in_space',
|
'confluence_search_in_space',
|
||||||
@@ -807,6 +913,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'confluence_list_blogposts',
|
'confluence_list_blogposts',
|
||||||
'confluence_get_blogpost',
|
'confluence_get_blogpost',
|
||||||
'confluence_create_blogpost',
|
'confluence_create_blogpost',
|
||||||
|
'confluence_update_blogpost',
|
||||||
|
'confluence_delete_blogpost',
|
||||||
'confluence_list_blogposts_in_space',
|
'confluence_list_blogposts_in_space',
|
||||||
// Comment Tools
|
// Comment Tools
|
||||||
'confluence_create_comment',
|
'confluence_create_comment',
|
||||||
@@ -820,6 +928,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Label Tools
|
// Label Tools
|
||||||
'confluence_list_labels',
|
'confluence_list_labels',
|
||||||
'confluence_add_label',
|
'confluence_add_label',
|
||||||
|
'confluence_delete_label',
|
||||||
|
'confluence_get_pages_by_label',
|
||||||
|
'confluence_list_space_labels',
|
||||||
// Space Tools
|
// Space Tools
|
||||||
'confluence_get_space',
|
'confluence_get_space',
|
||||||
'confluence_list_spaces',
|
'confluence_list_spaces',
|
||||||
@@ -852,6 +963,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
return 'confluence_list_page_properties'
|
return 'confluence_list_page_properties'
|
||||||
case 'create_page_property':
|
case 'create_page_property':
|
||||||
return 'confluence_create_page_property'
|
return 'confluence_create_page_property'
|
||||||
|
case 'update_page_property':
|
||||||
|
return 'confluence_update_page_property'
|
||||||
|
case 'delete_page_property':
|
||||||
|
return 'confluence_delete_page_property'
|
||||||
// Search Operations
|
// Search Operations
|
||||||
case 'search':
|
case 'search':
|
||||||
return 'confluence_search'
|
return 'confluence_search'
|
||||||
@@ -864,6 +979,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
return 'confluence_get_blogpost'
|
return 'confluence_get_blogpost'
|
||||||
case 'create_blogpost':
|
case 'create_blogpost':
|
||||||
return 'confluence_create_blogpost'
|
return 'confluence_create_blogpost'
|
||||||
|
case 'update_blogpost':
|
||||||
|
return 'confluence_update_blogpost'
|
||||||
|
case 'delete_blogpost':
|
||||||
|
return 'confluence_delete_blogpost'
|
||||||
case 'list_blogposts_in_space':
|
case 'list_blogposts_in_space':
|
||||||
return 'confluence_list_blogposts_in_space'
|
return 'confluence_list_blogposts_in_space'
|
||||||
// Comment Operations
|
// Comment Operations
|
||||||
@@ -887,6 +1006,12 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
return 'confluence_list_labels'
|
return 'confluence_list_labels'
|
||||||
case 'add_label':
|
case 'add_label':
|
||||||
return 'confluence_add_label'
|
return 'confluence_add_label'
|
||||||
|
case 'delete_label':
|
||||||
|
return 'confluence_delete_label'
|
||||||
|
case 'get_pages_by_label':
|
||||||
|
return 'confluence_get_pages_by_label'
|
||||||
|
case 'list_space_labels':
|
||||||
|
return 'confluence_list_space_labels'
|
||||||
// Space Operations
|
// Space Operations
|
||||||
case 'get_space':
|
case 'get_space':
|
||||||
return 'confluence_get_space'
|
return 'confluence_get_space'
|
||||||
@@ -908,7 +1033,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
versionNumber,
|
versionNumber,
|
||||||
propertyKey,
|
propertyKey,
|
||||||
propertyValue,
|
propertyValue,
|
||||||
|
propertyId,
|
||||||
|
propertyVersionNumber,
|
||||||
labelPrefix,
|
labelPrefix,
|
||||||
|
labelId,
|
||||||
blogPostStatus,
|
blogPostStatus,
|
||||||
purge,
|
purge,
|
||||||
bodyFormat,
|
bodyFormat,
|
||||||
@@ -938,6 +1066,25 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operation === 'update_blogpost') {
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
operation,
|
||||||
|
blogPostId,
|
||||||
|
status: blogPostStatus || undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'delete_blogpost') {
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
operation,
|
||||||
|
blogPostId,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (operation === 'delete') {
|
if (operation === 'delete') {
|
||||||
return {
|
return {
|
||||||
credential,
|
credential,
|
||||||
@@ -959,7 +1106,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operations that support cursor pagination
|
// Operations that support generic cursor pagination.
|
||||||
|
// get_pages_by_label and list_space_labels have dedicated handlers
|
||||||
|
// below that pass cursor along with their required params (labelId, spaceId).
|
||||||
const supportsCursor = [
|
const supportsCursor = [
|
||||||
'list_attachments',
|
'list_attachments',
|
||||||
'list_spaces',
|
'list_spaces',
|
||||||
@@ -996,6 +1145,53 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operation === 'update_page_property') {
|
||||||
|
if (!propertyKey) {
|
||||||
|
throw new Error('Property key is required for this operation.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
pageId: effectivePageId,
|
||||||
|
operation,
|
||||||
|
propertyId,
|
||||||
|
key: propertyKey,
|
||||||
|
value: propertyValue,
|
||||||
|
versionNumber: propertyVersionNumber
|
||||||
|
? Number.parseInt(String(propertyVersionNumber), 10)
|
||||||
|
: undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'delete_page_property') {
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
pageId: effectivePageId,
|
||||||
|
operation,
|
||||||
|
propertyId,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'get_pages_by_label') {
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
operation,
|
||||||
|
labelId,
|
||||||
|
cursor: cursor || undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'list_space_labels') {
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
operation,
|
||||||
|
cursor: cursor || undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (operation === 'upload_attachment') {
|
if (operation === 'upload_attachment') {
|
||||||
const normalizedFile = normalizeFileInput(attachmentFile, { single: true })
|
const normalizedFile = normalizeFileInput(attachmentFile, { single: true })
|
||||||
if (!normalizedFile) {
|
if (!normalizedFile) {
|
||||||
@@ -1044,7 +1240,13 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
|
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
|
||||||
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
|
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
|
||||||
labelName: { type: 'string', description: 'Label name' },
|
labelName: { type: 'string', description: 'Label name' },
|
||||||
|
labelId: { type: 'string', description: 'Label identifier' },
|
||||||
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
|
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
|
||||||
|
propertyId: { type: 'string', description: 'Property identifier' },
|
||||||
|
propertyVersionNumber: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Current version number of the property',
|
||||||
|
},
|
||||||
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
|
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
|
||||||
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
|
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
|
||||||
bodyFormat: { type: 'string', description: 'Body format for comments' },
|
bodyFormat: { type: 'string', description: 'Body format for comments' },
|
||||||
@@ -1080,6 +1282,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Label Results
|
// Label Results
|
||||||
labels: { type: 'array', description: 'List of labels' },
|
labels: { type: 'array', description: 'List of labels' },
|
||||||
labelName: { type: 'string', description: 'Label name' },
|
labelName: { type: 'string', description: 'Label name' },
|
||||||
|
labelId: { type: 'string', description: 'Label identifier' },
|
||||||
// Space Results
|
// Space Results
|
||||||
spaces: { type: 'array', description: 'List of spaces' },
|
spaces: { type: 'array', description: 'List of spaces' },
|
||||||
spaceId: { type: 'string', description: 'Space identifier' },
|
spaceId: { type: 'string', description: 'Space identifier' },
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
|||||||
'delete:issue-worklog:jira',
|
'delete:issue-worklog:jira',
|
||||||
'write:issue-link:jira',
|
'write:issue-link:jira',
|
||||||
'delete:issue-link:jira',
|
'delete:issue-link:jira',
|
||||||
|
'manage:jira-project',
|
||||||
|
'read:board-scope:jira-software',
|
||||||
|
'write:board-scope:jira-software',
|
||||||
|
'read:sprint:jira-software',
|
||||||
|
'write:sprint:jira-software',
|
||||||
|
'delete:sprint:jira-software',
|
||||||
],
|
],
|
||||||
placeholder: 'Select Jira account',
|
placeholder: 'Select Jira account',
|
||||||
},
|
},
|
||||||
@@ -695,7 +701,32 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
...getTrigger('jira_issue_updated').subBlocks,
|
...getTrigger('jira_issue_updated').subBlocks,
|
||||||
...getTrigger('jira_issue_deleted').subBlocks,
|
...getTrigger('jira_issue_deleted').subBlocks,
|
||||||
...getTrigger('jira_issue_commented').subBlocks,
|
...getTrigger('jira_issue_commented').subBlocks,
|
||||||
|
...getTrigger('jira_comment_updated').subBlocks,
|
||||||
|
...getTrigger('jira_comment_deleted').subBlocks,
|
||||||
...getTrigger('jira_worklog_created').subBlocks,
|
...getTrigger('jira_worklog_created').subBlocks,
|
||||||
|
...getTrigger('jira_worklog_updated').subBlocks,
|
||||||
|
...getTrigger('jira_worklog_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_sprint_created').subBlocks,
|
||||||
|
...getTrigger('jira_sprint_started').subBlocks,
|
||||||
|
...getTrigger('jira_sprint_closed').subBlocks,
|
||||||
|
...getTrigger('jira_sprint_updated').subBlocks,
|
||||||
|
...getTrigger('jira_sprint_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_project_created').subBlocks,
|
||||||
|
...getTrigger('jira_project_updated').subBlocks,
|
||||||
|
...getTrigger('jira_project_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_version_created').subBlocks,
|
||||||
|
...getTrigger('jira_version_released').subBlocks,
|
||||||
|
...getTrigger('jira_version_unreleased').subBlocks,
|
||||||
|
...getTrigger('jira_version_updated').subBlocks,
|
||||||
|
...getTrigger('jira_version_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_board_created').subBlocks,
|
||||||
|
...getTrigger('jira_board_updated').subBlocks,
|
||||||
|
...getTrigger('jira_board_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_board_config_changed').subBlocks,
|
||||||
|
...getTrigger('jira_attachment_created').subBlocks,
|
||||||
|
...getTrigger('jira_attachment_deleted').subBlocks,
|
||||||
|
...getTrigger('jira_issuelink_created').subBlocks,
|
||||||
|
...getTrigger('jira_issuelink_deleted').subBlocks,
|
||||||
...getTrigger('jira_webhook').subBlocks,
|
...getTrigger('jira_webhook').subBlocks,
|
||||||
],
|
],
|
||||||
tools: {
|
tools: {
|
||||||
@@ -1240,7 +1271,32 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
'jira_issue_updated',
|
'jira_issue_updated',
|
||||||
'jira_issue_deleted',
|
'jira_issue_deleted',
|
||||||
'jira_issue_commented',
|
'jira_issue_commented',
|
||||||
|
'jira_comment_updated',
|
||||||
|
'jira_comment_deleted',
|
||||||
'jira_worklog_created',
|
'jira_worklog_created',
|
||||||
|
'jira_worklog_updated',
|
||||||
|
'jira_worklog_deleted',
|
||||||
|
'jira_sprint_created',
|
||||||
|
'jira_sprint_started',
|
||||||
|
'jira_sprint_closed',
|
||||||
|
'jira_sprint_updated',
|
||||||
|
'jira_sprint_deleted',
|
||||||
|
'jira_project_created',
|
||||||
|
'jira_project_updated',
|
||||||
|
'jira_project_deleted',
|
||||||
|
'jira_version_created',
|
||||||
|
'jira_version_released',
|
||||||
|
'jira_version_unreleased',
|
||||||
|
'jira_version_updated',
|
||||||
|
'jira_version_deleted',
|
||||||
|
'jira_board_created',
|
||||||
|
'jira_board_updated',
|
||||||
|
'jira_board_deleted',
|
||||||
|
'jira_board_config_changed',
|
||||||
|
'jira_attachment_created',
|
||||||
|
'jira_attachment_deleted',
|
||||||
|
'jira_issuelink_created',
|
||||||
|
'jira_issuelink_deleted',
|
||||||
'jira_webhook',
|
'jira_webhook',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { JiraServiceManagementIcon } from '@/components/icons'
|
|||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
import { AuthMode } from '@/blocks/types'
|
import { AuthMode } from '@/blocks/types'
|
||||||
import type { JsmResponse } from '@/tools/jsm/types'
|
import type { JsmResponse } from '@/tools/jsm/types'
|
||||||
|
import { getTrigger } from '@/triggers'
|
||||||
|
|
||||||
export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||||
type: 'jira_service_management',
|
type: 'jira_service_management',
|
||||||
name: 'Jira Service Management',
|
name: 'Jira Service Management',
|
||||||
description: 'Interact with Jira Service Management',
|
description: 'Interact with Jira Service Management',
|
||||||
authMode: AuthMode.OAuth,
|
authMode: AuthMode.OAuth,
|
||||||
|
triggerAllowed: true,
|
||||||
longDescription:
|
longDescription:
|
||||||
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.',
|
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.',
|
||||||
docsLink: 'https://docs.sim.ai/tools/jira-service-management',
|
docsLink: 'https://docs.sim.ai/tools/jira-service-management',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
bgColor: '#E0E0E0',
|
bgColor: '#E0E0E0',
|
||||||
@@ -21,26 +23,46 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Get Service Desks', id: 'get_service_desks' },
|
{ label: 'Get Service Desks', id: 'get_service_desks' },
|
||||||
|
{ label: 'Get Service Desk', id: 'get_service_desk' },
|
||||||
{ label: 'Get Request Types', id: 'get_request_types' },
|
{ label: 'Get Request Types', id: 'get_request_types' },
|
||||||
|
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
|
||||||
{ label: 'Create Request', id: 'create_request' },
|
{ label: 'Create Request', id: 'create_request' },
|
||||||
{ label: 'Get Request', id: 'get_request' },
|
{ label: 'Get Request', id: 'get_request' },
|
||||||
{ label: 'Get Requests', id: 'get_requests' },
|
{ label: 'Get Requests', id: 'get_requests' },
|
||||||
|
{ label: 'Get Request Status', id: 'get_request_status' },
|
||||||
|
{ label: 'Get Request Attachments', id: 'get_request_attachments' },
|
||||||
{ label: 'Add Comment', id: 'add_comment' },
|
{ label: 'Add Comment', id: 'add_comment' },
|
||||||
{ label: 'Get Comments', id: 'get_comments' },
|
{ label: 'Get Comments', id: 'get_comments' },
|
||||||
{ label: 'Get Customers', id: 'get_customers' },
|
{ label: 'Get Customers', id: 'get_customers' },
|
||||||
{ label: 'Add Customer', id: 'add_customer' },
|
{ label: 'Add Customer', id: 'add_customer' },
|
||||||
|
{ label: 'Remove Customer', id: 'remove_customer' },
|
||||||
|
{ label: 'Create Customer', id: 'create_customer' },
|
||||||
{ label: 'Get Organizations', id: 'get_organizations' },
|
{ label: 'Get Organizations', id: 'get_organizations' },
|
||||||
|
{ label: 'Get Organization', id: 'get_organization' },
|
||||||
{ label: 'Create Organization', id: 'create_organization' },
|
{ label: 'Create Organization', id: 'create_organization' },
|
||||||
{ label: 'Add Organization', id: 'add_organization' },
|
{ label: 'Add Organization', id: 'add_organization' },
|
||||||
|
{ label: 'Remove Organization', id: 'remove_organization' },
|
||||||
|
{ label: 'Delete Organization', id: 'delete_organization' },
|
||||||
|
{ label: 'Get Organization Users', id: 'get_organization_users' },
|
||||||
|
{ label: 'Add Organization Users', id: 'add_organization_users' },
|
||||||
|
{ label: 'Remove Organization Users', id: 'remove_organization_users' },
|
||||||
{ label: 'Get Queues', id: 'get_queues' },
|
{ label: 'Get Queues', id: 'get_queues' },
|
||||||
|
{ label: 'Get Queue Issues', id: 'get_queue_issues' },
|
||||||
{ label: 'Get SLA', id: 'get_sla' },
|
{ label: 'Get SLA', id: 'get_sla' },
|
||||||
{ label: 'Get Transitions', id: 'get_transitions' },
|
{ label: 'Get Transitions', id: 'get_transitions' },
|
||||||
{ label: 'Transition Request', id: 'transition_request' },
|
{ label: 'Transition Request', id: 'transition_request' },
|
||||||
{ label: 'Get Participants', id: 'get_participants' },
|
{ label: 'Get Participants', id: 'get_participants' },
|
||||||
{ label: 'Add Participants', id: 'add_participants' },
|
{ label: 'Add Participants', id: 'add_participants' },
|
||||||
|
{ label: 'Remove Participants', id: 'remove_participants' },
|
||||||
{ label: 'Get Approvals', id: 'get_approvals' },
|
{ label: 'Get Approvals', id: 'get_approvals' },
|
||||||
{ label: 'Answer Approval', id: 'answer_approval' },
|
{ label: 'Answer Approval', id: 'answer_approval' },
|
||||||
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
|
{ label: 'Get Feedback', id: 'get_feedback' },
|
||||||
|
{ label: 'Add Feedback', id: 'add_feedback' },
|
||||||
|
{ label: 'Delete Feedback', id: 'delete_feedback' },
|
||||||
|
{ label: 'Get Notification', id: 'get_notification' },
|
||||||
|
{ label: 'Subscribe Notification', id: 'subscribe_notification' },
|
||||||
|
{ label: 'Unsubscribe Notification', id: 'unsubscribe_notification' },
|
||||||
|
{ label: 'Search Knowledge Base', id: 'search_knowledge_base' },
|
||||||
],
|
],
|
||||||
value: () => 'get_service_desks',
|
value: () => 'get_service_desks',
|
||||||
},
|
},
|
||||||
@@ -92,6 +114,18 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
'write:request.participant:jira-service-management',
|
'write:request.participant:jira-service-management',
|
||||||
'read:request.approval:jira-service-management',
|
'read:request.approval:jira-service-management',
|
||||||
'write:request.approval:jira-service-management',
|
'write:request.approval:jira-service-management',
|
||||||
|
'read:request.feedback:jira-service-management',
|
||||||
|
'write:request.feedback:jira-service-management',
|
||||||
|
'delete:request.feedback:jira-service-management',
|
||||||
|
'read:request.notification:jira-service-management',
|
||||||
|
'write:request.notification:jira-service-management',
|
||||||
|
'delete:request.notification:jira-service-management',
|
||||||
|
'read:request.attachment:jira-service-management',
|
||||||
|
'read:knowledgebase:jira-service-management',
|
||||||
|
'read:organization.user:jira-service-management',
|
||||||
|
'write:organization.user:jira-service-management',
|
||||||
|
'delete:organization:jira-service-management',
|
||||||
|
'delete:servicedesk.customer:jira-service-management',
|
||||||
],
|
],
|
||||||
placeholder: 'Select Jira account',
|
placeholder: 'Select Jira account',
|
||||||
},
|
},
|
||||||
@@ -103,15 +137,20 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
condition: {
|
condition: {
|
||||||
field: 'operation',
|
field: 'operation',
|
||||||
value: [
|
value: [
|
||||||
|
'get_service_desk',
|
||||||
'get_request_types',
|
'get_request_types',
|
||||||
'create_request',
|
'create_request',
|
||||||
'get_customers',
|
'get_customers',
|
||||||
'add_customer',
|
'add_customer',
|
||||||
|
'remove_customer',
|
||||||
'get_organizations',
|
'get_organizations',
|
||||||
'add_organization',
|
'add_organization',
|
||||||
|
'remove_organization',
|
||||||
'get_queues',
|
'get_queues',
|
||||||
|
'get_queue_issues',
|
||||||
'get_requests',
|
'get_requests',
|
||||||
'get_request_type_fields',
|
'get_request_type_fields',
|
||||||
|
'search_knowledge_base',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -133,6 +172,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
field: 'operation',
|
field: 'operation',
|
||||||
value: [
|
value: [
|
||||||
'get_request',
|
'get_request',
|
||||||
|
'get_request_status',
|
||||||
|
'get_request_attachments',
|
||||||
'add_comment',
|
'add_comment',
|
||||||
'get_comments',
|
'get_comments',
|
||||||
'get_sla',
|
'get_sla',
|
||||||
@@ -140,8 +181,15 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
'transition_request',
|
'transition_request',
|
||||||
'get_participants',
|
'get_participants',
|
||||||
'add_participants',
|
'add_participants',
|
||||||
|
'remove_participants',
|
||||||
'get_approvals',
|
'get_approvals',
|
||||||
'answer_approval',
|
'answer_approval',
|
||||||
|
'get_feedback',
|
||||||
|
'add_feedback',
|
||||||
|
'delete_feedback',
|
||||||
|
'get_notification',
|
||||||
|
'subscribe_notification',
|
||||||
|
'unsubscribe_notification',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,7 +321,15 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Comma-separated Atlassian account IDs',
|
placeholder: 'Comma-separated Atlassian account IDs',
|
||||||
condition: { field: 'operation', value: 'add_customer' },
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: [
|
||||||
|
'add_customer',
|
||||||
|
'remove_customer',
|
||||||
|
'add_organization_users',
|
||||||
|
'remove_organization_users',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'customerQuery',
|
id: 'customerQuery',
|
||||||
@@ -366,7 +422,18 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Enter organization ID',
|
placeholder: 'Enter organization ID',
|
||||||
condition: { field: 'operation', value: 'add_organization' },
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: [
|
||||||
|
'add_organization',
|
||||||
|
'remove_organization',
|
||||||
|
'delete_organization',
|
||||||
|
'get_organization',
|
||||||
|
'get_organization_users',
|
||||||
|
'add_organization_users',
|
||||||
|
'remove_organization_users',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'participantAccountIds',
|
id: 'participantAccountIds',
|
||||||
@@ -374,7 +441,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Comma-separated account IDs',
|
placeholder: 'Comma-separated account IDs',
|
||||||
condition: { field: 'operation', value: 'add_participants' },
|
condition: { field: 'operation', value: ['add_participants', 'remove_participants'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'approvalId',
|
id: 'approvalId',
|
||||||
@@ -406,55 +473,165 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
'get_service_desks',
|
'get_service_desks',
|
||||||
'get_request_types',
|
'get_request_types',
|
||||||
'get_requests',
|
'get_requests',
|
||||||
|
'get_request_status',
|
||||||
|
'get_request_attachments',
|
||||||
'get_comments',
|
'get_comments',
|
||||||
'get_customers',
|
'get_customers',
|
||||||
'get_organizations',
|
'get_organizations',
|
||||||
|
'get_organization_users',
|
||||||
'get_queues',
|
'get_queues',
|
||||||
|
'get_queue_issues',
|
||||||
'get_sla',
|
'get_sla',
|
||||||
'get_transitions',
|
'get_transitions',
|
||||||
'get_participants',
|
'get_participants',
|
||||||
'get_approvals',
|
'get_approvals',
|
||||||
|
'search_knowledge_base',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'queueId',
|
||||||
|
title: 'Queue ID',
|
||||||
|
type: 'short-input',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter queue ID',
|
||||||
|
condition: { field: 'operation', value: 'get_queue_issues' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customerEmail',
|
||||||
|
title: 'Customer Email',
|
||||||
|
type: 'short-input',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter customer email address',
|
||||||
|
condition: { field: 'operation', value: 'create_customer' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customerDisplayName',
|
||||||
|
title: 'Display Name',
|
||||||
|
type: 'short-input',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter customer display name',
|
||||||
|
condition: { field: 'operation', value: 'create_customer' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'knowledgeBaseQuery',
|
||||||
|
title: 'Search Query',
|
||||||
|
type: 'short-input',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Search knowledge base articles',
|
||||||
|
condition: { field: 'operation', value: 'search_knowledge_base' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feedbackRating',
|
||||||
|
title: 'Rating',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: '1 - Very Unsatisfied', id: '1' },
|
||||||
|
{ label: '2 - Unsatisfied', id: '2' },
|
||||||
|
{ label: '3 - Neutral', id: '3' },
|
||||||
|
{ label: '4 - Satisfied', id: '4' },
|
||||||
|
{ label: '5 - Very Satisfied', id: '5' },
|
||||||
|
],
|
||||||
|
value: () => '5',
|
||||||
|
condition: { field: 'operation', value: 'add_feedback' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feedbackComment',
|
||||||
|
title: 'Feedback Comment',
|
||||||
|
type: 'long-input',
|
||||||
|
placeholder: 'Optional feedback comment',
|
||||||
|
condition: { field: 'operation', value: 'add_feedback' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'includeAttachments',
|
||||||
|
title: 'Include File Content',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'No', id: 'false' },
|
||||||
|
{ label: 'Yes', id: 'true' },
|
||||||
|
],
|
||||||
|
value: () => 'false',
|
||||||
|
condition: { field: 'operation', value: 'get_request_attachments' },
|
||||||
|
},
|
||||||
|
// Trigger SubBlocks
|
||||||
|
...getTrigger('jsm_request_created').subBlocks,
|
||||||
|
...getTrigger('jsm_request_updated').subBlocks,
|
||||||
|
...getTrigger('jsm_request_deleted').subBlocks,
|
||||||
|
...getTrigger('jsm_request_commented').subBlocks,
|
||||||
|
...getTrigger('jsm_comment_updated').subBlocks,
|
||||||
|
...getTrigger('jsm_comment_deleted').subBlocks,
|
||||||
|
...getTrigger('jsm_worklog_created').subBlocks,
|
||||||
|
...getTrigger('jsm_worklog_updated').subBlocks,
|
||||||
|
...getTrigger('jsm_worklog_deleted').subBlocks,
|
||||||
|
...getTrigger('jsm_attachment_created').subBlocks,
|
||||||
|
...getTrigger('jsm_attachment_deleted').subBlocks,
|
||||||
|
...getTrigger('jsm_webhook').subBlocks,
|
||||||
],
|
],
|
||||||
tools: {
|
tools: {
|
||||||
access: [
|
access: [
|
||||||
'jsm_get_service_desks',
|
'jsm_get_service_desks',
|
||||||
|
'jsm_get_service_desk',
|
||||||
'jsm_get_request_types',
|
'jsm_get_request_types',
|
||||||
|
'jsm_get_request_type_fields',
|
||||||
'jsm_create_request',
|
'jsm_create_request',
|
||||||
'jsm_get_request',
|
'jsm_get_request',
|
||||||
'jsm_get_requests',
|
'jsm_get_requests',
|
||||||
|
'jsm_get_request_status',
|
||||||
|
'jsm_get_request_attachments',
|
||||||
'jsm_add_comment',
|
'jsm_add_comment',
|
||||||
'jsm_get_comments',
|
'jsm_get_comments',
|
||||||
'jsm_get_customers',
|
'jsm_get_customers',
|
||||||
'jsm_add_customer',
|
'jsm_add_customer',
|
||||||
|
'jsm_remove_customer',
|
||||||
|
'jsm_create_customer',
|
||||||
'jsm_get_organizations',
|
'jsm_get_organizations',
|
||||||
|
'jsm_get_organization',
|
||||||
'jsm_create_organization',
|
'jsm_create_organization',
|
||||||
'jsm_add_organization',
|
'jsm_add_organization',
|
||||||
|
'jsm_remove_organization',
|
||||||
|
'jsm_delete_organization',
|
||||||
|
'jsm_get_organization_users',
|
||||||
|
'jsm_add_organization_users',
|
||||||
|
'jsm_remove_organization_users',
|
||||||
'jsm_get_queues',
|
'jsm_get_queues',
|
||||||
|
'jsm_get_queue_issues',
|
||||||
'jsm_get_sla',
|
'jsm_get_sla',
|
||||||
'jsm_get_transitions',
|
'jsm_get_transitions',
|
||||||
'jsm_transition_request',
|
'jsm_transition_request',
|
||||||
'jsm_get_participants',
|
'jsm_get_participants',
|
||||||
'jsm_add_participants',
|
'jsm_add_participants',
|
||||||
|
'jsm_remove_participants',
|
||||||
'jsm_get_approvals',
|
'jsm_get_approvals',
|
||||||
'jsm_answer_approval',
|
'jsm_answer_approval',
|
||||||
'jsm_get_request_type_fields',
|
'jsm_get_feedback',
|
||||||
|
'jsm_add_feedback',
|
||||||
|
'jsm_delete_feedback',
|
||||||
|
'jsm_get_notification',
|
||||||
|
'jsm_subscribe_notification',
|
||||||
|
'jsm_unsubscribe_notification',
|
||||||
|
'jsm_search_knowledge_base',
|
||||||
],
|
],
|
||||||
config: {
|
config: {
|
||||||
tool: (params) => {
|
tool: (params) => {
|
||||||
switch (params.operation) {
|
switch (params.operation) {
|
||||||
case 'get_service_desks':
|
case 'get_service_desks':
|
||||||
return 'jsm_get_service_desks'
|
return 'jsm_get_service_desks'
|
||||||
|
case 'get_service_desk':
|
||||||
|
return 'jsm_get_service_desk'
|
||||||
case 'get_request_types':
|
case 'get_request_types':
|
||||||
return 'jsm_get_request_types'
|
return 'jsm_get_request_types'
|
||||||
|
case 'get_request_type_fields':
|
||||||
|
return 'jsm_get_request_type_fields'
|
||||||
case 'create_request':
|
case 'create_request':
|
||||||
return 'jsm_create_request'
|
return 'jsm_create_request'
|
||||||
case 'get_request':
|
case 'get_request':
|
||||||
return 'jsm_get_request'
|
return 'jsm_get_request'
|
||||||
case 'get_requests':
|
case 'get_requests':
|
||||||
return 'jsm_get_requests'
|
return 'jsm_get_requests'
|
||||||
|
case 'get_request_status':
|
||||||
|
return 'jsm_get_request_status'
|
||||||
|
case 'get_request_attachments':
|
||||||
|
return 'jsm_get_request_attachments'
|
||||||
case 'add_comment':
|
case 'add_comment':
|
||||||
return 'jsm_add_comment'
|
return 'jsm_add_comment'
|
||||||
case 'get_comments':
|
case 'get_comments':
|
||||||
@@ -463,14 +640,32 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
return 'jsm_get_customers'
|
return 'jsm_get_customers'
|
||||||
case 'add_customer':
|
case 'add_customer':
|
||||||
return 'jsm_add_customer'
|
return 'jsm_add_customer'
|
||||||
|
case 'remove_customer':
|
||||||
|
return 'jsm_remove_customer'
|
||||||
|
case 'create_customer':
|
||||||
|
return 'jsm_create_customer'
|
||||||
case 'get_organizations':
|
case 'get_organizations':
|
||||||
return 'jsm_get_organizations'
|
return 'jsm_get_organizations'
|
||||||
|
case 'get_organization':
|
||||||
|
return 'jsm_get_organization'
|
||||||
case 'create_organization':
|
case 'create_organization':
|
||||||
return 'jsm_create_organization'
|
return 'jsm_create_organization'
|
||||||
case 'add_organization':
|
case 'add_organization':
|
||||||
return 'jsm_add_organization'
|
return 'jsm_add_organization'
|
||||||
|
case 'remove_organization':
|
||||||
|
return 'jsm_remove_organization'
|
||||||
|
case 'delete_organization':
|
||||||
|
return 'jsm_delete_organization'
|
||||||
|
case 'get_organization_users':
|
||||||
|
return 'jsm_get_organization_users'
|
||||||
|
case 'add_organization_users':
|
||||||
|
return 'jsm_add_organization_users'
|
||||||
|
case 'remove_organization_users':
|
||||||
|
return 'jsm_remove_organization_users'
|
||||||
case 'get_queues':
|
case 'get_queues':
|
||||||
return 'jsm_get_queues'
|
return 'jsm_get_queues'
|
||||||
|
case 'get_queue_issues':
|
||||||
|
return 'jsm_get_queue_issues'
|
||||||
case 'get_sla':
|
case 'get_sla':
|
||||||
return 'jsm_get_sla'
|
return 'jsm_get_sla'
|
||||||
case 'get_transitions':
|
case 'get_transitions':
|
||||||
@@ -481,12 +676,26 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
return 'jsm_get_participants'
|
return 'jsm_get_participants'
|
||||||
case 'add_participants':
|
case 'add_participants':
|
||||||
return 'jsm_add_participants'
|
return 'jsm_add_participants'
|
||||||
|
case 'remove_participants':
|
||||||
|
return 'jsm_remove_participants'
|
||||||
case 'get_approvals':
|
case 'get_approvals':
|
||||||
return 'jsm_get_approvals'
|
return 'jsm_get_approvals'
|
||||||
case 'answer_approval':
|
case 'answer_approval':
|
||||||
return 'jsm_answer_approval'
|
return 'jsm_answer_approval'
|
||||||
case 'get_request_type_fields':
|
case 'get_feedback':
|
||||||
return 'jsm_get_request_type_fields'
|
return 'jsm_get_feedback'
|
||||||
|
case 'add_feedback':
|
||||||
|
return 'jsm_add_feedback'
|
||||||
|
case 'delete_feedback':
|
||||||
|
return 'jsm_delete_feedback'
|
||||||
|
case 'get_notification':
|
||||||
|
return 'jsm_get_notification'
|
||||||
|
case 'subscribe_notification':
|
||||||
|
return 'jsm_subscribe_notification'
|
||||||
|
case 'unsubscribe_notification':
|
||||||
|
return 'jsm_unsubscribe_notification'
|
||||||
|
case 'search_knowledge_base':
|
||||||
|
return 'jsm_search_knowledge_base'
|
||||||
default:
|
default:
|
||||||
return 'jsm_get_service_desks'
|
return 'jsm_get_service_desks'
|
||||||
}
|
}
|
||||||
@@ -731,6 +940,204 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
serviceDeskId: params.serviceDeskId,
|
serviceDeskId: params.serviceDeskId,
|
||||||
requestTypeId: params.requestTypeId,
|
requestTypeId: params.requestTypeId,
|
||||||
}
|
}
|
||||||
|
case 'get_service_desk':
|
||||||
|
if (!params.serviceDeskId) {
|
||||||
|
throw new Error('Service Desk ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
serviceDeskId: params.serviceDeskId,
|
||||||
|
}
|
||||||
|
case 'get_request_status':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
||||||
|
}
|
||||||
|
case 'get_request_attachments':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
includeAttachments: params.includeAttachments === 'true',
|
||||||
|
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
||||||
|
}
|
||||||
|
case 'remove_customer': {
|
||||||
|
if (!params.serviceDeskId) {
|
||||||
|
throw new Error('Service Desk ID is required')
|
||||||
|
}
|
||||||
|
if (!params.accountIds) {
|
||||||
|
throw new Error('Account IDs are required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
serviceDeskId: params.serviceDeskId,
|
||||||
|
accountIds: params.accountIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'create_customer':
|
||||||
|
if (!params.customerEmail) {
|
||||||
|
throw new Error('Customer email is required')
|
||||||
|
}
|
||||||
|
if (!params.customerDisplayName) {
|
||||||
|
throw new Error('Customer display name is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
email: params.customerEmail,
|
||||||
|
displayName: params.customerDisplayName,
|
||||||
|
}
|
||||||
|
case 'get_organization':
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
}
|
||||||
|
case 'remove_organization':
|
||||||
|
if (!params.serviceDeskId) {
|
||||||
|
throw new Error('Service Desk ID is required')
|
||||||
|
}
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
serviceDeskId: params.serviceDeskId,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
}
|
||||||
|
case 'delete_organization':
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
}
|
||||||
|
case 'get_organization_users':
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
||||||
|
}
|
||||||
|
case 'add_organization_users':
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
if (!params.accountIds) {
|
||||||
|
throw new Error('Account IDs are required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
accountIds: params.accountIds,
|
||||||
|
}
|
||||||
|
case 'remove_organization_users':
|
||||||
|
if (!params.organizationId) {
|
||||||
|
throw new Error('Organization ID is required')
|
||||||
|
}
|
||||||
|
if (!params.accountIds) {
|
||||||
|
throw new Error('Account IDs are required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
accountIds: params.accountIds,
|
||||||
|
}
|
||||||
|
case 'get_queue_issues':
|
||||||
|
if (!params.serviceDeskId) {
|
||||||
|
throw new Error('Service Desk ID is required')
|
||||||
|
}
|
||||||
|
if (!params.queueId) {
|
||||||
|
throw new Error('Queue ID is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
serviceDeskId: params.serviceDeskId,
|
||||||
|
queueId: params.queueId,
|
||||||
|
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
||||||
|
}
|
||||||
|
case 'remove_participants':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
if (!params.participantAccountIds) {
|
||||||
|
throw new Error('Account IDs are required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
accountIds: params.participantAccountIds,
|
||||||
|
}
|
||||||
|
case 'get_feedback':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
}
|
||||||
|
case 'add_feedback':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
rating: Number.parseInt(params.feedbackRating || '5'),
|
||||||
|
comment: params.feedbackComment,
|
||||||
|
}
|
||||||
|
case 'delete_feedback':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
}
|
||||||
|
case 'get_notification':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
}
|
||||||
|
case 'subscribe_notification':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
}
|
||||||
|
case 'unsubscribe_notification':
|
||||||
|
if (!params.issueIdOrKey) {
|
||||||
|
throw new Error('Issue ID or key is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
issueIdOrKey: params.issueIdOrKey,
|
||||||
|
}
|
||||||
|
case 'search_knowledge_base':
|
||||||
|
if (!params.knowledgeBaseQuery) {
|
||||||
|
throw new Error('Search query is required')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseParams,
|
||||||
|
serviceDeskId: params.serviceDeskId,
|
||||||
|
query: params.knowledgeBaseQuery,
|
||||||
|
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return baseParams
|
return baseParams
|
||||||
}
|
}
|
||||||
@@ -779,6 +1186,16 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
searchQuery: { type: 'string', description: 'Filter request types by name' },
|
searchQuery: { type: 'string', description: 'Filter request types by name' },
|
||||||
groupId: { type: 'string', description: 'Filter by request type group ID' },
|
groupId: { type: 'string', description: 'Filter by request type group ID' },
|
||||||
expand: { type: 'string', description: 'Comma-separated fields to expand' },
|
expand: { type: 'string', description: 'Comma-separated fields to expand' },
|
||||||
|
queueId: { type: 'string', description: 'Queue ID' },
|
||||||
|
customerEmail: { type: 'string', description: 'Customer email address' },
|
||||||
|
customerDisplayName: { type: 'string', description: 'Customer display name' },
|
||||||
|
knowledgeBaseQuery: { type: 'string', description: 'Knowledge base search query' },
|
||||||
|
feedbackRating: { type: 'string', description: 'CSAT feedback rating (1-5)' },
|
||||||
|
feedbackComment: { type: 'string', description: 'CSAT feedback comment' },
|
||||||
|
includeAttachments: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Whether to download attachment file content',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
outputs: {
|
outputs: {
|
||||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||||
@@ -810,6 +1227,19 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
total: { type: 'number', description: 'Total count' },
|
total: { type: 'number', description: 'Total count' },
|
||||||
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
|
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
|
||||||
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
|
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
|
||||||
|
rating: { type: 'number', description: 'CSAT feedback rating' },
|
||||||
|
subscribed: { type: 'boolean', description: 'Whether subscribed to notifications' },
|
||||||
|
articles: { type: 'json', description: 'Array of knowledge base articles' },
|
||||||
|
statuses: { type: 'json', description: 'Array of request status history entries' },
|
||||||
|
attachments: { type: 'json', description: 'Array of attachment metadata' },
|
||||||
|
issues: { type: 'json', description: 'Array of queue issues' },
|
||||||
|
users: { type: 'json', description: 'Array of organization users' },
|
||||||
|
id: { type: 'string', description: 'Resource ID' },
|
||||||
|
projectId: { type: 'string', description: 'Service desk project ID' },
|
||||||
|
projectName: { type: 'string', description: 'Service desk project name' },
|
||||||
|
projectKey: { type: 'string', description: 'Service desk project key' },
|
||||||
|
email: { type: 'string', description: 'Customer email address' },
|
||||||
|
displayName: { type: 'string', description: 'Customer display name' },
|
||||||
canAddRequestParticipants: {
|
canAddRequestParticipants: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Whether participants can be added to this request type',
|
description: 'Whether participants can be added to this request type',
|
||||||
@@ -818,5 +1248,36 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Whether requests can be raised on behalf of another user',
|
description: 'Whether requests can be raised on behalf of another user',
|
||||||
},
|
},
|
||||||
|
// Trigger outputs (from webhook events)
|
||||||
|
webhookEvent: { type: 'string', description: 'Webhook event type' },
|
||||||
|
issue: { type: 'json', description: 'Complete issue object from webhook' },
|
||||||
|
changelog: { type: 'json', description: 'Changelog object (for update events)' },
|
||||||
|
comment: { type: 'json', description: 'Comment object (for comment events)' },
|
||||||
|
worklog: { type: 'json', description: 'Worklog object (for worklog events)' },
|
||||||
|
attachment: { type: 'json', description: 'Attachment metadata (for attachment events)' },
|
||||||
|
files: {
|
||||||
|
type: 'file[]',
|
||||||
|
description:
|
||||||
|
'Downloaded file attachments (if includeFiles is enabled and Jira credentials are provided)',
|
||||||
|
},
|
||||||
|
user: { type: 'json', description: 'User object who triggered the event' },
|
||||||
|
webhook: { type: 'json', description: 'Complete webhook payload' },
|
||||||
|
},
|
||||||
|
triggers: {
|
||||||
|
enabled: true,
|
||||||
|
available: [
|
||||||
|
'jsm_request_created',
|
||||||
|
'jsm_request_updated',
|
||||||
|
'jsm_request_deleted',
|
||||||
|
'jsm_request_commented',
|
||||||
|
'jsm_comment_updated',
|
||||||
|
'jsm_comment_deleted',
|
||||||
|
'jsm_worklog_created',
|
||||||
|
'jsm_worklog_updated',
|
||||||
|
'jsm_worklog_deleted',
|
||||||
|
'jsm_attachment_created',
|
||||||
|
'jsm_attachment_deleted',
|
||||||
|
'jsm_webhook',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ export const CREDENTIAL_SET = {
|
|||||||
PREFIX: 'credentialSet:',
|
PREFIX: 'credentialSet:',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export const CREDENTIAL = {
|
||||||
|
FOREIGN_LABEL: 'Saved by collaborator',
|
||||||
|
} as const
|
||||||
|
|
||||||
export function isCredentialSetValue(value: string | null | undefined): boolean {
|
export function isCredentialSetValue(value: string | null | undefined): boolean {
|
||||||
return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
|
return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ export class DAGExecutor {
|
|||||||
executionId: this.contextExtensions.executionId,
|
executionId: this.contextExtensions.executionId,
|
||||||
userId: this.contextExtensions.userId,
|
userId: this.contextExtensions.userId,
|
||||||
isDeployedContext: this.contextExtensions.isDeployedContext,
|
isDeployedContext: this.contextExtensions.isDeployedContext,
|
||||||
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
|
|
||||||
blockStates: state.getBlockStates(),
|
blockStates: state.getBlockStates(),
|
||||||
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
|
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export interface ExecutionMetadata {
|
|||||||
useDraftState: boolean
|
useDraftState: boolean
|
||||||
startTime: string
|
startTime: string
|
||||||
isClientSession?: boolean
|
isClientSession?: boolean
|
||||||
enforceCredentialAccess?: boolean
|
|
||||||
pendingBlocks?: string[]
|
pendingBlocks?: string[]
|
||||||
resumeFromSnapshot?: boolean
|
resumeFromSnapshot?: boolean
|
||||||
credentialAccountUserId?: string
|
credentialAccountUserId?: string
|
||||||
@@ -81,7 +80,6 @@ export interface ContextExtensions {
|
|||||||
selectedOutputs?: string[]
|
selectedOutputs?: string[]
|
||||||
edges?: Array<{ source: string; target: string }>
|
edges?: Array<{ source: string; target: string }>
|
||||||
isDeployedContext?: boolean
|
isDeployedContext?: boolean
|
||||||
enforceCredentialAccess?: boolean
|
|
||||||
isChildExecution?: boolean
|
isChildExecution?: boolean
|
||||||
resumeFromSnapshot?: boolean
|
resumeFromSnapshot?: boolean
|
||||||
resumePendingQueue?: string[]
|
resumePendingQueue?: string[]
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export class ApiBlockHandler implements BlockHandler {
|
|||||||
executionId: ctx.executionId,
|
executionId: ctx.executionId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export async function evaluateConditionExpression(
|
|||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export class FunctionBlockHandler implements BlockHandler {
|
|||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export class GenericBlockHandler implements BlockHandler {
|
|||||||
executionId: ctx.executionId,
|
executionId: ctx.executionId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -607,7 +607,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
|||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
isDeployedContext: ctx.isDeployedContext,
|
isDeployedContext: ctx.isDeployedContext,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
},
|
},
|
||||||
blockData: blockDataWithPause,
|
blockData: blockDataWithPause,
|
||||||
blockNameMapping: blockNameMappingWithPause,
|
blockNameMapping: blockNameMappingWithPause,
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
contextExtensions: {
|
contextExtensions: {
|
||||||
isChildExecution: true,
|
isChildExecution: true,
|
||||||
isDeployedContext: ctx.isDeployedContext === true,
|
isDeployedContext: ctx.isDeployedContext === true,
|
||||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
executionId: ctx.executionId,
|
executionId: ctx.executionId,
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ export interface ExecutionContext {
|
|||||||
executionId?: string
|
executionId?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
isDeployedContext?: boolean
|
isDeployedContext?: boolean
|
||||||
enforceCredentialAccess?: boolean
|
|
||||||
|
|
||||||
permissionConfig?: PermissionGroupConfig | null
|
permissionConfig?: PermissionGroupConfig | null
|
||||||
permissionConfigLoaded?: boolean
|
permissionConfigLoaded?: boolean
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { environmentKeys } from '@/hooks/queries/environment'
|
|
||||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
|
||||||
|
|
||||||
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
|
|
||||||
export type WorkspaceCredentialRole = 'admin' | 'member'
|
|
||||||
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
|
|
||||||
|
|
||||||
export interface WorkspaceCredential {
|
|
||||||
id: string
|
|
||||||
workspaceId: string
|
|
||||||
type: WorkspaceCredentialType
|
|
||||||
displayName: string
|
|
||||||
description: string | null
|
|
||||||
providerId: string | null
|
|
||||||
accountId: string | null
|
|
||||||
envKey: string | null
|
|
||||||
envOwnerUserId: string | null
|
|
||||||
createdBy: string
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
role?: WorkspaceCredentialRole
|
|
||||||
status?: WorkspaceCredentialMemberStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceCredentialMember {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
role: WorkspaceCredentialRole
|
|
||||||
status: WorkspaceCredentialMemberStatus
|
|
||||||
joinedAt: string | null
|
|
||||||
invitedBy: string | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
userName: string | null
|
|
||||||
userEmail: string | null
|
|
||||||
userImage: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CredentialListResponse {
|
|
||||||
credentials?: WorkspaceCredential[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CredentialResponse {
|
|
||||||
credential?: WorkspaceCredential | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MembersResponse {
|
|
||||||
members?: WorkspaceCredentialMember[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const workspaceCredentialKeys = {
|
|
||||||
all: ['workspaceCredentials'] as const,
|
|
||||||
list: (workspaceId?: string, type?: string, providerId?: string) =>
|
|
||||||
['workspaceCredentials', workspaceId ?? 'none', type ?? 'all', providerId ?? 'all'] as const,
|
|
||||||
detail: (credentialId?: string) =>
|
|
||||||
['workspaceCredentials', 'detail', credentialId ?? 'none'] as const,
|
|
||||||
members: (credentialId?: string) =>
|
|
||||||
['workspaceCredentials', 'detail', credentialId ?? 'none', 'members'] as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkspaceCredentials(params: {
|
|
||||||
workspaceId?: string
|
|
||||||
type?: WorkspaceCredentialType
|
|
||||||
providerId?: string
|
|
||||||
enabled?: boolean
|
|
||||||
}) {
|
|
||||||
const { workspaceId, type, providerId, enabled = true } = params
|
|
||||||
|
|
||||||
return useQuery<WorkspaceCredential[]>({
|
|
||||||
queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!workspaceId) return []
|
|
||||||
const data = await fetchJson<CredentialListResponse>('/api/credentials', {
|
|
||||||
searchParams: {
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
providerId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data.credentials ?? []
|
|
||||||
},
|
|
||||||
enabled: Boolean(workspaceId) && enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkspaceCredential(credentialId?: string, enabled = true) {
|
|
||||||
return useQuery<WorkspaceCredential | null>({
|
|
||||||
queryKey: workspaceCredentialKeys.detail(credentialId),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!credentialId) return null
|
|
||||||
const data = await fetchJson<CredentialResponse>(`/api/credentials/${credentialId}`)
|
|
||||||
return data.credential ?? null
|
|
||||||
},
|
|
||||||
enabled: Boolean(credentialId) && enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkspaceCredential() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
workspaceId: string
|
|
||||||
type: WorkspaceCredentialType
|
|
||||||
displayName?: string
|
|
||||||
description?: string
|
|
||||||
providerId?: string
|
|
||||||
accountId?: string
|
|
||||||
envKey?: string
|
|
||||||
envOwnerUserId?: string
|
|
||||||
}) => {
|
|
||||||
const response = await fetch('/api/credentials', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.error || 'Failed to create credential')
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.list(variables.workspaceId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.all,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateWorkspaceCredential() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
credentialId: string
|
|
||||||
displayName?: string
|
|
||||||
description?: string | null
|
|
||||||
accountId?: string
|
|
||||||
}) => {
|
|
||||||
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
displayName: payload.displayName,
|
|
||||||
description: payload.description,
|
|
||||||
accountId: payload.accountId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.error || 'Failed to update credential')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.all,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWorkspaceCredential() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (credentialId: string) => {
|
|
||||||
const response = await fetch(`/api/credentials/${credentialId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.error || 'Failed to delete credential')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
onSuccess: (_data, credentialId) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
|
|
||||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
|
|
||||||
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkspaceCredentialMembers(credentialId?: string) {
|
|
||||||
return useQuery<WorkspaceCredentialMember[]>({
|
|
||||||
queryKey: workspaceCredentialKeys.members(credentialId),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!credentialId) return []
|
|
||||||
const data = await fetchJson<MembersResponse>(`/api/credentials/${credentialId}/members`)
|
|
||||||
return data.members ?? []
|
|
||||||
},
|
|
||||||
enabled: Boolean(credentialId),
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertWorkspaceCredentialMember() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
credentialId: string
|
|
||||||
userId: string
|
|
||||||
role: WorkspaceCredentialRole
|
|
||||||
}) => {
|
|
||||||
const response = await fetch(`/api/credentials/${payload.credentialId}/members`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
userId: payload.userId,
|
|
||||||
role: payload.role,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.error || 'Failed to update credential member')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveWorkspaceCredentialMember() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (payload: { credentialId: string; userId: string }) => {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/credentials/${payload.credentialId}/members?userId=${encodeURIComponent(payload.userId)}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.error || 'Failed to remove credential member')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -169,9 +169,9 @@ export function useConnectOAuthService() {
|
|||||||
|
|
||||||
interface DisconnectServiceParams {
|
interface DisconnectServiceParams {
|
||||||
provider: string
|
provider: string
|
||||||
providerId?: string
|
providerId: string
|
||||||
serviceId: string
|
serviceId: string
|
||||||
accountId?: string
|
accountId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +182,7 @@ export function useDisconnectOAuthService() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => {
|
mutationFn: async ({ provider, providerId }: DisconnectServiceParams) => {
|
||||||
const response = await fetch('/api/auth/oauth/disconnect', {
|
const response = await fetch('/api/auth/oauth/disconnect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -191,7 +191,6 @@ export function useDisconnectOAuthService() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
provider,
|
provider,
|
||||||
providerId,
|
providerId,
|
||||||
accountId,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -213,8 +212,7 @@ export function useDisconnectOAuthService() {
|
|||||||
oauthConnectionsKeys.connections(),
|
oauthConnectionsKeys.connections(),
|
||||||
previousServices.map((svc) => {
|
previousServices.map((svc) => {
|
||||||
if (svc.id === serviceId) {
|
if (svc.id === serviceId) {
|
||||||
const updatedAccounts =
|
const updatedAccounts = svc.accounts?.filter((acc) => acc.id !== accountId) || []
|
||||||
accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
|
|
||||||
return {
|
return {
|
||||||
...svc,
|
...svc,
|
||||||
accounts: updatedAccounts,
|
accounts: updatedAccounts,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { Credential } from '@/lib/oauth'
|
import type { Credential } from '@/lib/oauth'
|
||||||
import { CREDENTIAL_SET } from '@/executor/constants'
|
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
|
||||||
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
|
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
|
||||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||||
|
|
||||||
@@ -13,34 +13,15 @@ interface CredentialDetailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const oauthCredentialKeys = {
|
export const oauthCredentialKeys = {
|
||||||
list: (providerId?: string, workspaceId?: string, workflowId?: string) =>
|
list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const,
|
||||||
[
|
|
||||||
'oauthCredentials',
|
|
||||||
providerId ?? 'none',
|
|
||||||
workspaceId ?? 'none',
|
|
||||||
workflowId ?? 'none',
|
|
||||||
] as const,
|
|
||||||
detail: (credentialId?: string, workflowId?: string) =>
|
detail: (credentialId?: string, workflowId?: string) =>
|
||||||
['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
|
['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchOAuthCredentialsParams {
|
export async function fetchOAuthCredentials(providerId: string): Promise<Credential[]> {
|
||||||
providerId: string
|
|
||||||
workspaceId?: string
|
|
||||||
workflowId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchOAuthCredentials(
|
|
||||||
params: FetchOAuthCredentialsParams
|
|
||||||
): Promise<Credential[]> {
|
|
||||||
const { providerId, workspaceId, workflowId } = params
|
|
||||||
if (!providerId) return []
|
if (!providerId) return []
|
||||||
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
|
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
|
||||||
searchParams: {
|
searchParams: { provider: providerId },
|
||||||
provider: providerId,
|
|
||||||
workspaceId,
|
|
||||||
workflowId,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return data.credentials ?? []
|
return data.credentials ?? []
|
||||||
}
|
}
|
||||||
@@ -59,44 +40,10 @@ export async function fetchOAuthCredentialDetail(
|
|||||||
return data.credentials ?? []
|
return data.credentials ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseOAuthCredentialsOptions {
|
export function useOAuthCredentials(providerId?: string, enabled = true) {
|
||||||
enabled?: boolean
|
|
||||||
workspaceId?: string
|
|
||||||
workflowId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOptions(
|
|
||||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
|
||||||
): Required<UseOAuthCredentialsOptions> {
|
|
||||||
if (typeof enabledOrOptions === 'boolean') {
|
|
||||||
return {
|
|
||||||
enabled: enabledOrOptions,
|
|
||||||
workspaceId: '',
|
|
||||||
workflowId: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: enabledOrOptions?.enabled ?? true,
|
|
||||||
workspaceId: enabledOrOptions?.workspaceId ?? '',
|
|
||||||
workflowId: enabledOrOptions?.workflowId ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOAuthCredentials(
|
|
||||||
providerId?: string,
|
|
||||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
|
||||||
) {
|
|
||||||
const { enabled, workspaceId, workflowId } = resolveOptions(enabledOrOptions)
|
|
||||||
|
|
||||||
return useQuery<Credential[]>({
|
return useQuery<Credential[]>({
|
||||||
queryKey: oauthCredentialKeys.list(providerId, workspaceId, workflowId),
|
queryKey: oauthCredentialKeys.list(providerId),
|
||||||
queryFn: () =>
|
queryFn: () => fetchOAuthCredentials(providerId ?? ''),
|
||||||
fetchOAuthCredentials({
|
|
||||||
providerId: providerId ?? '',
|
|
||||||
workspaceId: workspaceId || undefined,
|
|
||||||
workflowId: workflowId || undefined,
|
|
||||||
}),
|
|
||||||
enabled: Boolean(providerId) && enabled,
|
enabled: Boolean(providerId) && enabled,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
@@ -115,12 +62,7 @@ export function useOAuthCredentialDetail(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCredentialName(
|
export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
|
||||||
credentialId?: string,
|
|
||||||
providerId?: string,
|
|
||||||
workflowId?: string,
|
|
||||||
workspaceId?: string
|
|
||||||
) {
|
|
||||||
// Check if this is a credential set value
|
// Check if this is a credential set value
|
||||||
const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
|
const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
|
||||||
const credentialSetId = isCredentialSet
|
const credentialSetId = isCredentialSet
|
||||||
@@ -135,11 +77,7 @@ export function useCredentialName(
|
|||||||
|
|
||||||
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
|
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
|
||||||
providerId,
|
providerId,
|
||||||
{
|
Boolean(providerId) && !isCredentialSet
|
||||||
enabled: Boolean(providerId) && !isCredentialSet,
|
|
||||||
workspaceId,
|
|
||||||
workflowId,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
|
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
|
||||||
@@ -154,18 +92,18 @@ export function useCredentialName(
|
|||||||
shouldFetchDetail
|
shouldFetchDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
const detailCredential = foreignCredentials[0]
|
|
||||||
const hasForeignMeta = foreignCredentials.length > 0
|
const hasForeignMeta = foreignCredentials.length > 0
|
||||||
|
const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
credentialSetData?.name ?? selectedCredential?.name ?? detailCredential?.name ?? null
|
credentialSetData?.name ??
|
||||||
|
selectedCredential?.name ??
|
||||||
|
(hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ??
|
||||||
|
(isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName,
|
displayName,
|
||||||
isLoading:
|
isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading),
|
||||||
credentialsLoading ||
|
|
||||||
foreignLoading ||
|
|
||||||
(isCredentialSet && credentialSetLoading && !credentialSetData),
|
|
||||||
hasForeignMeta,
|
hasForeignMeta,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
oneTimeToken,
|
oneTimeToken,
|
||||||
organization,
|
organization,
|
||||||
} from 'better-auth/plugins'
|
} from 'better-auth/plugins'
|
||||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
import {
|
import {
|
||||||
@@ -150,6 +150,16 @@ export const auth = betterAuth({
|
|||||||
account: {
|
account: {
|
||||||
create: {
|
create: {
|
||||||
before: async (account) => {
|
before: async (account) => {
|
||||||
|
// Only one credential per (userId, providerId) is allowed
|
||||||
|
// If user reconnects (even with a different external account), delete the old one
|
||||||
|
// and let Better Auth create the new one (returning false breaks account linking flow)
|
||||||
|
const existing = await db.query.account.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.account.userId, account.userId),
|
||||||
|
eq(schema.account.providerId, account.providerId)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
const modifiedAccount = { ...account }
|
const modifiedAccount = { ...account }
|
||||||
|
|
||||||
if (account.providerId === 'salesforce' && account.accessToken) {
|
if (account.providerId === 'salesforce' && account.accessToken) {
|
||||||
@@ -179,149 +189,32 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Microsoft refresh token expiry
|
||||||
if (isMicrosoftProvider(account.providerId)) {
|
if (isMicrosoftProvider(account.providerId)) {
|
||||||
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Delete the existing account so Better Auth can create the new one
|
||||||
|
// This allows account linking/re-authorization to succeed
|
||||||
|
await db.delete(schema.account).where(eq(schema.account.id, existing.id))
|
||||||
|
|
||||||
|
// Preserve the existing account ID so references (like workspace notifications) continue to work
|
||||||
|
modifiedAccount.id = existing.id
|
||||||
|
|
||||||
|
logger.info('[account.create.before] Deleted existing account for re-authorization', {
|
||||||
|
userId: account.userId,
|
||||||
|
providerId: account.providerId,
|
||||||
|
existingAccountId: existing.id,
|
||||||
|
preservingId: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync webhooks for credential sets after reconnecting (in after hook)
|
||||||
|
}
|
||||||
|
|
||||||
return { data: modifiedAccount }
|
return { data: modifiedAccount }
|
||||||
},
|
},
|
||||||
after: async (account) => {
|
after: async (account) => {
|
||||||
/**
|
|
||||||
* Migrate credentials from stale account rows to the newly created one.
|
|
||||||
*
|
|
||||||
* Each getUserInfo appends a random UUID to the stable external ID so
|
|
||||||
* that Better Auth never blocks cross-user connections. This means
|
|
||||||
* re-connecting the same external identity creates a new row. We detect
|
|
||||||
* the stale siblings here by comparing the stable prefix (everything
|
|
||||||
* before the trailing UUID), migrate any credential FKs to the new row,
|
|
||||||
* then delete the stale rows.
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
||||||
const stablePrefix = account.accountId.replace(UUID_SUFFIX_RE, '')
|
|
||||||
|
|
||||||
if (stablePrefix && stablePrefix !== account.accountId) {
|
|
||||||
const siblings = await db
|
|
||||||
.select({ id: schema.account.id, accountId: schema.account.accountId })
|
|
||||||
.from(schema.account)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.account.userId, account.userId),
|
|
||||||
eq(schema.account.providerId, account.providerId),
|
|
||||||
sql`${schema.account.id} != ${account.id}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const staleRows = siblings.filter(
|
|
||||||
(row) => row.accountId.replace(UUID_SUFFIX_RE, '') === stablePrefix
|
|
||||||
)
|
|
||||||
|
|
||||||
if (staleRows.length > 0) {
|
|
||||||
const staleIds = staleRows.map((row) => row.id)
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.credential)
|
|
||||||
.set({ accountId: account.id })
|
|
||||||
.where(inArray(schema.credential.accountId, staleIds))
|
|
||||||
|
|
||||||
await db.delete(schema.account).where(inArray(schema.account.id, staleIds))
|
|
||||||
|
|
||||||
logger.info('[account.create.after] Migrated credentials from stale accounts', {
|
|
||||||
userId: account.userId,
|
|
||||||
providerId: account.providerId,
|
|
||||||
newAccountId: account.id,
|
|
||||||
migratedFrom: staleIds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[account.create.after] Failed to clean up stale accounts', {
|
|
||||||
userId: account.userId,
|
|
||||||
providerId: account.providerId,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If a pending credential draft exists for this (userId, providerId),
|
|
||||||
* create the credential now with the user's chosen display name.
|
|
||||||
* This is deterministic — the account row is guaranteed to exist.
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
const [draft] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.pendingCredentialDraft)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.pendingCredentialDraft.userId, account.userId),
|
|
||||||
eq(schema.pendingCredentialDraft.providerId, account.providerId),
|
|
||||||
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (draft) {
|
|
||||||
const credentialId = crypto.randomUUID()
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(schema.credential).values({
|
|
||||||
id: credentialId,
|
|
||||||
workspaceId: draft.workspaceId,
|
|
||||||
type: 'oauth',
|
|
||||||
displayName: draft.displayName,
|
|
||||||
description: draft.description ?? null,
|
|
||||||
providerId: account.providerId,
|
|
||||||
accountId: account.id,
|
|
||||||
createdBy: account.userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
await db.insert(schema.credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId: account.userId,
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: account.userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('[account.create.after] Created credential from draft', {
|
|
||||||
credentialId,
|
|
||||||
displayName: draft.displayName,
|
|
||||||
providerId: account.providerId,
|
|
||||||
accountId: account.id,
|
|
||||||
})
|
|
||||||
} catch (insertError: unknown) {
|
|
||||||
const code =
|
|
||||||
insertError && typeof insertError === 'object' && 'code' in insertError
|
|
||||||
? (insertError as { code: string }).code
|
|
||||||
: undefined
|
|
||||||
if (code !== '23505') {
|
|
||||||
throw insertError
|
|
||||||
}
|
|
||||||
logger.info('[account.create.after] Credential already exists, skipping draft', {
|
|
||||||
providerId: account.providerId,
|
|
||||||
accountId: account.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(schema.pendingCredentialDraft)
|
|
||||||
.where(eq(schema.pendingCredentialDraft.id, draft.id))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[account.create.after] Failed to create credential from draft', {
|
|
||||||
userId: account.userId,
|
|
||||||
providerId: account.providerId,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
|
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
|
||||||
await ensureUserStatsExists(account.userId)
|
await ensureUserStatsExists(account.userId)
|
||||||
@@ -1594,7 +1487,7 @@ export const auth = betterAuth({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${(data.user_id || data.hub_id).toString()}-${crypto.randomUUID()}`,
|
id: `${data.user_id || data.hub_id.toString()}-${crypto.randomUUID()}`,
|
||||||
name: data.user || 'HubSpot User',
|
name: data.user || 'HubSpot User',
|
||||||
email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
|
email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
@@ -1648,7 +1541,7 @@ export const auth = betterAuth({
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${(data.user_id || data.sub).toString()}-${crypto.randomUUID()}`,
|
id: `${data.user_id || data.sub}-${crypto.randomUUID()}`,
|
||||||
name: data.name || 'Salesforce User',
|
name: data.name || 'Salesforce User',
|
||||||
email: data.email || `salesforce-${data.user_id}@salesforce.com`,
|
email: data.email || `salesforce-${data.user_id}@salesforce.com`,
|
||||||
emailVerified: data.email_verified || true,
|
emailVerified: data.email_verified || true,
|
||||||
@@ -1707,7 +1600,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.data.id.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.data.id}-${crypto.randomUUID()}`,
|
||||||
name: profile.data.name || 'X User',
|
name: profile.data.name || 'X User',
|
||||||
email: `${profile.data.username}@x.com`,
|
email: `${profile.data.username}@x.com`,
|
||||||
image: profile.data.profile_image_url,
|
image: profile.data.profile_image_url,
|
||||||
@@ -1787,7 +1680,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||||
name: profile.name || profile.display_name || 'Confluence User',
|
name: profile.name || profile.display_name || 'Confluence User',
|
||||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||||
image: profile.picture || undefined,
|
image: profile.picture || undefined,
|
||||||
@@ -1898,7 +1791,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||||
name: profile.name || profile.display_name || 'Jira User',
|
name: profile.name || profile.display_name || 'Jira User',
|
||||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||||
image: profile.picture || undefined,
|
image: profile.picture || undefined,
|
||||||
@@ -1948,7 +1841,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${data.id.toString()}-${crypto.randomUUID()}`,
|
id: `${data.id}-${crypto.randomUUID()}`,
|
||||||
name: data.email ? data.email.split('@')[0] : 'Airtable User',
|
name: data.email ? data.email.split('@')[0] : 'Airtable User',
|
||||||
email: data.email || `${data.id}@airtable.user`,
|
email: data.email || `${data.id}@airtable.user`,
|
||||||
emailVerified: !!data.email,
|
emailVerified: !!data.email,
|
||||||
@@ -1997,7 +1890,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${crypto.randomUUID()}`,
|
id: `${profile.bot?.owner?.user?.id || profile.id}-${crypto.randomUUID()}`,
|
||||||
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
|
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
|
||||||
email: profile.person?.email || `${profile.id}@notion.user`,
|
email: profile.person?.email || `${profile.id}@notion.user`,
|
||||||
emailVerified: !!profile.person?.email,
|
emailVerified: !!profile.person?.email,
|
||||||
@@ -2064,7 +1957,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${data.id.toString()}-${crypto.randomUUID()}`,
|
id: `${data.id}-${crypto.randomUUID()}`,
|
||||||
name: data.name || 'Reddit User',
|
name: data.name || 'Reddit User',
|
||||||
email: `${data.name}@reddit.user`,
|
email: `${data.name}@reddit.user`,
|
||||||
image: data.icon_img || undefined,
|
image: data.icon_img || undefined,
|
||||||
@@ -2136,7 +2029,7 @@ export const auth = betterAuth({
|
|||||||
const viewer = data.viewer
|
const viewer = data.viewer
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${viewer.id.toString()}-${crypto.randomUUID()}`,
|
id: `${viewer.id}-${crypto.randomUUID()}`,
|
||||||
email: viewer.email,
|
email: viewer.email,
|
||||||
name: viewer.name,
|
name: viewer.name,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
@@ -2199,7 +2092,7 @@ export const auth = betterAuth({
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${data.account_id.toString()}-${crypto.randomUUID()}`,
|
id: `${data.account_id}-${crypto.randomUUID()}`,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
name: data.name?.display_name || data.email,
|
name: data.name?.display_name || data.email,
|
||||||
emailVerified: data.email_verified || false,
|
emailVerified: data.email_verified || false,
|
||||||
@@ -2250,7 +2143,7 @@ export const auth = betterAuth({
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.gid.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.gid}-${crypto.randomUUID()}`,
|
||||||
name: profile.name || 'Asana User',
|
name: profile.name || 'Asana User',
|
||||||
email: profile.email || `${profile.gid}@asana.user`,
|
email: profile.email || `${profile.gid}@asana.user`,
|
||||||
image: profile.photo?.image_128x128 || undefined,
|
image: profile.photo?.image_128x128 || undefined,
|
||||||
@@ -2485,7 +2378,7 @@ export const auth = betterAuth({
|
|||||||
const profile = await response.json()
|
const profile = await response.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||||
name:
|
name:
|
||||||
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
|
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
|
||||||
email: profile.email || `${profile.id}@zoom.user`,
|
email: profile.email || `${profile.id}@zoom.user`,
|
||||||
@@ -2552,7 +2445,7 @@ export const auth = betterAuth({
|
|||||||
const profile = await response.json()
|
const profile = await response.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||||
name: profile.display_name || 'Spotify User',
|
name: profile.display_name || 'Spotify User',
|
||||||
email: profile.email || `${profile.id}@spotify.user`,
|
email: profile.email || `${profile.id}@spotify.user`,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema'
|
import { account, workflow as workflowTable } from '@sim/db/schema'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -12,25 +12,23 @@ export interface CredentialAccessResult {
|
|||||||
requesterUserId?: string
|
requesterUserId?: string
|
||||||
credentialOwnerUserId?: string
|
credentialOwnerUserId?: string
|
||||||
workspaceId?: string
|
workspaceId?: string
|
||||||
resolvedCredentialId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralizes auth + credential membership checks for OAuth usage.
|
* Centralizes auth + collaboration rules for credential use.
|
||||||
* - Workspace-scoped credential IDs enforce active credential_member access.
|
* - Uses checkSessionOrInternalAuth to authenticate the caller
|
||||||
* - Legacy account IDs are resolved to workspace-scoped credentials when workflowId is provided.
|
* - Fetches credential owner
|
||||||
* - Direct legacy account-ID access without workflowId is restricted to account owners only.
|
* - Authorization rules:
|
||||||
|
* - session: allow if requester owns the credential; otherwise require workflowId and
|
||||||
|
* verify BOTH requester and owner have access to the workflow's workspace
|
||||||
|
* - internal_jwt: require workflowId (by default) and verify credential owner has access to the
|
||||||
|
* workflow's workspace (requester identity is the system/workflow)
|
||||||
*/
|
*/
|
||||||
export async function authorizeCredentialUse(
|
export async function authorizeCredentialUse(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
params: {
|
params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
|
||||||
credentialId: string
|
|
||||||
workflowId?: string
|
|
||||||
requireWorkflowIdForInternal?: boolean
|
|
||||||
callerUserId?: string
|
|
||||||
}
|
|
||||||
): Promise<CredentialAccessResult> {
|
): Promise<CredentialAccessResult> {
|
||||||
const { credentialId, workflowId, requireWorkflowIdForInternal = true, callerUserId } = params
|
const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
|
||||||
|
|
||||||
const auth = await checkSessionOrInternalAuth(request, {
|
const auth = await checkSessionOrInternalAuth(request, {
|
||||||
requireWorkflowId: requireWorkflowIdForInternal,
|
requireWorkflowId: requireWorkflowIdForInternal,
|
||||||
@@ -39,192 +37,71 @@ export async function authorizeCredentialUse(
|
|||||||
return { ok: false, error: auth.error || 'Authentication required' }
|
return { ok: false, error: auth.error || 'Authentication required' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const [workflowContext] = workflowId
|
// Lookup credential owner
|
||||||
? await db
|
const [credRow] = await db
|
||||||
.select({ workspaceId: workflowTable.workspaceId })
|
|
||||||
.from(workflowTable)
|
|
||||||
.where(eq(workflowTable.id, workflowId))
|
|
||||||
.limit(1)
|
|
||||||
: [null]
|
|
||||||
|
|
||||||
if (workflowId && (!workflowContext || !workflowContext.workspaceId)) {
|
|
||||||
return { ok: false, error: 'Workflow not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [platformCredential] = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
type: credential.type,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (platformCredential) {
|
|
||||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
|
||||||
return { ok: false, error: 'Unsupported credential type for OAuth access' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
|
|
||||||
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [accountRow] = await db
|
|
||||||
.select({ userId: account.userId })
|
|
||||||
.from(account)
|
|
||||||
.where(eq(account.id, platformCredential.accountId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!accountRow) {
|
|
||||||
return { ok: false, error: 'Credential account not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveCallerId =
|
|
||||||
callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
|
|
||||||
|
|
||||||
if (effectiveCallerId) {
|
|
||||||
const requesterPerm = await getUserEntityPermissions(
|
|
||||||
effectiveCallerId,
|
|
||||||
'workspace',
|
|
||||||
platformCredential.workspaceId
|
|
||||||
)
|
|
||||||
|
|
||||||
const [membership] = await db
|
|
||||||
.select({ id: credentialMember.id })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, platformCredential.id),
|
|
||||||
eq(credentialMember.userId, effectiveCallerId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (requesterPerm === null) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: 'You do not have access to this workspace.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerPerm = await getUserEntityPermissions(
|
|
||||||
accountRow.userId,
|
|
||||||
'workspace',
|
|
||||||
platformCredential.workspaceId
|
|
||||||
)
|
|
||||||
if (ownerPerm === null) {
|
|
||||||
return { ok: false, error: 'Unauthorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
authType: auth.authType as CredentialAccessResult['authType'],
|
|
||||||
requesterUserId: auth.userId,
|
|
||||||
credentialOwnerUserId: accountRow.userId,
|
|
||||||
workspaceId: platformCredential.workspaceId,
|
|
||||||
resolvedCredentialId: platformCredential.accountId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowContext?.workspaceId) {
|
|
||||||
const [workspaceCredential] = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
workspaceId: credential.workspaceId,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.type, 'oauth'),
|
|
||||||
eq(credential.workspaceId, workflowContext.workspaceId),
|
|
||||||
eq(credential.accountId, credentialId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!workspaceCredential?.accountId) {
|
|
||||||
return { ok: false, error: 'Credential not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [accountRow] = await db
|
|
||||||
.select({ userId: account.userId })
|
|
||||||
.from(account)
|
|
||||||
.where(eq(account.id, workspaceCredential.accountId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!accountRow) {
|
|
||||||
return { ok: false, error: 'Credential account not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyCallerId = callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
|
|
||||||
|
|
||||||
if (legacyCallerId) {
|
|
||||||
const [membership] = await db
|
|
||||||
.select({ id: credentialMember.id })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, workspaceCredential.id),
|
|
||||||
eq(credentialMember.userId, legacyCallerId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error:
|
|
||||||
'You do not have access to this credential. Ask the credential admin to add you as a member.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerPerm = await getUserEntityPermissions(
|
|
||||||
accountRow.userId,
|
|
||||||
'workspace',
|
|
||||||
workflowContext.workspaceId
|
|
||||||
)
|
|
||||||
if (ownerPerm === null) {
|
|
||||||
return { ok: false, error: 'Unauthorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
authType: auth.authType as CredentialAccessResult['authType'],
|
|
||||||
requesterUserId: auth.userId,
|
|
||||||
credentialOwnerUserId: accountRow.userId,
|
|
||||||
workspaceId: workflowContext.workspaceId,
|
|
||||||
resolvedCredentialId: workspaceCredential.accountId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [legacyAccount] = await db
|
|
||||||
.select({ userId: account.userId })
|
.select({ userId: account.userId })
|
||||||
.from(account)
|
.from(account)
|
||||||
.where(eq(account.id, credentialId))
|
.where(eq(account.id, credentialId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!legacyAccount) {
|
if (!credRow) {
|
||||||
return { ok: false, error: 'Credential not found' }
|
return { ok: false, error: 'Credential not found' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.authType === 'internal_jwt') {
|
const credentialOwnerUserId = credRow.userId
|
||||||
|
|
||||||
|
// If requester owns the credential, allow immediately
|
||||||
|
if (auth.authType !== 'internal_jwt' && auth.userId === credentialOwnerUserId) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authType: auth.authType as CredentialAccessResult['authType'],
|
||||||
|
requesterUserId: auth.userId,
|
||||||
|
credentialOwnerUserId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For collaboration paths, workflowId is required to scope to a workspace
|
||||||
|
if (!workflowId) {
|
||||||
return { ok: false, error: 'workflowId is required' }
|
return { ok: false, error: 'workflowId is required' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.userId !== legacyAccount.userId) {
|
const [wf] = await db
|
||||||
|
.select({ workspaceId: workflowTable.workspaceId })
|
||||||
|
.from(workflowTable)
|
||||||
|
.where(eq(workflowTable.id, workflowId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!wf || !wf.workspaceId) {
|
||||||
|
return { ok: false, error: 'Workflow not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.authType === 'internal_jwt') {
|
||||||
|
// Internal calls: verify credential owner belongs to the workflow's workspace
|
||||||
|
const ownerPerm = await getUserEntityPermissions(
|
||||||
|
credentialOwnerUserId,
|
||||||
|
'workspace',
|
||||||
|
wf.workspaceId
|
||||||
|
)
|
||||||
|
if (ownerPerm === null) {
|
||||||
|
return { ok: false, error: 'Unauthorized' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authType: auth.authType as CredentialAccessResult['authType'],
|
||||||
|
requesterUserId: auth.userId,
|
||||||
|
credentialOwnerUserId,
|
||||||
|
workspaceId: wf.workspaceId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session: verify BOTH requester and owner belong to the workflow's workspace
|
||||||
|
const requesterPerm = await getUserEntityPermissions(auth.userId, 'workspace', wf.workspaceId)
|
||||||
|
const ownerPerm = await getUserEntityPermissions(
|
||||||
|
credentialOwnerUserId,
|
||||||
|
'workspace',
|
||||||
|
wf.workspaceId
|
||||||
|
)
|
||||||
|
if (requesterPerm === null || ownerPerm === null) {
|
||||||
return { ok: false, error: 'Unauthorized' }
|
return { ok: false, error: 'Unauthorized' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +109,7 @@ export async function authorizeCredentialUse(
|
|||||||
ok: true,
|
ok: true,
|
||||||
authType: auth.authType as CredentialAccessResult['authType'],
|
authType: auth.authType as CredentialAccessResult['authType'],
|
||||||
requesterUserId: auth.userId,
|
requesterUserId: auth.userId,
|
||||||
credentialOwnerUserId: legacyAccount.userId,
|
credentialOwnerUserId,
|
||||||
resolvedCredentialId: credentialId,
|
workspaceId: wf.workspaceId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface BuildPayloadParams {
|
|||||||
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
|
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
|
||||||
commands?: string[]
|
commands?: string[]
|
||||||
chatId?: string
|
chatId?: string
|
||||||
|
conversationId?: string
|
||||||
|
prefetch?: boolean
|
||||||
implicitFeedback?: string
|
implicitFeedback?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,10 @@ export async function buildCopilotRequestPayload(
|
|||||||
fileAttachments,
|
fileAttachments,
|
||||||
commands,
|
commands,
|
||||||
chatId,
|
chatId,
|
||||||
|
conversationId,
|
||||||
|
prefetch,
|
||||||
|
conversationHistory,
|
||||||
|
implicitFeedback,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const selectedModel = options.selectedModel
|
const selectedModel = options.selectedModel
|
||||||
@@ -154,6 +160,12 @@ export async function buildCopilotRequestPayload(
|
|||||||
version: SIM_AGENT_VERSION,
|
version: SIM_AGENT_VERSION,
|
||||||
...(contexts && contexts.length > 0 ? { context: contexts } : {}),
|
...(contexts && contexts.length > 0 ? { context: contexts } : {}),
|
||||||
...(chatId ? { chatId } : {}),
|
...(chatId ? { chatId } : {}),
|
||||||
|
...(conversationId ? { conversationId } : {}),
|
||||||
|
...(Array.isArray(conversationHistory) && conversationHistory.length > 0
|
||||||
|
? { conversationHistory }
|
||||||
|
: {}),
|
||||||
|
...(typeof prefetch === 'boolean' ? { prefetch } : {}),
|
||||||
|
...(implicitFeedback ? { implicitFeedback } : {}),
|
||||||
...(processedFileContents.length > 0 ? { fileAttachments: processedFileContents } : {}),
|
...(processedFileContents.length > 0 ? { fileAttachments: processedFileContents } : {}),
|
||||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||||
...(credentials ? { credentials } : {}),
|
...(credentials ? { credentials } : {}),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { workflow } from '@sim/db/schema'
|
import { customTools, workflow } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||||
import type {
|
import type {
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
@@ -12,6 +12,7 @@ import { routeExecution } from '@/lib/copilot/tools/server/router'
|
|||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||||
|
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||||
import { getTool, resolveToolId } from '@/tools/utils'
|
import { getTool, resolveToolId } from '@/tools/utils'
|
||||||
import {
|
import {
|
||||||
executeCheckDeploymentStatus,
|
executeCheckDeploymentStatus,
|
||||||
@@ -76,6 +77,247 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('CopilotToolExecutor')
|
const logger = createLogger('CopilotToolExecutor')
|
||||||
|
|
||||||
|
type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list'
|
||||||
|
|
||||||
|
interface ManageCustomToolSchema {
|
||||||
|
type: 'function'
|
||||||
|
function: {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
parameters: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManageCustomToolParams {
|
||||||
|
operation?: string
|
||||||
|
toolId?: string
|
||||||
|
schema?: ManageCustomToolSchema
|
||||||
|
code?: string
|
||||||
|
title?: string
|
||||||
|
workspaceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeManageCustomTool(
|
||||||
|
rawParams: Record<string, unknown>,
|
||||||
|
context: ExecutionContext
|
||||||
|
): Promise<ToolCallResult> {
|
||||||
|
const params = rawParams as ManageCustomToolParams
|
||||||
|
const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation
|
||||||
|
const workspaceId = params.workspaceId || context.workspaceId
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
return { success: false, error: "Missing required 'operation' argument" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (operation === 'list') {
|
||||||
|
const toolsForUser = workspaceId
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(customTools)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(customTools.workspaceId, workspaceId),
|
||||||
|
and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(customTools.createdAt))
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(customTools)
|
||||||
|
.where(and(isNull(customTools.workspaceId), eq(customTools.userId, context.userId)))
|
||||||
|
.orderBy(desc(customTools.createdAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
operation,
|
||||||
|
tools: toolsForUser,
|
||||||
|
count: toolsForUser.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'add') {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "workspaceId is required for operation 'add'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!params.schema || !params.code) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Both 'schema' and 'code' are required for operation 'add'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = params.title || params.schema.function?.name
|
||||||
|
if (!title) {
|
||||||
|
return { success: false, error: "Missing tool title or schema.function.name for 'add'" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultTools = await upsertCustomTools({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
schema: params.schema,
|
||||||
|
code: params.code,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workspaceId,
|
||||||
|
userId: context.userId,
|
||||||
|
})
|
||||||
|
const created = resultTools.find((tool) => tool.title === title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
operation,
|
||||||
|
toolId: created?.id,
|
||||||
|
title,
|
||||||
|
message: `Created custom tool "${title}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'edit') {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "workspaceId is required for operation 'edit'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!params.toolId) {
|
||||||
|
return { success: false, error: "'toolId' is required for operation 'edit'" }
|
||||||
|
}
|
||||||
|
if (!params.schema && !params.code) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "At least one of 'schema' or 'code' is required for operation 'edit'",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceTool = await db
|
||||||
|
.select()
|
||||||
|
.from(customTools)
|
||||||
|
.where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const legacyTool =
|
||||||
|
workspaceTool.length === 0
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(customTools)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(customTools.id, params.toolId),
|
||||||
|
isNull(customTools.workspaceId),
|
||||||
|
eq(customTools.userId, context.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const existing = workspaceTool[0] || legacyTool[0]
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: `Custom tool not found: ${params.toolId}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema)
|
||||||
|
const mergedCode = params.code || existing.code
|
||||||
|
const title = params.title || mergedSchema.function?.name || existing.title
|
||||||
|
|
||||||
|
await upsertCustomTools({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: params.toolId,
|
||||||
|
title,
|
||||||
|
schema: mergedSchema,
|
||||||
|
code: mergedCode,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workspaceId,
|
||||||
|
userId: context.userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
operation,
|
||||||
|
toolId: params.toolId,
|
||||||
|
title,
|
||||||
|
message: `Updated custom tool "${title}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'delete') {
|
||||||
|
if (!params.toolId) {
|
||||||
|
return { success: false, error: "'toolId' is required for operation 'delete'" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceDelete =
|
||||||
|
workspaceId != null
|
||||||
|
? await db
|
||||||
|
.delete(customTools)
|
||||||
|
.where(
|
||||||
|
and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, workspaceId))
|
||||||
|
)
|
||||||
|
.returning({ id: customTools.id })
|
||||||
|
: []
|
||||||
|
|
||||||
|
const legacyDelete =
|
||||||
|
workspaceDelete.length === 0
|
||||||
|
? await db
|
||||||
|
.delete(customTools)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(customTools.id, params.toolId),
|
||||||
|
isNull(customTools.workspaceId),
|
||||||
|
eq(customTools.userId, context.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: customTools.id })
|
||||||
|
: []
|
||||||
|
|
||||||
|
const deleted = workspaceDelete[0] || legacyDelete[0]
|
||||||
|
if (!deleted) {
|
||||||
|
return { success: false, error: `Custom tool not found: ${params.toolId}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
operation,
|
||||||
|
toolId: params.toolId,
|
||||||
|
message: 'Deleted custom tool',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unsupported operation for manage_custom_tool: ${operation}`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('manage_custom_tool execution failed', {
|
||||||
|
operation,
|
||||||
|
workspaceId,
|
||||||
|
userId: context.userId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to manage custom tool',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SERVER_TOOLS = new Set<string>([
|
const SERVER_TOOLS = new Set<string>([
|
||||||
'get_blocks_and_tools',
|
'get_blocks_and_tools',
|
||||||
'get_blocks_metadata',
|
'get_blocks_metadata',
|
||||||
@@ -161,6 +403,19 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
oauth_request_access: async (p, _c) => {
|
||||||
|
const providerName = (p.providerName || p.provider_name || 'the provider') as string
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
status: 'requested',
|
||||||
|
providerName,
|
||||||
|
message: `Requested ${providerName} OAuth connection. The user should complete the OAuth modal in the UI, then retry credential-dependent actions.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
manage_custom_tool: (p, c) => executeManageCustomTool(p, c),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { credential, environment, workflow, workspaceEnvironment } from '@sim/db/schema'
|
import { environment } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import {
|
|
||||||
syncPersonalEnvCredentialsForUser,
|
|
||||||
syncWorkspaceEnvCredentials,
|
|
||||||
} from '@/lib/credentials/environment'
|
|
||||||
|
|
||||||
interface SetEnvironmentVariablesParams {
|
interface SetEnvironmentVariablesParams {
|
||||||
variables: Record<string, any> | Array<{ name: string; value: string }>
|
variables: Record<string, any> | Array<{ name: string; value: string }>
|
||||||
@@ -58,179 +54,74 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
|
|||||||
const normalized = normalizeVariables(variables || {})
|
const normalized = normalizeVariables(variables || {})
|
||||||
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
|
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
|
||||||
|
|
||||||
const requestedKeys = Object.keys(validatedVariables)
|
// Fetch existing personal environment variables
|
||||||
const workflowId = params.workflowId
|
const existingData = await db
|
||||||
|
.select()
|
||||||
const workspaceKeySet = new Set<string>()
|
.from(environment)
|
||||||
let resolvedWorkspaceId: string | null = null
|
.where(eq(environment.userId, authenticatedUserId))
|
||||||
|
.limit(1)
|
||||||
if (requestedKeys.length > 0 && workflowId) {
|
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
|
||||||
const [wf] = await db
|
|
||||||
.select({ workspaceId: workflow.workspaceId })
|
|
||||||
.from(workflow)
|
|
||||||
.where(eq(workflow.id, workflowId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (wf?.workspaceId) {
|
|
||||||
resolvedWorkspaceId = wf.workspaceId
|
|
||||||
const existingWorkspaceCredentials = await db
|
|
||||||
.select({ envKey: credential.envKey })
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, wf.workspaceId),
|
|
||||||
eq(credential.type, 'env_workspace'),
|
|
||||||
inArray(credential.envKey, requestedKeys)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const row of existingWorkspaceCredentials) {
|
|
||||||
if (row.envKey) workspaceKeySet.add(row.envKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const personalVars: Record<string, string> = {}
|
|
||||||
const workspaceVars: Record<string, string> = {}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(validatedVariables)) {
|
|
||||||
if (workspaceKeySet.has(key)) {
|
|
||||||
workspaceVars[key] = value
|
|
||||||
} else {
|
|
||||||
personalVars[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const toEncrypt: Record<string, string> = {}
|
||||||
const added: string[] = []
|
const added: string[] = []
|
||||||
const updated: string[] = []
|
const updated: string[] = []
|
||||||
const workspaceUpdated: string[] = []
|
for (const [key, newVal] of Object.entries(validatedVariables)) {
|
||||||
|
if (!(key in existingEncrypted)) {
|
||||||
if (Object.keys(personalVars).length > 0) {
|
toEncrypt[key] = newVal
|
||||||
const existingData = await db
|
added.push(key)
|
||||||
.select()
|
} else {
|
||||||
.from(environment)
|
try {
|
||||||
.where(eq(environment.userId, authenticatedUserId))
|
const { decrypted } = await decryptSecret(existingEncrypted[key])
|
||||||
.limit(1)
|
if (decrypted !== newVal) {
|
||||||
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
|
|
||||||
|
|
||||||
const toEncrypt: Record<string, string> = {}
|
|
||||||
for (const [key, newVal] of Object.entries(personalVars)) {
|
|
||||||
if (!(key in existingEncrypted)) {
|
|
||||||
toEncrypt[key] = newVal
|
|
||||||
added.push(key)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const { decrypted } = await decryptSecret(existingEncrypted[key])
|
|
||||||
if (decrypted !== newVal) {
|
|
||||||
toEncrypt[key] = newVal
|
|
||||||
updated.push(key)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toEncrypt[key] = newVal
|
toEncrypt[key] = newVal
|
||||||
updated.push(key)
|
updated.push(key)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toEncrypt[key] = newVal
|
||||||
|
updated.push(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newlyEncrypted = await Object.entries(toEncrypt).reduce(
|
const newlyEncrypted = await Object.entries(toEncrypt).reduce(
|
||||||
async (accP, [key, val]) => {
|
async (accP, [key, val]) => {
|
||||||
const acc = await accP
|
const acc = await accP
|
||||||
const { encrypted } = await encryptSecret(val)
|
const { encrypted } = await encryptSecret(val)
|
||||||
return { ...acc, [key]: encrypted }
|
return { ...acc, [key]: encrypted }
|
||||||
},
|
},
|
||||||
Promise.resolve({} as Record<string, string>)
|
Promise.resolve({} as Record<string, string>)
|
||||||
)
|
)
|
||||||
|
|
||||||
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
|
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
|
||||||
|
|
||||||
await db
|
// Save to personal environment variables (keyed by userId)
|
||||||
.insert(environment)
|
await db
|
||||||
.values({
|
.insert(environment)
|
||||||
id: crypto.randomUUID(),
|
.values({
|
||||||
userId: authenticatedUserId,
|
id: crypto.randomUUID(),
|
||||||
variables: finalEncrypted,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [environment.userId],
|
|
||||||
set: { variables: finalEncrypted, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
|
|
||||||
await syncPersonalEnvCredentialsForUser({
|
|
||||||
userId: authenticatedUserId,
|
userId: authenticatedUserId,
|
||||||
envKeys: Object.keys(finalEncrypted),
|
variables: finalEncrypted,
|
||||||
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
}
|
.onConflictDoUpdate({
|
||||||
|
target: [environment.userId],
|
||||||
if (Object.keys(workspaceVars).length > 0 && resolvedWorkspaceId) {
|
set: { variables: finalEncrypted, updatedAt: new Date() },
|
||||||
const wsRows = await db
|
|
||||||
.select()
|
|
||||||
.from(workspaceEnvironment)
|
|
||||||
.where(eq(workspaceEnvironment.workspaceId, resolvedWorkspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const existingWsEncrypted = (wsRows[0]?.variables as Record<string, string>) || {}
|
|
||||||
|
|
||||||
const toEncryptWs: Record<string, string> = {}
|
|
||||||
for (const [key, newVal] of Object.entries(workspaceVars)) {
|
|
||||||
toEncryptWs[key] = newVal
|
|
||||||
workspaceUpdated.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const newlyEncryptedWs = await Object.entries(toEncryptWs).reduce(
|
|
||||||
async (accP, [key, val]) => {
|
|
||||||
const acc = await accP
|
|
||||||
const { encrypted } = await encryptSecret(val)
|
|
||||||
return { ...acc, [key]: encrypted }
|
|
||||||
},
|
|
||||||
Promise.resolve({} as Record<string, string>)
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedWs = { ...existingWsEncrypted, ...newlyEncryptedWs }
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(workspaceEnvironment)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
workspaceId: resolvedWorkspaceId,
|
|
||||||
variables: mergedWs,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [workspaceEnvironment.workspaceId],
|
|
||||||
set: { variables: mergedWs, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
|
|
||||||
await syncWorkspaceEnvCredentials({
|
|
||||||
workspaceId: resolvedWorkspaceId,
|
|
||||||
envKeys: Object.keys(workspaceVars),
|
|
||||||
actingUserId: authenticatedUserId,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const totalProcessed = added.length + updated.length + workspaceUpdated.length
|
logger.info('Saved personal environment variables', {
|
||||||
|
|
||||||
logger.info('Saved environment variables', {
|
|
||||||
userId: authenticatedUserId,
|
userId: authenticatedUserId,
|
||||||
addedCount: added.length,
|
addedCount: added.length,
|
||||||
updatedCount: updated.length,
|
updatedCount: updated.length,
|
||||||
workspaceUpdatedCount: workspaceUpdated.length,
|
totalCount: Object.keys(finalEncrypted).length,
|
||||||
})
|
})
|
||||||
|
|
||||||
const parts: string[] = []
|
|
||||||
if (added.length > 0) parts.push(`${added.length} personal secret(s) added`)
|
|
||||||
if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`)
|
|
||||||
if (workspaceUpdated.length > 0)
|
|
||||||
parts.push(`${workspaceUpdated.length} workspace secret(s) updated`)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`,
|
message: `Successfully processed ${Object.keys(validatedVariables).length} personal environment variable(s): ${added.length} added, ${updated.length} updated`,
|
||||||
variableCount: Object.keys(validatedVariables).length,
|
variableCount: Object.keys(validatedVariables).length,
|
||||||
variableNames: Object.keys(validatedVariables),
|
variableNames: Object.keys(validatedVariables),
|
||||||
|
totalVariableCount: Object.keys(finalEncrypted).length,
|
||||||
addedVariables: added,
|
addedVariables: added,
|
||||||
updatedVariables: updated,
|
updatedVariables: updated,
|
||||||
workspaceUpdatedVariables: workspaceUpdated,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { credential, credentialMember } from '@sim/db/schema'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
||||||
|
|
||||||
type ActiveCredentialMember = typeof credentialMember.$inferSelect
|
|
||||||
type CredentialRecord = typeof credential.$inferSelect
|
|
||||||
|
|
||||||
export interface CredentialActorContext {
|
|
||||||
credential: CredentialRecord | null
|
|
||||||
member: ActiveCredentialMember | null
|
|
||||||
hasWorkspaceAccess: boolean
|
|
||||||
canWriteWorkspace: boolean
|
|
||||||
isAdmin: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves user access context for a credential.
|
|
||||||
*/
|
|
||||||
export async function getCredentialActorContext(
|
|
||||||
credentialId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<CredentialActorContext> {
|
|
||||||
const [credentialRow] = await db
|
|
||||||
.select()
|
|
||||||
.from(credential)
|
|
||||||
.where(eq(credential.id, credentialId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!credentialRow) {
|
|
||||||
return {
|
|
||||||
credential: null,
|
|
||||||
member: null,
|
|
||||||
hasWorkspaceAccess: false,
|
|
||||||
canWriteWorkspace: false,
|
|
||||||
isAdmin: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceAccess = await checkWorkspaceAccess(credentialRow.workspaceId, userId)
|
|
||||||
const [memberRow] = await db
|
|
||||||
.select()
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
eq(credentialMember.userId, userId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const isAdmin = memberRow?.role === 'admin'
|
|
||||||
|
|
||||||
return {
|
|
||||||
credential: credentialRow,
|
|
||||||
member: memberRow ?? null,
|
|
||||||
hasWorkspaceAccess: workspaceAccess.hasAccess,
|
|
||||||
canWriteWorkspace: workspaceAccess.canWrite,
|
|
||||||
isAdmin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
export const PENDING_OAUTH_CREDENTIAL_DRAFT_KEY = 'sim.pending-oauth-credential-draft'
|
|
||||||
export const PENDING_CREDENTIAL_CREATE_REQUEST_KEY = 'sim.pending-credential-create-request'
|
|
||||||
|
|
||||||
export interface PendingOAuthCredentialDraft {
|
|
||||||
workspaceId: string
|
|
||||||
providerId: string
|
|
||||||
displayName: string
|
|
||||||
existingCredentialIds: string[]
|
|
||||||
existingAccountIds: string[]
|
|
||||||
requestedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingOAuthCredentialCreateRequest {
|
|
||||||
workspaceId: string
|
|
||||||
type: 'oauth'
|
|
||||||
providerId: string
|
|
||||||
displayName: string
|
|
||||||
serviceId: string
|
|
||||||
requiredScopes: string[]
|
|
||||||
requestedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingSecretCredentialCreateRequest {
|
|
||||||
workspaceId: string
|
|
||||||
type: 'env_personal' | 'env_workspace'
|
|
||||||
envKey?: string
|
|
||||||
requestedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PendingCredentialCreateRequest =
|
|
||||||
| PendingOAuthCredentialCreateRequest
|
|
||||||
| PendingSecretCredentialCreateRequest
|
|
||||||
|
|
||||||
function parseJson<T>(raw: string | null): T | null {
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as T
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPendingOAuthCredentialDraft(): PendingOAuthCredentialDraft | null {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
return parseJson<PendingOAuthCredentialDraft>(
|
|
||||||
window.sessionStorage.getItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writePendingOAuthCredentialDraft(payload: PendingOAuthCredentialDraft) {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.sessionStorage.setItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY, JSON.stringify(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPendingOAuthCredentialDraft() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.sessionStorage.removeItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPendingCredentialCreateRequest(): PendingCredentialCreateRequest | null {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
return parseJson<PendingCredentialCreateRequest>(
|
|
||||||
window.sessionStorage.getItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writePendingCredentialCreateRequest(payload: PendingCredentialCreateRequest) {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.sessionStorage.setItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY, JSON.stringify(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPendingCredentialCreateRequest() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { credential, credentialMember, permissions, workspace } from '@sim/db/schema'
|
|
||||||
import { and, eq, inArray, notInArray } from 'drizzle-orm'
|
|
||||||
|
|
||||||
interface AccessibleEnvCredential {
|
|
||||||
type: 'env_workspace' | 'env_personal'
|
|
||||||
envKey: string
|
|
||||||
envOwnerUserId: string | null
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPostgresErrorCode(error: unknown): string | undefined {
|
|
||||||
if (!error || typeof error !== 'object') return undefined
|
|
||||||
const err = error as { code?: string; cause?: { code?: string } }
|
|
||||||
return err.code || err.cause?.code
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorkspaceMemberUserIds(workspaceId: string): Promise<string[]> {
|
|
||||||
const [workspaceRows, permissionRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ ownerId: workspace.ownerId })
|
|
||||||
.from(workspace)
|
|
||||||
.where(eq(workspace.id, workspaceId))
|
|
||||||
.limit(1),
|
|
||||||
db
|
|
||||||
.select({ userId: permissions.userId })
|
|
||||||
.from(permissions)
|
|
||||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))),
|
|
||||||
])
|
|
||||||
const workspaceRow = workspaceRows[0]
|
|
||||||
|
|
||||||
const memberIds = new Set<string>(permissionRows.map((row) => row.userId))
|
|
||||||
if (workspaceRow?.ownerId) {
|
|
||||||
memberIds.add(workspaceRow.ownerId)
|
|
||||||
}
|
|
||||||
return Array.from(memberIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
|
||||||
const [permissionRows, ownedWorkspaceRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ workspaceId: workspace.id })
|
|
||||||
.from(permissions)
|
|
||||||
.innerJoin(
|
|
||||||
workspace,
|
|
||||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id))
|
|
||||||
)
|
|
||||||
.where(eq(permissions.userId, userId)),
|
|
||||||
db.select({ workspaceId: workspace.id }).from(workspace).where(eq(workspace.ownerId, userId)),
|
|
||||||
])
|
|
||||||
|
|
||||||
const workspaceIds = new Set<string>(permissionRows.map((row) => row.workspaceId))
|
|
||||||
for (const row of ownedWorkspaceRows) {
|
|
||||||
workspaceIds.add(row.workspaceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(workspaceIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertCredentialAdminMember(credentialId: string, adminUserId: string) {
|
|
||||||
const now = new Date()
|
|
||||||
const [existingMembership] = await db
|
|
||||||
.select({ id: credentialMember.id, joinedAt: credentialMember.joinedAt })
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, adminUserId))
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (existingMembership) {
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: existingMembership.joinedAt ?? now,
|
|
||||||
invitedBy: adminUserId,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(credentialMember.id, existingMembership.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId: adminUserId,
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: adminUserId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureWorkspaceCredentialMemberships(
|
|
||||||
credentialId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
ownerUserId: string
|
|
||||||
) {
|
|
||||||
const workspaceMemberUserIds = await getWorkspaceMemberUserIds(workspaceId)
|
|
||||||
if (!workspaceMemberUserIds.length) return
|
|
||||||
|
|
||||||
const existingMemberships = await db
|
|
||||||
.select({
|
|
||||||
id: credentialMember.id,
|
|
||||||
userId: credentialMember.userId,
|
|
||||||
status: credentialMember.status,
|
|
||||||
joinedAt: credentialMember.joinedAt,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credentialId),
|
|
||||||
inArray(credentialMember.userId, workspaceMemberUserIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const byUserId = new Map(existingMemberships.map((row) => [row.userId, row]))
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
for (const memberUserId of workspaceMemberUserIds) {
|
|
||||||
const targetRole = memberUserId === ownerUserId ? 'admin' : 'member'
|
|
||||||
const existing = byUserId.get(memberUserId)
|
|
||||||
if (existing) {
|
|
||||||
if (existing.status === 'revoked') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({
|
|
||||||
role: targetRole,
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: existing.joinedAt ?? now,
|
|
||||||
invitedBy: ownerUserId,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(credentialMember.id, existing.id))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId: memberUserId,
|
|
||||||
role: targetRole,
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: ownerUserId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncWorkspaceEnvCredentials(params: {
|
|
||||||
workspaceId: string
|
|
||||||
envKeys: string[]
|
|
||||||
actingUserId: string
|
|
||||||
}) {
|
|
||||||
const { workspaceId, envKeys, actingUserId } = params
|
|
||||||
const [workspaceRow] = await db
|
|
||||||
.select({ ownerId: workspace.ownerId })
|
|
||||||
.from(workspace)
|
|
||||||
.where(eq(workspace.id, workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!workspaceRow) return
|
|
||||||
|
|
||||||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
|
||||||
const existingCredentials = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
envKey: credential.envKey,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
|
||||||
|
|
||||||
const existingByKey = new Map(
|
|
||||||
existingCredentials
|
|
||||||
.filter((row): row is { id: string; envKey: string } => Boolean(row.envKey))
|
|
||||||
.map((row) => [row.envKey, row.id])
|
|
||||||
)
|
|
||||||
|
|
||||||
const credentialIdsToEnsureMembership = new Set<string>()
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
for (const envKey of normalizedKeys) {
|
|
||||||
const existingId = existingByKey.get(envKey)
|
|
||||||
if (existingId) {
|
|
||||||
credentialIdsToEnsureMembership.add(existingId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdId = crypto.randomUUID()
|
|
||||||
try {
|
|
||||||
await db.insert(credential).values({
|
|
||||||
id: createdId,
|
|
||||||
workspaceId,
|
|
||||||
type: 'env_workspace',
|
|
||||||
displayName: envKey,
|
|
||||||
envKey,
|
|
||||||
createdBy: actingUserId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
credentialIdsToEnsureMembership.add(createdId)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const code = getPostgresErrorCode(error)
|
|
||||||
if (code !== '23505') throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const credentialId of credentialIdsToEnsureMembership) {
|
|
||||||
await ensureWorkspaceCredentialMemberships(credentialId, workspaceId, workspaceRow.ownerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedKeys.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_workspace'),
|
|
||||||
notInArray(credential.envKey, normalizedKeys)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(credential)
|
|
||||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncPersonalEnvCredentialsForUser(params: {
|
|
||||||
userId: string
|
|
||||||
envKeys: string[]
|
|
||||||
}) {
|
|
||||||
const { userId, envKeys } = params
|
|
||||||
const workspaceIds = await getUserWorkspaceIds(userId)
|
|
||||||
if (!workspaceIds.length) return
|
|
||||||
|
|
||||||
const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean)))
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
for (const workspaceId of workspaceIds) {
|
|
||||||
const existingCredentials = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
envKey: credential.envKey,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_personal'),
|
|
||||||
eq(credential.envOwnerUserId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const existingByKey = new Map(
|
|
||||||
existingCredentials
|
|
||||||
.filter((row): row is { id: string; envKey: string } => Boolean(row.envKey))
|
|
||||||
.map((row) => [row.envKey, row.id])
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const envKey of normalizedKeys) {
|
|
||||||
const existingId = existingByKey.get(envKey)
|
|
||||||
if (existingId) {
|
|
||||||
await upsertCredentialAdminMember(existingId, userId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdId = crypto.randomUUID()
|
|
||||||
try {
|
|
||||||
await db.insert(credential).values({
|
|
||||||
id: createdId,
|
|
||||||
workspaceId,
|
|
||||||
type: 'env_personal',
|
|
||||||
displayName: envKey,
|
|
||||||
envKey,
|
|
||||||
envOwnerUserId: userId,
|
|
||||||
createdBy: userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
await upsertCredentialAdminMember(createdId, userId)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const code = getPostgresErrorCode(error)
|
|
||||||
if (code !== '23505') throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedKeys.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_personal'),
|
|
||||||
eq(credential.envOwnerUserId, userId),
|
|
||||||
notInArray(credential.envKey, normalizedKeys)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'env_personal'),
|
|
||||||
eq(credential.envOwnerUserId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAccessibleEnvCredentials(
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<AccessibleEnvCredential[]> {
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
type: credential.type,
|
|
||||||
envKey: credential.envKey,
|
|
||||||
envOwnerUserId: credential.envOwnerUserId,
|
|
||||||
updatedAt: credential.updatedAt,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.innerJoin(
|
|
||||||
credentialMember,
|
|
||||||
and(
|
|
||||||
eq(credentialMember.credentialId, credential.id),
|
|
||||||
eq(credentialMember.userId, userId),
|
|
||||||
eq(credentialMember.status, 'active')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
inArray(credential.type, ['env_workspace', 'env_personal'])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return rows
|
|
||||||
.filter(
|
|
||||||
(row): row is AccessibleEnvCredential =>
|
|
||||||
(row.type === 'env_workspace' || row.type === 'env_personal') && Boolean(row.envKey)
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
type: row.type,
|
|
||||||
envKey: row.envKey!,
|
|
||||||
envOwnerUserId: row.envOwnerUserId,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { account, credential, credentialMember } from '@sim/db/schema'
|
|
||||||
import { and, eq, inArray, notInArray } from 'drizzle-orm'
|
|
||||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
|
||||||
|
|
||||||
/** Provider IDs that are not real OAuth integrations (e.g. Better Auth's password provider) */
|
|
||||||
const NON_OAUTH_PROVIDER_IDS = ['credential'] as const
|
|
||||||
|
|
||||||
interface SyncWorkspaceOAuthCredentialsForUserParams {
|
|
||||||
workspaceId: string
|
|
||||||
userId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncWorkspaceOAuthCredentialsForUserResult {
|
|
||||||
createdCredentials: number
|
|
||||||
updatedMemberships: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPostgresErrorCode(error: unknown): string | undefined {
|
|
||||||
if (!error || typeof error !== 'object') return undefined
|
|
||||||
const err = error as { code?: string; cause?: { code?: string } }
|
|
||||||
return err.code || err.cause?.code
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures connected OAuth accounts for a user exist as workspace-scoped credentials.
|
|
||||||
*/
|
|
||||||
export async function syncWorkspaceOAuthCredentialsForUser(
|
|
||||||
params: SyncWorkspaceOAuthCredentialsForUserParams
|
|
||||||
): Promise<SyncWorkspaceOAuthCredentialsForUserResult> {
|
|
||||||
const { workspaceId, userId } = params
|
|
||||||
|
|
||||||
const userAccounts = await db
|
|
||||||
.select({
|
|
||||||
id: account.id,
|
|
||||||
providerId: account.providerId,
|
|
||||||
accountId: account.accountId,
|
|
||||||
})
|
|
||||||
.from(account)
|
|
||||||
.where(
|
|
||||||
and(eq(account.userId, userId), notInArray(account.providerId, [...NON_OAUTH_PROVIDER_IDS]))
|
|
||||||
)
|
|
||||||
|
|
||||||
if (userAccounts.length === 0) {
|
|
||||||
return { createdCredentials: 0, updatedMemberships: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountIds = userAccounts.map((row) => row.id)
|
|
||||||
const existingCredentials = await db
|
|
||||||
.select({
|
|
||||||
id: credential.id,
|
|
||||||
displayName: credential.displayName,
|
|
||||||
providerId: credential.providerId,
|
|
||||||
accountId: credential.accountId,
|
|
||||||
})
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'oauth'),
|
|
||||||
inArray(credential.accountId, accountIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const userAccountById = new Map(userAccounts.map((row) => [row.id, row]))
|
|
||||||
for (const existingCredential of existingCredentials) {
|
|
||||||
if (!existingCredential.accountId) continue
|
|
||||||
const linkedAccount = userAccountById.get(existingCredential.accountId)
|
|
||||||
if (!linkedAccount) continue
|
|
||||||
|
|
||||||
const normalizedLabel =
|
|
||||||
getServiceConfigByProviderId(linkedAccount.providerId)?.name || linkedAccount.providerId
|
|
||||||
const shouldNormalizeDisplayName =
|
|
||||||
existingCredential.displayName === linkedAccount.accountId ||
|
|
||||||
existingCredential.displayName === linkedAccount.providerId
|
|
||||||
|
|
||||||
if (!shouldNormalizeDisplayName || existingCredential.displayName === normalizedLabel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(credential)
|
|
||||||
.set({
|
|
||||||
displayName: normalizedLabel,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(credential.id, existingCredential.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingByAccountId = new Map(
|
|
||||||
existingCredentials
|
|
||||||
.filter((row) => Boolean(row.accountId))
|
|
||||||
.map((row) => [row.accountId!, row.id])
|
|
||||||
)
|
|
||||||
|
|
||||||
let createdCredentials = 0
|
|
||||||
|
|
||||||
for (const acc of userAccounts) {
|
|
||||||
if (existingByAccountId.has(acc.id)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(credential).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
workspaceId,
|
|
||||||
type: 'oauth',
|
|
||||||
displayName: getServiceConfigByProviderId(acc.providerId)?.name || acc.providerId,
|
|
||||||
providerId: acc.providerId,
|
|
||||||
accountId: acc.id,
|
|
||||||
createdBy: userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
createdCredentials += 1
|
|
||||||
} catch (error) {
|
|
||||||
if (getPostgresErrorCode(error) !== '23505') {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentialRows = await db
|
|
||||||
.select({ id: credential.id, accountId: credential.accountId })
|
|
||||||
.from(credential)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(credential.workspaceId, workspaceId),
|
|
||||||
eq(credential.type, 'oauth'),
|
|
||||||
inArray(credential.accountId, accountIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const credentialIdByAccountId = new Map(
|
|
||||||
credentialRows.filter((row) => Boolean(row.accountId)).map((row) => [row.accountId!, row.id])
|
|
||||||
)
|
|
||||||
const allCredentialIds = Array.from(credentialIdByAccountId.values())
|
|
||||||
if (allCredentialIds.length === 0) {
|
|
||||||
return { createdCredentials, updatedMemberships: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMemberships = await db
|
|
||||||
.select({
|
|
||||||
id: credentialMember.id,
|
|
||||||
credentialId: credentialMember.credentialId,
|
|
||||||
joinedAt: credentialMember.joinedAt,
|
|
||||||
})
|
|
||||||
.from(credentialMember)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(credentialMember.credentialId, allCredentialIds),
|
|
||||||
eq(credentialMember.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const membershipByCredentialId = new Map(
|
|
||||||
existingMemberships.map((row) => [row.credentialId, row])
|
|
||||||
)
|
|
||||||
let updatedMemberships = 0
|
|
||||||
|
|
||||||
for (const credentialId of allCredentialIds) {
|
|
||||||
const existingMembership = membershipByCredentialId.get(credentialId)
|
|
||||||
if (existingMembership) {
|
|
||||||
await db
|
|
||||||
.update(credentialMember)
|
|
||||||
.set({
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: existingMembership.joinedAt ?? now,
|
|
||||||
invitedBy: userId,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(credentialMember.id, existingMembership.id))
|
|
||||||
updatedMemberships += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(credentialMember).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
credentialId,
|
|
||||||
userId,
|
|
||||||
role: 'admin',
|
|
||||||
status: 'active',
|
|
||||||
joinedAt: now,
|
|
||||||
invitedBy: userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
updatedMemberships += 1
|
|
||||||
} catch (error) {
|
|
||||||
if (getPostgresErrorCode(error) !== '23505') {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdCredentials, updatedMemberships }
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq, inArray } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { getAccessibleEnvCredentials } from '@/lib/credentials/environment'
|
|
||||||
|
|
||||||
const logger = createLogger('EnvironmentUtils')
|
const logger = createLogger('EnvironmentUtils')
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ export async function getPersonalAndWorkspaceEnv(
|
|||||||
conflicts: string[]
|
conflicts: string[]
|
||||||
decryptionFailures: string[]
|
decryptionFailures: string[]
|
||||||
}> {
|
}> {
|
||||||
const [personalRows, workspaceRows, accessibleEnvCredentials] = await Promise.all([
|
const [personalRows, workspaceRows] = await Promise.all([
|
||||||
db.select().from(environment).where(eq(environment.userId, userId)).limit(1),
|
db.select().from(environment).where(eq(environment.userId, userId)).limit(1),
|
||||||
workspaceId
|
workspaceId
|
||||||
? db
|
? db
|
||||||
@@ -63,69 +62,10 @@ export async function getPersonalAndWorkspaceEnv(
|
|||||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: Promise.resolve([] as any[]),
|
: Promise.resolve([] as any[]),
|
||||||
workspaceId ? getAccessibleEnvCredentials(workspaceId, userId) : Promise.resolve([]),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const ownPersonalEncrypted: Record<string, string> = (personalRows[0]?.variables as any) || {}
|
const personalEncrypted: Record<string, string> = (personalRows[0]?.variables as any) || {}
|
||||||
const allWorkspaceEncrypted: Record<string, string> = (workspaceRows[0]?.variables as any) || {}
|
const workspaceEncrypted: Record<string, string> = (workspaceRows[0]?.variables as any) || {}
|
||||||
|
|
||||||
const hasCredentialFiltering = Boolean(workspaceId) && accessibleEnvCredentials.length > 0
|
|
||||||
const workspaceCredentialKeys = new Set(
|
|
||||||
accessibleEnvCredentials.filter((row) => row.type === 'env_workspace').map((row) => row.envKey)
|
|
||||||
)
|
|
||||||
|
|
||||||
const personalCredentialRows = accessibleEnvCredentials
|
|
||||||
.filter((row) => row.type === 'env_personal' && row.envOwnerUserId)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aIsRequester = a.envOwnerUserId === userId
|
|
||||||
const bIsRequester = b.envOwnerUserId === userId
|
|
||||||
if (aIsRequester && !bIsRequester) return -1
|
|
||||||
if (!aIsRequester && bIsRequester) return 1
|
|
||||||
return b.updatedAt.getTime() - a.updatedAt.getTime()
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedPersonalOwners = new Map<string, string>()
|
|
||||||
for (const row of personalCredentialRows) {
|
|
||||||
if (!selectedPersonalOwners.has(row.envKey) && row.envOwnerUserId) {
|
|
||||||
selectedPersonalOwners.set(row.envKey, row.envOwnerUserId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerUserIds = Array.from(new Set(selectedPersonalOwners.values()))
|
|
||||||
const ownerEnvironmentRows =
|
|
||||||
ownerUserIds.length > 0
|
|
||||||
? await db
|
|
||||||
.select({
|
|
||||||
userId: environment.userId,
|
|
||||||
variables: environment.variables,
|
|
||||||
})
|
|
||||||
.from(environment)
|
|
||||||
.where(inArray(environment.userId, ownerUserIds))
|
|
||||||
: []
|
|
||||||
|
|
||||||
const ownerVariablesByUserId = new Map<string, Record<string, string>>(
|
|
||||||
ownerEnvironmentRows.map((row) => [row.userId, (row.variables as Record<string, string>) || {}])
|
|
||||||
)
|
|
||||||
|
|
||||||
let personalEncrypted: Record<string, string> = ownPersonalEncrypted
|
|
||||||
let workspaceEncrypted: Record<string, string> = allWorkspaceEncrypted
|
|
||||||
|
|
||||||
if (hasCredentialFiltering) {
|
|
||||||
personalEncrypted = {}
|
|
||||||
for (const [envKey, ownerUserId] of selectedPersonalOwners.entries()) {
|
|
||||||
const ownerVariables = ownerVariablesByUserId.get(ownerUserId)
|
|
||||||
const encryptedValue = ownerVariables?.[envKey]
|
|
||||||
if (encryptedValue) {
|
|
||||||
personalEncrypted[envKey] = encryptedValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceEncrypted = Object.fromEntries(
|
|
||||||
Object.entries(allWorkspaceEncrypted).filter(([envKey]) =>
|
|
||||||
workspaceCredentialKeys.has(envKey)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptionFailures: string[] = []
|
const decryptionFailures: string[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'read:attachment:confluence',
|
'read:attachment:confluence',
|
||||||
'write:attachment:confluence',
|
'write:attachment:confluence',
|
||||||
'search:confluence',
|
'search:confluence',
|
||||||
|
'read:blogpost:confluence',
|
||||||
|
'write:blogpost:confluence',
|
||||||
|
'read:content.property:confluence',
|
||||||
|
'write:content.property:confluence',
|
||||||
|
'read:hierarchical-content:confluence',
|
||||||
|
'read:content.metadata:confluence',
|
||||||
'read:me',
|
'read:me',
|
||||||
'offline_access',
|
'offline_access',
|
||||||
],
|
],
|
||||||
@@ -368,6 +374,14 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'read:comment.property:jira',
|
'read:comment.property:jira',
|
||||||
'read:jql:jira',
|
'read:jql:jira',
|
||||||
'read:field:jira',
|
'read:field:jira',
|
||||||
|
// Project management (components, versions)
|
||||||
|
'manage:jira-project',
|
||||||
|
// Jira Software / Agile scopes (no classic equivalent)
|
||||||
|
'read:board-scope:jira-software',
|
||||||
|
'write:board-scope:jira-software',
|
||||||
|
'read:sprint:jira-software',
|
||||||
|
'write:sprint:jira-software',
|
||||||
|
'delete:sprint:jira-software',
|
||||||
// Jira Service Management scopes
|
// Jira Service Management scopes
|
||||||
'read:servicedesk:jira-service-management',
|
'read:servicedesk:jira-service-management',
|
||||||
'read:requesttype:jira-service-management',
|
'read:requesttype:jira-service-management',
|
||||||
@@ -397,6 +411,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'write:request.participant:jira-service-management',
|
'write:request.participant:jira-service-management',
|
||||||
'read:request.approval:jira-service-management',
|
'read:request.approval:jira-service-management',
|
||||||
'write:request.approval:jira-service-management',
|
'write:request.approval:jira-service-management',
|
||||||
|
'read:request.feedback:jira-service-management',
|
||||||
|
'write:request.feedback:jira-service-management',
|
||||||
|
'delete:request.feedback:jira-service-management',
|
||||||
|
'read:request.notification:jira-service-management',
|
||||||
|
'write:request.notification:jira-service-management',
|
||||||
|
'delete:request.notification:jira-service-management',
|
||||||
|
'read:request.attachment:jira-service-management',
|
||||||
|
'read:knowledgebase:jira-service-management',
|
||||||
|
'delete:organization:jira-service-management',
|
||||||
|
'delete:servicedesk.customer:jira-service-management',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ import {
|
|||||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||||
|
import { isConfluenceEventMatch } from '@/triggers/confluence/utils'
|
||||||
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
||||||
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
||||||
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
||||||
|
import { isJsmEventMatch } from '@/triggers/jsm/utils'
|
||||||
|
|
||||||
const logger = createLogger('WebhookProcessor')
|
const logger = createLogger('WebhookProcessor')
|
||||||
|
|
||||||
@@ -681,7 +683,7 @@ export async function verifyProviderAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'jira') {
|
if (foundWebhook.provider === 'jira' || foundWebhook.provider === 'jira_service_management') {
|
||||||
const secret = providerConfig.secret as string | undefined
|
const secret = providerConfig.secret as string | undefined
|
||||||
|
|
||||||
if (secret) {
|
if (secret) {
|
||||||
@@ -706,6 +708,31 @@ export async function verifyProviderAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (foundWebhook.provider === 'confluence') {
|
||||||
|
const secret = providerConfig.secret as string | undefined
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
const signature = request.headers.get('X-Hub-Signature')
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
logger.warn(`[${requestId}] Confluence webhook missing signature header`)
|
||||||
|
return new NextResponse('Unauthorized - Missing Confluence signature', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidSignature = validateJiraSignature(secret, signature, rawBody)
|
||||||
|
|
||||||
|
if (!isValidSignature) {
|
||||||
|
logger.warn(`[${requestId}] Confluence signature verification failed`, {
|
||||||
|
signatureLength: signature.length,
|
||||||
|
secretLength: secret.length,
|
||||||
|
})
|
||||||
|
return new NextResponse('Unauthorized - Invalid Confluence signature', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[${requestId}] Confluence signature verified successfully`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'github') {
|
if (foundWebhook.provider === 'github') {
|
||||||
const secret = providerConfig.secret as string | undefined
|
const secret = providerConfig.secret as string | undefined
|
||||||
|
|
||||||
@@ -929,6 +956,60 @@ export async function queueWebhookExecution(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSM event filtering for event-specific triggers
|
||||||
|
if (foundWebhook.provider === 'jira_service_management') {
|
||||||
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||||
|
const triggerId = providerConfig.triggerId as string | undefined
|
||||||
|
|
||||||
|
if (triggerId && triggerId !== 'jsm_webhook') {
|
||||||
|
const webhookEvent = body.webhookEvent as string | undefined
|
||||||
|
|
||||||
|
if (!isJsmEventMatch(triggerId, webhookEvent || '', body)) {
|
||||||
|
logger.debug(
|
||||||
|
`[${options.requestId}] JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`,
|
||||||
|
{
|
||||||
|
webhookId: foundWebhook.id,
|
||||||
|
workflowId: foundWorkflow.id,
|
||||||
|
triggerId,
|
||||||
|
receivedEvent: webhookEvent,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return 200 OK to prevent Jira from retrying
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Event type does not match trigger configuration. Ignoring.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confluence event filtering for event-specific triggers
|
||||||
|
if (foundWebhook.provider === 'confluence') {
|
||||||
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||||
|
const triggerId = providerConfig.triggerId as string | undefined
|
||||||
|
|
||||||
|
if (triggerId && triggerId !== 'confluence_webhook') {
|
||||||
|
const event = body.event as string | undefined
|
||||||
|
|
||||||
|
if (!isConfluenceEventMatch(triggerId, event || '')) {
|
||||||
|
logger.debug(
|
||||||
|
`[${options.requestId}] Confluence event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
|
||||||
|
{
|
||||||
|
webhookId: foundWebhook.id,
|
||||||
|
workflowId: foundWorkflow.id,
|
||||||
|
triggerId,
|
||||||
|
receivedEvent: event,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return 200 OK to prevent Confluence from retrying
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Event type does not match trigger configuration. Ignoring.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'hubspot') {
|
if (foundWebhook.provider === 'hubspot') {
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
const triggerId = providerConfig.triggerId as string | undefined
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const PROVIDER_EXTRACTORS: Record<string, (body: any) => string | null> = {
|
|||||||
hubspot: extractHubSpotIdentifier,
|
hubspot: extractHubSpotIdentifier,
|
||||||
linear: extractLinearIdentifier,
|
linear: extractLinearIdentifier,
|
||||||
jira: extractJiraIdentifier,
|
jira: extractJiraIdentifier,
|
||||||
|
jira_service_management: extractJiraIdentifier,
|
||||||
'microsoft-teams': extractMicrosoftTeamsIdentifier,
|
'microsoft-teams': extractMicrosoftTeamsIdentifier,
|
||||||
airtable: extractAirtableIdentifier,
|
airtable: extractAirtableIdentifier,
|
||||||
grain: extractGrainIdentifier,
|
grain: extractGrainIdentifier,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user