mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fcd02fd3b | ||
|
|
6326353f5c | ||
|
|
d3daab743f | ||
|
|
0d22cc3186 | ||
|
|
413c45d863 | ||
|
|
30b7192e75 | ||
|
|
17bdc80eb9 | ||
|
|
c3c22e4674 | ||
|
|
ce3d2d5e95 | ||
|
|
507954c2d5 | ||
|
|
25789855af | ||
|
|
27a41d4e33 |
@@ -124,6 +124,34 @@ export function NoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkdayIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const clipId = `workday_clip_${id}`
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath={`url(#${clipId})`} transform='matrix(0.53333333,0,0,0.53333333,-124.63685,-16)'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='m 251.21,88.7755 h 8.224 c 1.166,0 2.178,0.7836 2.444,1.8924 l 11.057,44.6751 c 0.152,0.002 12.182,-44.6393 12.182,-44.6393 0.306,-1.1361 1.36,-1.9282 2.566,-1.9282 h 12.74 c 1.144,0 2.144,0.7515 2.435,1.8296 l 12.118,44.9289 c 0.448,-0.282 11.147,-44.8661 11.147,-44.8661 0.267,-1.1088 1.279,-1.8924 2.444,-1.8924 h 8.219 c 1.649,0 2.854,1.5192 2.437,3.0742 l -15.08,56.3173 c -0.286,1.072 -1.272,1.823 -2.406,1.833 l -12.438,-0.019 c -1.142,-0.002 -2.137,-0.744 -2.429,-1.819 -2.126,-7.805 -12.605,-47.277 -12.605,-47.277 0,0 -11.008,39.471 -13.133,47.277 -0.293,1.075 -1.288,1.817 -2.429,1.819 L 266.264,150 c -1.133,-0.01 -2.119,-0.761 -2.406,-1.833 L 248.777,91.8438 c -0.416,-1.5524 0.786,-3.0683 2.433,-3.0683 z'
|
||||
fill='#005cb9'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='m 333.324,72.2449 c 0.531,0 1.071,-0.0723 1.608,-0.2234 3.18,-0.8968 5.039,-4.2303 4.153,-7.446 -0.129,-0.4673 -0.265,-0.9327 -0.408,-1.3936 C 332.529,43.3349 314.569,30 293.987,30 c -20.557,0 -38.51,13.3133 -44.673,33.1281 -0.136,0.4355 -0.267,0.8782 -0.391,1.3232 -0.902,3.2119 0.943,6.5541 4.12,7.4645 3.173,0.9112 6.48,-0.9547 7.381,-4.1666 0.094,-0.3322 0.19,-0.6616 0.292,-0.9892 4.591,-14.7582 17.961,-24.6707 33.271,-24.6707 15.329,0 28.704,9.9284 33.281,24.7063 0.105,0.3397 0.206,0.682 0.301,1.0263 0.737,2.6726 3.139,4.423 5.755,4.423 z'
|
||||
fill='#f38b00'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d='M 354,30 H 234 v 120 h 120 z' fill='#ffffff' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -4110,6 +4138,16 @@ export function IncidentioIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function InfisicalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 273 182' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m191.6 39.4c-20.3 0-37.15 13.21-52.9 30.61-12.99-16.4-29.8-30.61-51.06-30.61-27.74 0-50.44 23.86-50.44 51.33 0 26.68 21.43 51.8 48.98 51.8 20.55 0 37.07-13.86 51.32-31.81 12.69 16.97 29.1 31.41 53.2 31.41 27.13 0 49.85-22.96 49.85-51.4 0-27.12-20.44-51.33-48.95-51.33zm-104.3 77.94c-14.56 0-25.51-12.84-25.51-26.07 0-13.7 10.95-28.29 25.51-28.29 14.93 0 25.71 11.6 37.6 27.34-11.31 15.21-22.23 27.02-37.6 27.02zm104.4 0.25c-15 0-25.28-11.13-37.97-27.37 12.69-16.4 22.01-27.24 37.59-27.24 14.97 0 24.79 13.25 24.79 27.26 0 13-10.17 27.35-24.41 27.35z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function IntercomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -6068,6 +6106,19 @@ export function AgentSkillsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function OktaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 63 63' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M34.6.4l-1.3 16c-.6-.1-1.2-.1-1.9-.1-.8 0-1.6.1-2.3.2l-.7-7.7c0-.2.2-.5.4-.5h1.3L29.5.5c0-.2.2-.5.4-.5h4.3c.3 0 .5.2.4.4zm-10.8.8c-.1-.2-.3-.4-.5-.3l-4 1.5c-.3.1-.4.4-.3.6l3.3 7.1-1.2.5c-.2.1-.3.3-.2.6l3.3 7c1.2-.7 2.5-1.2 3.9-1.5L23.8 1.2zM14 5.7l9.3 13.1c-1.2.8-2.2 1.7-3.1 2.7L14.5 16c-.2-.2-.2-.5 0-.6l1-.8L10 9c-.2-.2-.2-.5 0-.6l3.3-2.7c.2-.3.5-.2.7 0zM6.2 13.2c-.2-.1-.5-.1-.6.1l-2.1 3.7c-.1.2 0 .5.2.6l7.1 3.4-.7 1.1c-.1.2 0 .5.2.6l7.1 3.2c.5-1.3 1.2-2.5 2-3.6L6.2 13.2zM.9 23.3c0-.2.3-.4.5-.3l15.5 4c-.4 1.3-.6 2.7-.7 4.1l-7.8-.6c-.2 0-.4-.2-.4-.5l.2-1.3L.6 28c-.2 0-.4-.2-.4-.5l.7-4.2zM.4 33.8c-.3 0-.4.2-.4.5l.8 4.2c0 .2.3.4.5.3l7.6-2 .2 1.3c0 .2.3.4.5.3l7.5-2.1c-.4-1.3-.7-2.7-.8-4.1L.4 33.8zm2.5 11.1c-.1-.2 0-.5.2-.6l14.5-6.9c.5 1.3 1.3 2.5 2.2 3.6l-6.3 4.5c-.2.1-.5.1-.6-.1L12 44.3l-6.5 4.5c-.2.1-.5.1-.6-.1l-2-3.8zm17.5-3L9.1 53.3c-.2.2-.2.5 0 .6l3.3 2.7c.2.2.5.1.6-.1l4.6-6.4 1 .9c.2.2.5.1.6-.1l4.4-6.4c-1.2-.7-2.3-1.6-3.2-2.6zm-2.2 18.2c-.2-.1-.3-.3-.2-.6L24.6 45c1.2.6 2.6 1.1 3.9 1.4l-2 7.5c-.1.2-.3.4-.5.3l-1.2-.5-2.1 7.6c-.1.2-.3.4-.5.3l-4-1.5zm10.9-13.5l-1.3 16c0 .2.2.5.4.5H33c.2 0 .4-.2.4-.5l-.6-7.8h1.3c.2 0 .4-.2.4-.5l-.7-7.7c-.8.1-1.5.2-2.3.2-.6 0-1.3 0-1.9-.1zm16-43.2c.1-.2 0-.5-.2-.6l-4-1.5c-.2-.1-.5.1-.5.3l-2.1 7.6-1.2-.5c-.2-.1-.5.1-.5.3l-2 7.5c1.4.3 2.7.8 3.9 1.4l6.6-14.5zm8.8 6.3L42.6 21.1c-.9-1-2-1.9-3.2-2.6l4.4-6.4c.1-.2.4-.2.6-.1l1 .9 4.6-6.4c.1-.2.4-.2.6-.1l3.3 2.7c.2.2.2.5 0 .6zM59.9 18.7c.2-.1.3-.4.2-.6L58 14.4c-.1-.2-.4-.3-.6-.1l-6.5 4.5-.7-1.1c-.1-.2-.4-.3-.6-.1L43.3 22c.9 1.1 1.6 2.3 2.2 3.6l14.4-6.9zm2.3 5.8l.7 4.2c0 .2-.1.5-.4.5l-15.9 1.5c-.1-1.4-.4-2.8-.8-4.1l7.5-2.1c.2-.1.5.1.5.3l.2 1.3 7.6-2c.3-.1.5.1.6.4zM61.5 40c.2.1.5-.1.5-.3l.7-4.2c0-.2-.1-.5-.4-.5l-7.8-.7.2-1.3c0-.2-.1-.5-.4-.5l-7.8-.6c0 1.4-.3 2.8-.7 4.1L61.5 40zm-4.1 9.6c-.1.2-.4.3-.6.1l-13.2-9.1c.8-1.1 1.5-2.3 2-3.6l7.1 3.2c.2.1.3.4.2.6L52.2 42l7.1 3.4c.2.1.3.4.2.6l-2.1 3.6zm-17.7-5.4L49 57.3c.1.2.4.2.6.1l3.3-2.7c.2-.2.2-.4 0-.6l-5.5-5.6 1-.8c.2-.2.2-.4 0-.6l-5.5-5.5c1.1.8 0 1.7-1.2 2.4zm0 17.8c-.2.1-.5-.1-.5-.3l-4.2-15.4c1.4-.3 2.7-.8 3.9-1.5l3.3 7c.1.2 0 .5-.2.6l-1.2.5 3.3 7.1c.1.2 0 .5-.2.6L39.7 62z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 48 48' xmlns='http://www.w3.org/2000/svg' fill='none'>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AsanaIcon,
|
||||
AshbyIcon,
|
||||
AttioIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
BrandfetchIcon,
|
||||
@@ -81,6 +82,7 @@ import {
|
||||
HunterIOIcon,
|
||||
ImageIcon,
|
||||
IncidentioIcon,
|
||||
InfisicalIcon,
|
||||
IntercomIcon,
|
||||
JinaAIIcon,
|
||||
JiraIcon,
|
||||
@@ -109,6 +111,7 @@ import {
|
||||
Neo4jIcon,
|
||||
NotionIcon,
|
||||
ObsidianIcon,
|
||||
OktaIcon,
|
||||
OnePasswordIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
@@ -164,6 +167,7 @@ import {
|
||||
WhatsAppIcon,
|
||||
WikipediaIcon,
|
||||
WordpressIcon,
|
||||
WorkdayIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
ZendeskIcon,
|
||||
@@ -250,6 +254,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
infisical: InfisicalIcon,
|
||||
intercom_v2: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
@@ -267,6 +272,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_ad: AzureIcon,
|
||||
microsoft_dataverse: MicrosoftDataverseIcon,
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
@@ -277,6 +283,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
neo4j: Neo4jIcon,
|
||||
notion_v2: NotionIcon,
|
||||
obsidian: ObsidianIcon,
|
||||
okta: OktaIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
onepassword: OnePasswordIcon,
|
||||
openai: OpenAIIcon,
|
||||
@@ -335,6 +342,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
whatsapp: WhatsAppIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
wordpress: WordpressIcon,
|
||||
workday: WorkdayIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
|
||||
@@ -30,12 +30,50 @@ In Sim, the Ashby integration enables your agents to programmatically manage you
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Ashby into the workflow. Can list, search, create, and update candidates, list and get job details, create notes, list notes, list and get applications, create applications, and list offers.
|
||||
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `ashby_add_candidate_tag`
|
||||
|
||||
Adds a tag to a candidate in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `candidateId` | string | Yes | The UUID of the candidate to add the tag to |
|
||||
| `tagId` | string | Yes | The UUID of the tag to add |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the tag was successfully added |
|
||||
|
||||
### `ashby_change_application_stage`
|
||||
|
||||
Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `applicationId` | string | Yes | The UUID of the application to update the stage of |
|
||||
| `interviewStageId` | string | Yes | The UUID of the interview stage to move the application to |
|
||||
| `archiveReasonId` | string | No | Archive reason UUID. Required when moving to an Archived stage, ignored otherwise |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `applicationId` | string | Application UUID |
|
||||
| `stageId` | string | New interview stage UUID |
|
||||
|
||||
### `ashby_create_application`
|
||||
|
||||
Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.
|
||||
@@ -57,23 +95,7 @@ Creates a new application for a candidate on a job. Optionally specify interview
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Created application UUID |
|
||||
| `status` | string | Application status \(Active, Hired, Archived, Lead\) |
|
||||
| `candidate` | object | Associated candidate |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Candidate name |
|
||||
| `job` | object | Associated job |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| `currentInterviewStage` | object | Current interview stage |
|
||||
| ↳ `id` | string | Stage UUID |
|
||||
| ↳ `title` | string | Stage title |
|
||||
| ↳ `type` | string | Stage type |
|
||||
| `source` | object | Application source |
|
||||
| ↳ `id` | string | Source UUID |
|
||||
| ↳ `title` | string | Source title |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `applicationId` | string | Created application UUID |
|
||||
|
||||
### `ashby_create_candidate`
|
||||
|
||||
@@ -85,10 +107,8 @@ Creates a new candidate record in Ashby.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `name` | string | Yes | The candidate full name |
|
||||
| `email` | string | No | Primary email address for the candidate |
|
||||
| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) |
|
||||
| `email` | string | Yes | Primary email address for the candidate |
|
||||
| `phoneNumber` | string | No | Primary phone number for the candidate |
|
||||
| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) |
|
||||
| `linkedInUrl` | string | No | LinkedIn profile URL |
|
||||
| `githubUrl` | string | No | GitHub profile URL |
|
||||
| `sourceId` | string | No | UUID of the source to attribute the candidate to |
|
||||
@@ -127,14 +147,7 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Created note UUID |
|
||||
| `content` | string | Note content as stored |
|
||||
| `author` | object | Note author |
|
||||
| ↳ `id` | string | Author user UUID |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `noteId` | string | Created note UUID |
|
||||
|
||||
### `ashby_get_application`
|
||||
|
||||
@@ -228,7 +241,7 @@ Retrieves full details about a single job by its ID.
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Job UUID |
|
||||
| `title` | string | Job title |
|
||||
| `status` | string | Job status \(Open, Closed, Draft, Archived, On Hold\) |
|
||||
| `status` | string | Job status \(Open, Closed, Draft, Archived\) |
|
||||
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
|
||||
| `departmentId` | string | Department UUID |
|
||||
| `locationId` | string | Location UUID |
|
||||
@@ -237,6 +250,58 @@ Retrieves full details about a single job by its ID.
|
||||
| `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_get_job_posting`
|
||||
|
||||
Retrieves full details about a single job posting by its ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `jobPostingId` | string | Yes | The UUID of the job posting to fetch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Job posting UUID |
|
||||
| `title` | string | Job posting title |
|
||||
| `jobId` | string | Associated job UUID |
|
||||
| `locationName` | string | Location name |
|
||||
| `departmentName` | string | Department name |
|
||||
| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
|
||||
| `descriptionPlain` | string | Job posting description in plain text |
|
||||
| `isListed` | boolean | Whether the posting is publicly listed |
|
||||
| `publishedDate` | string | ISO 8601 published date |
|
||||
| `externalLink` | string | External link to the job posting |
|
||||
|
||||
### `ashby_get_offer`
|
||||
|
||||
Retrieves full details about a single offer by its ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `offerId` | string | Yes | The UUID of the offer to fetch |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Offer UUID |
|
||||
| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) |
|
||||
| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) |
|
||||
| `applicationId` | string | Associated application UUID |
|
||||
| `startDate` | string | Offer start date |
|
||||
| `salary` | object | Salary details |
|
||||
| ↳ `currencyCode` | string | ISO 4217 currency code |
|
||||
| ↳ `value` | number | Salary amount |
|
||||
| `openingId` | string | Associated opening UUID |
|
||||
| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) |
|
||||
|
||||
### `ashby_list_applications`
|
||||
|
||||
Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.
|
||||
@@ -278,6 +343,45 @@ Lists all applications in an Ashby organization with pagination and optional fil
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_archive_reasons`
|
||||
|
||||
Lists all archive reasons configured in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `archiveReasons` | array | List of archive reasons |
|
||||
| ↳ `id` | string | Archive reason UUID |
|
||||
| ↳ `text` | string | Archive reason text |
|
||||
| ↳ `reasonType` | string | Reason type |
|
||||
| ↳ `isArchived` | boolean | Whether the reason is archived |
|
||||
|
||||
### `ashby_list_candidate_tags`
|
||||
|
||||
Lists all candidate tags configured in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tags` | array | List of candidate tags |
|
||||
| ↳ `id` | string | Tag UUID |
|
||||
| ↳ `title` | string | Tag title |
|
||||
| ↳ `isArchived` | boolean | Whether the tag is archived |
|
||||
|
||||
### `ashby_list_candidates`
|
||||
|
||||
Lists all candidates in an Ashby organization with cursor-based pagination.
|
||||
@@ -310,6 +414,98 @@ Lists all candidates in an Ashby organization with cursor-based pagination.
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_custom_fields`
|
||||
|
||||
Lists all custom field definitions configured in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `customFields` | array | List of custom field definitions |
|
||||
| ↳ `id` | string | Custom field UUID |
|
||||
| ↳ `title` | string | Custom field title |
|
||||
| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) |
|
||||
| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) |
|
||||
| ↳ `isArchived` | boolean | Whether the custom field is archived |
|
||||
|
||||
### `ashby_list_departments`
|
||||
|
||||
Lists all departments in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `departments` | array | List of departments |
|
||||
| ↳ `id` | string | Department UUID |
|
||||
| ↳ `name` | string | Department name |
|
||||
| ↳ `isArchived` | boolean | Whether the department is archived |
|
||||
| ↳ `parentId` | string | Parent department UUID |
|
||||
|
||||
### `ashby_list_interviews`
|
||||
|
||||
Lists interview schedules in Ashby, optionally filtered by application or interview stage.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `applicationId` | string | No | The UUID of the application to list interview schedules for |
|
||||
| `interviewStageId` | string | No | The UUID of the interview stage to list interview schedules for |
|
||||
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `interviewSchedules` | array | List of interview schedules |
|
||||
| ↳ `id` | string | Interview schedule UUID |
|
||||
| ↳ `applicationId` | string | Associated application UUID |
|
||||
| ↳ `interviewStageId` | string | Interview stage UUID |
|
||||
| ↳ `status` | string | Schedule status |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_job_postings`
|
||||
|
||||
Lists all job postings in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `jobPostings` | array | List of job postings |
|
||||
| ↳ `id` | string | Job posting UUID |
|
||||
| ↳ `title` | string | Job posting title |
|
||||
| ↳ `jobId` | string | Associated job UUID |
|
||||
| ↳ `locationName` | string | Location name |
|
||||
| ↳ `departmentName` | string | Department name |
|
||||
| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
|
||||
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
|
||||
| ↳ `publishedDate` | string | ISO 8601 published date |
|
||||
|
||||
### `ashby_list_jobs`
|
||||
|
||||
Lists all jobs in an Ashby organization. By default returns Open, Closed, and Archived jobs. Specify status to filter.
|
||||
@@ -339,6 +535,30 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_locations`
|
||||
|
||||
Lists all locations configured in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `locations` | array | List of locations |
|
||||
| ↳ `id` | string | Location UUID |
|
||||
| ↳ `name` | string | Location name |
|
||||
| ↳ `isArchived` | boolean | Whether the location is archived |
|
||||
| ↳ `isRemote` | boolean | Whether this is a remote location |
|
||||
| ↳ `address` | object | Location address |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `region` | string | State or region |
|
||||
| ↳ `country` | string | Country |
|
||||
|
||||
### `ashby_list_notes`
|
||||
|
||||
Lists all notes on a candidate with pagination support.
|
||||
@@ -386,18 +606,106 @@ Lists all offers with their latest version in an Ashby organization.
|
||||
| --------- | ---- | ----------- |
|
||||
| `offers` | array | List of offers |
|
||||
| ↳ `id` | string | Offer UUID |
|
||||
| ↳ `status` | string | Offer status |
|
||||
| ↳ `candidate` | object | Associated candidate |
|
||||
| ↳ `id` | string | Candidate UUID |
|
||||
| ↳ `name` | string | Candidate name |
|
||||
| ↳ `job` | object | Associated job |
|
||||
| ↳ `id` | string | Job UUID |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `offerStatus` | string | Offer status |
|
||||
| ↳ `acceptanceStatus` | string | Acceptance status |
|
||||
| ↳ `applicationId` | string | Associated application UUID |
|
||||
| ↳ `startDate` | string | Offer start date |
|
||||
| ↳ `salary` | object | Salary details |
|
||||
| ↳ `currencyCode` | string | ISO 4217 currency code |
|
||||
| ↳ `value` | number | Salary amount |
|
||||
| ↳ `openingId` | string | Associated opening UUID |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_openings`
|
||||
|
||||
Lists all openings in Ashby with pagination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `openings` | array | List of openings |
|
||||
| ↳ `id` | string | Opening UUID |
|
||||
| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) |
|
||||
| ↳ `isArchived` | boolean | Whether the opening is archived |
|
||||
| ↳ `openedAt` | string | ISO 8601 opened timestamp |
|
||||
| ↳ `closedAt` | string | ISO 8601 closed timestamp |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_list_sources`
|
||||
|
||||
Lists all candidate sources configured in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sources` | array | List of sources |
|
||||
| ↳ `id` | string | Source UUID |
|
||||
| ↳ `title` | string | Source title |
|
||||
| ↳ `isArchived` | boolean | Whether the source is archived |
|
||||
|
||||
### `ashby_list_users`
|
||||
|
||||
Lists all users in Ashby with pagination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
|
||||
| `perPage` | number | No | Number of results per page \(default 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of users |
|
||||
| ↳ `id` | string | User UUID |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `isEnabled` | boolean | Whether the user account is enabled |
|
||||
| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) |
|
||||
| `moreDataAvailable` | boolean | Whether more pages of results exist |
|
||||
| `nextCursor` | string | Opaque cursor for fetching the next page |
|
||||
|
||||
### `ashby_remove_candidate_tag`
|
||||
|
||||
Removes a tag from a candidate in Ashby.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Ashby API Key |
|
||||
| `candidateId` | string | Yes | The UUID of the candidate to remove the tag from |
|
||||
| `tagId` | string | Yes | The UUID of the tag to remove |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the tag was successfully removed |
|
||||
|
||||
### `ashby_search_candidates`
|
||||
|
||||
Searches for candidates by name and/or email with AND logic. Results are limited to 100 matches. Use candidate.list for full pagination.
|
||||
@@ -425,6 +733,8 @@ Searches for candidates by name and/or email with AND logic. Results are limited
|
||||
| ↳ `value` | string | Phone number |
|
||||
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
|
||||
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
|
||||
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
|
||||
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
|
||||
|
||||
### `ashby_update_candidate`
|
||||
|
||||
@@ -438,9 +748,7 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
|
||||
| `candidateId` | string | Yes | The UUID of the candidate to update |
|
||||
| `name` | string | No | Updated full name |
|
||||
| `email` | string | No | Updated primary email address |
|
||||
| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) |
|
||||
| `phoneNumber` | string | No | Updated primary phone number |
|
||||
| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) |
|
||||
| `linkedInUrl` | string | No | LinkedIn profile URL |
|
||||
| `githubUrl` | string | No | GitHub profile URL |
|
||||
| `websiteUrl` | string | No | Personal website URL |
|
||||
|
||||
255
apps/docs/content/docs/en/tools/infisical.mdx
Normal file
255
apps/docs/content/docs/en/tools/infisical.mdx
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: Infisical
|
||||
description: Manage secrets with Infisical
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="infisical"
|
||||
color="#F7FE62"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Infisical](https://infisical.com/) is an open-source secrets management platform that helps teams centralize and manage application secrets, environment variables, and sensitive configuration data across their infrastructure. This integration brings Infisical's secrets management capabilities directly into Sim workflows.
|
||||
|
||||
With Infisical in Sim, you can:
|
||||
|
||||
- **List secrets**: Retrieve all secrets from a project environment with filtering by path, tags, and recursive subdirectory support
|
||||
- **Get a secret**: Fetch a specific secret by name, with optional version pinning and secret reference expansion
|
||||
- **Create secrets**: Add new secrets to any project environment with support for comments, paths, and tag assignments
|
||||
- **Update secrets**: Modify existing secret values, comments, names, and tags
|
||||
- **Delete secrets**: Remove secrets from a project environment
|
||||
|
||||
In Sim, the Infisical integration enables your agents to programmatically manage secrets as part of automated workflows — for example, rotating credentials, syncing environment variables across environments, or auditing secret usage. Simply configure the Infisical block with your API key, select the operation, and provide the project ID and environment slug to get started.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Infisical into your workflow. List, get, create, update, and delete secrets across project environments.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `infisical_list_secrets`
|
||||
|
||||
List all secrets in a project environment. Returns secret keys, values, comments, tags, and metadata.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Infisical API token |
|
||||
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
|
||||
| `projectId` | string | Yes | The ID of the project to list secrets from |
|
||||
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
|
||||
| `secretPath` | string | No | The path of the secrets \(default: "/"\) |
|
||||
| `recursive` | boolean | No | Whether to fetch secrets recursively from subdirectories |
|
||||
| `expandSecretReferences` | boolean | No | Whether to expand secret references \(default: true\) |
|
||||
| `viewSecretValue` | boolean | No | Whether to include secret values in the response \(default: true\) |
|
||||
| `includeImports` | boolean | No | Whether to include imported secrets \(default: true\) |
|
||||
| `tagSlugs` | string | No | Comma-separated tag slugs to filter secrets by |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secrets` | array | Array of secrets |
|
||||
| ↳ `id` | string | Secret ID |
|
||||
| ↳ `workspace` | string | Workspace/project ID |
|
||||
| ↳ `secretKey` | string | Secret name/key |
|
||||
| ↳ `secretValue` | string | Secret value |
|
||||
| ↳ `secretComment` | string | Secret comment |
|
||||
| ↳ `secretPath` | string | Secret path |
|
||||
| ↳ `version` | number | Secret version |
|
||||
| ↳ `type` | string | Secret type \(shared or personal\) |
|
||||
| ↳ `environment` | string | Environment slug |
|
||||
| ↳ `tags` | array | Tags attached to the secret |
|
||||
| ↳ `id` | string | Tag ID |
|
||||
| ↳ `slug` | string | Tag slug |
|
||||
| ↳ `color` | string | Tag color |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
|
||||
| ↳ `key` | string | Metadata key |
|
||||
| ↳ `value` | string | Metadata value |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
| `count` | number | Total number of secrets returned |
|
||||
|
||||
### `infisical_get_secret`
|
||||
|
||||
Retrieve a single secret by name from a project environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Infisical API token |
|
||||
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
|
||||
| `projectId` | string | Yes | The ID of the project |
|
||||
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
|
||||
| `secretName` | string | Yes | The name of the secret to retrieve |
|
||||
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
|
||||
| `version` | number | No | Specific version of the secret to retrieve |
|
||||
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
|
||||
| `viewSecretValue` | boolean | No | Whether to include the secret value in the response \(default: true\) |
|
||||
| `expandSecretReferences` | boolean | No | Whether to expand secret references \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secret` | object | The retrieved secret |
|
||||
| ↳ `id` | string | Secret ID |
|
||||
| ↳ `workspace` | string | Workspace/project ID |
|
||||
| ↳ `secretKey` | string | Secret name/key |
|
||||
| ↳ `secretValue` | string | Secret value |
|
||||
| ↳ `secretComment` | string | Secret comment |
|
||||
| ↳ `secretPath` | string | Secret path |
|
||||
| ↳ `version` | number | Secret version |
|
||||
| ↳ `type` | string | Secret type \(shared or personal\) |
|
||||
| ↳ `environment` | string | Environment slug |
|
||||
| ↳ `tags` | array | Tags attached to the secret |
|
||||
| ↳ `id` | string | Tag ID |
|
||||
| ↳ `slug` | string | Tag slug |
|
||||
| ↳ `color` | string | Tag color |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
|
||||
| ↳ `key` | string | Metadata key |
|
||||
| ↳ `value` | string | Metadata value |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
|
||||
### `infisical_create_secret`
|
||||
|
||||
Create a new secret in a project environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Infisical API token |
|
||||
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
|
||||
| `projectId` | string | Yes | The ID of the project |
|
||||
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
|
||||
| `secretName` | string | Yes | The name of the secret to create |
|
||||
| `secretValue` | string | Yes | The value of the secret |
|
||||
| `secretPath` | string | No | The path for the secret \(default: "/"\) |
|
||||
| `secretComment` | string | No | A comment for the secret |
|
||||
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
|
||||
| `tagIds` | string | No | Comma-separated tag IDs to attach to the secret |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secret` | object | The created secret |
|
||||
| ↳ `id` | string | Secret ID |
|
||||
| ↳ `workspace` | string | Workspace/project ID |
|
||||
| ↳ `secretKey` | string | Secret name/key |
|
||||
| ↳ `secretValue` | string | Secret value |
|
||||
| ↳ `secretComment` | string | Secret comment |
|
||||
| ↳ `secretPath` | string | Secret path |
|
||||
| ↳ `version` | number | Secret version |
|
||||
| ↳ `type` | string | Secret type \(shared or personal\) |
|
||||
| ↳ `environment` | string | Environment slug |
|
||||
| ↳ `tags` | array | Tags attached to the secret |
|
||||
| ↳ `id` | string | Tag ID |
|
||||
| ↳ `slug` | string | Tag slug |
|
||||
| ↳ `color` | string | Tag color |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
|
||||
| ↳ `key` | string | Metadata key |
|
||||
| ↳ `value` | string | Metadata value |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
|
||||
### `infisical_update_secret`
|
||||
|
||||
Update an existing secret in a project environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Infisical API token |
|
||||
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
|
||||
| `projectId` | string | Yes | The ID of the project |
|
||||
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
|
||||
| `secretName` | string | Yes | The name of the secret to update |
|
||||
| `secretValue` | string | No | The new value for the secret |
|
||||
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
|
||||
| `secretComment` | string | No | A comment for the secret |
|
||||
| `newSecretName` | string | No | New name for the secret \(to rename it\) |
|
||||
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
|
||||
| `tagIds` | string | No | Comma-separated tag IDs to set on the secret |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secret` | object | The updated secret |
|
||||
| ↳ `id` | string | Secret ID |
|
||||
| ↳ `workspace` | string | Workspace/project ID |
|
||||
| ↳ `secretKey` | string | Secret name/key |
|
||||
| ↳ `secretValue` | string | Secret value |
|
||||
| ↳ `secretComment` | string | Secret comment |
|
||||
| ↳ `secretPath` | string | Secret path |
|
||||
| ↳ `version` | number | Secret version |
|
||||
| ↳ `type` | string | Secret type \(shared or personal\) |
|
||||
| ↳ `environment` | string | Environment slug |
|
||||
| ↳ `tags` | array | Tags attached to the secret |
|
||||
| ↳ `id` | string | Tag ID |
|
||||
| ↳ `slug` | string | Tag slug |
|
||||
| ↳ `color` | string | Tag color |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
|
||||
| ↳ `key` | string | Metadata key |
|
||||
| ↳ `value` | string | Metadata value |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
|
||||
### `infisical_delete_secret`
|
||||
|
||||
Delete a secret from a project environment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Infisical API token |
|
||||
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
|
||||
| `projectId` | string | Yes | The ID of the project |
|
||||
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
|
||||
| `secretName` | string | Yes | The name of the secret to delete |
|
||||
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
|
||||
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `secret` | object | The deleted secret |
|
||||
| ↳ `id` | string | Secret ID |
|
||||
| ↳ `workspace` | string | Workspace/project ID |
|
||||
| ↳ `secretKey` | string | Secret name/key |
|
||||
| ↳ `secretValue` | string | Secret value |
|
||||
| ↳ `secretComment` | string | Secret comment |
|
||||
| ↳ `secretPath` | string | Secret path |
|
||||
| ↳ `version` | number | Secret version |
|
||||
| ↳ `type` | string | Secret type \(shared or personal\) |
|
||||
| ↳ `environment` | string | Environment slug |
|
||||
| ↳ `tags` | array | Tags attached to the secret |
|
||||
| ↳ `id` | string | Tag ID |
|
||||
| ↳ `slug` | string | Tag slug |
|
||||
| ↳ `color` | string | Tag color |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
|
||||
| ↳ `key` | string | Metadata key |
|
||||
| ↳ `value` | string | Metadata value |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `updatedAt` | string | Last update timestamp |
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"image_generator",
|
||||
"imap",
|
||||
"incidentio",
|
||||
"infisical",
|
||||
"intercom",
|
||||
"jina",
|
||||
"jira",
|
||||
@@ -94,6 +95,7 @@
|
||||
"mailgun",
|
||||
"mem0",
|
||||
"memory",
|
||||
"microsoft_ad",
|
||||
"microsoft_dataverse",
|
||||
"microsoft_excel",
|
||||
"microsoft_planner",
|
||||
@@ -104,6 +106,7 @@
|
||||
"neo4j",
|
||||
"notion",
|
||||
"obsidian",
|
||||
"okta",
|
||||
"onedrive",
|
||||
"onepassword",
|
||||
"openai",
|
||||
@@ -163,6 +166,7 @@
|
||||
"whatsapp",
|
||||
"wikipedia",
|
||||
"wordpress",
|
||||
"workday",
|
||||
"x",
|
||||
"youtube",
|
||||
"zendesk",
|
||||
|
||||
336
apps/docs/content/docs/en/tools/microsoft_ad.mdx
Normal file
336
apps/docs/content/docs/en/tools/microsoft_ad.mdx
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
title: Azure AD
|
||||
description: Manage users and groups in Azure AD (Microsoft Entra ID)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="microsoft_ad"
|
||||
color="#0078D4"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Azure Active Directory](https://entra.microsoft.com) (now Microsoft Entra ID) is Microsoft's cloud-based identity and access management service. It helps organizations manage users, groups, and access to applications and resources across cloud and on-premises environments.
|
||||
|
||||
With the Azure AD integration in Sim, you can:
|
||||
|
||||
- **Manage users**: List, create, update, and delete user accounts in your directory
|
||||
- **Manage groups**: Create and configure security groups and Microsoft 365 groups
|
||||
- **Control group membership**: Add and remove members from groups programmatically
|
||||
- **Query directory data**: Search and filter users and groups using OData expressions
|
||||
- **Automate onboarding/offboarding**: Create new user accounts with initial passwords and enable/disable accounts as part of HR workflows
|
||||
|
||||
In Sim, the Azure AD integration enables your agents to programmatically manage your organization's identity infrastructure. This allows for automation scenarios such as provisioning new employees, updating user profiles in bulk, managing team group memberships, and auditing directory data. By connecting Sim with Azure AD, you can streamline identity lifecycle management and ensure your directory stays in sync with your organization's needs.
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues with the Azure AD integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Azure Active Directory into your workflows. List, create, update, and delete users and groups. Manage group memberships programmatically.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `microsoft_ad_list_users`
|
||||
|
||||
List users in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `top` | number | No | Maximum number of users to return \(default 100, max 999\) |
|
||||
| `filter` | string | No | OData filter expression \(e.g., "department eq \'Sales\'"\) |
|
||||
| `search` | string | No | Search string to filter users by displayName or mail |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | List of users |
|
||||
| `userCount` | number | Number of users returned |
|
||||
|
||||
### `microsoft_ad_get_user`
|
||||
|
||||
Get a user by ID or user principal name from Azure AD
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `userId` | string | Yes | User ID or user principal name \(e.g., "user@example.com"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `user` | object | User details |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `givenName` | string | First name |
|
||||
| ↳ `surname` | string | Last name |
|
||||
| ↳ `userPrincipalName` | string | User principal name \(email\) |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `jobTitle` | string | Job title |
|
||||
| ↳ `department` | string | Department |
|
||||
| ↳ `officeLocation` | string | Office location |
|
||||
| ↳ `mobilePhone` | string | Mobile phone number |
|
||||
| ↳ `accountEnabled` | boolean | Whether the account is enabled |
|
||||
|
||||
### `microsoft_ad_create_user`
|
||||
|
||||
Create a new user in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `displayName` | string | Yes | Display name for the user |
|
||||
| `mailNickname` | string | Yes | Mail alias for the user |
|
||||
| `userPrincipalName` | string | Yes | User principal name \(e.g., "user@example.com"\) |
|
||||
| `password` | string | Yes | Initial password for the user |
|
||||
| `accountEnabled` | boolean | Yes | Whether the account is enabled |
|
||||
| `givenName` | string | No | First name |
|
||||
| `surname` | string | No | Last name |
|
||||
| `jobTitle` | string | No | Job title |
|
||||
| `department` | string | No | Department |
|
||||
| `officeLocation` | string | No | Office location |
|
||||
| `mobilePhone` | string | No | Mobile phone number |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `user` | object | Created user details |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `givenName` | string | First name |
|
||||
| ↳ `surname` | string | Last name |
|
||||
| ↳ `userPrincipalName` | string | User principal name \(email\) |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `jobTitle` | string | Job title |
|
||||
| ↳ `department` | string | Department |
|
||||
| ↳ `officeLocation` | string | Office location |
|
||||
| ↳ `mobilePhone` | string | Mobile phone number |
|
||||
| ↳ `accountEnabled` | boolean | Whether the account is enabled |
|
||||
|
||||
### `microsoft_ad_update_user`
|
||||
|
||||
Update user properties in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `userId` | string | Yes | User ID or user principal name |
|
||||
| `displayName` | string | No | Display name |
|
||||
| `givenName` | string | No | First name |
|
||||
| `surname` | string | No | Last name |
|
||||
| `jobTitle` | string | No | Job title |
|
||||
| `department` | string | No | Department |
|
||||
| `officeLocation` | string | No | Office location |
|
||||
| `mobilePhone` | string | No | Mobile phone number |
|
||||
| `accountEnabled` | boolean | No | Whether the account is enabled |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated` | boolean | Whether the update was successful |
|
||||
| `userId` | string | ID of the updated user |
|
||||
|
||||
### `microsoft_ad_delete_user`
|
||||
|
||||
Delete a user from Azure AD (Microsoft Entra ID). The user is moved to a temporary container and can be restored within 30 days.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `userId` | string | Yes | User ID or user principal name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the deletion was successful |
|
||||
| `userId` | string | ID of the deleted user |
|
||||
|
||||
### `microsoft_ad_list_groups`
|
||||
|
||||
List groups in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `top` | number | No | Maximum number of groups to return \(default 100, max 999\) |
|
||||
| `filter` | string | No | OData filter expression \(e.g., "securityEnabled eq true"\) |
|
||||
| `search` | string | No | Search string to filter groups by displayName or description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groups` | array | List of groups |
|
||||
| `groupCount` | number | Number of groups returned |
|
||||
|
||||
### `microsoft_ad_get_group`
|
||||
|
||||
Get a group by ID from Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `group` | object | Group details |
|
||||
| ↳ `id` | string | Group ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `description` | string | Group description |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `mailEnabled` | boolean | Whether mail is enabled |
|
||||
| ↳ `mailNickname` | string | Mail nickname |
|
||||
| ↳ `securityEnabled` | boolean | Whether security is enabled |
|
||||
| ↳ `groupTypes` | array | Group types |
|
||||
| ↳ `visibility` | string | Group visibility |
|
||||
| ↳ `createdDateTime` | string | Creation date |
|
||||
|
||||
### `microsoft_ad_create_group`
|
||||
|
||||
Create a new group in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `displayName` | string | Yes | Display name for the group |
|
||||
| `mailNickname` | string | Yes | Mail alias for the group \(ASCII only, max 64 characters\) |
|
||||
| `description` | string | No | Group description |
|
||||
| `mailEnabled` | boolean | Yes | Whether mail is enabled \(true for Microsoft 365 groups\) |
|
||||
| `securityEnabled` | boolean | Yes | Whether security is enabled \(true for security groups\) |
|
||||
| `groupTypes` | string | No | Group type: "Unified" for Microsoft 365 group, leave empty for security group |
|
||||
| `visibility` | string | No | Group visibility: "Private" or "Public" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `group` | object | Created group details |
|
||||
| ↳ `id` | string | Group ID |
|
||||
| ↳ `displayName` | string | Display name |
|
||||
| ↳ `description` | string | Group description |
|
||||
| ↳ `mail` | string | Email address |
|
||||
| ↳ `mailEnabled` | boolean | Whether mail is enabled |
|
||||
| ↳ `mailNickname` | string | Mail nickname |
|
||||
| ↳ `securityEnabled` | boolean | Whether security is enabled |
|
||||
| ↳ `groupTypes` | array | Group types |
|
||||
| ↳ `visibility` | string | Group visibility |
|
||||
| ↳ `createdDateTime` | string | Creation date |
|
||||
|
||||
### `microsoft_ad_update_group`
|
||||
|
||||
Update group properties in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
| `displayName` | string | No | Display name |
|
||||
| `description` | string | No | Group description |
|
||||
| `mailNickname` | string | No | Mail alias |
|
||||
| `visibility` | string | No | Group visibility: "Private" or "Public" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated` | boolean | Whether the update was successful |
|
||||
| `groupId` | string | ID of the updated group |
|
||||
|
||||
### `microsoft_ad_delete_group`
|
||||
|
||||
Delete a group from Azure AD (Microsoft Entra ID). Microsoft 365 and security groups can be restored within 30 days.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the deletion was successful |
|
||||
| `groupId` | string | ID of the deleted group |
|
||||
|
||||
### `microsoft_ad_list_group_members`
|
||||
|
||||
List members of a group in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
| `top` | number | No | Maximum number of members to return \(default 100, max 999\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | List of group members |
|
||||
| `memberCount` | number | Number of members returned |
|
||||
|
||||
### `microsoft_ad_add_group_member`
|
||||
|
||||
Add a member to a group in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
| `memberId` | string | Yes | User ID of the member to add |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `added` | boolean | Whether the member was added successfully |
|
||||
| `groupId` | string | Group ID |
|
||||
| `memberId` | string | Member ID that was added |
|
||||
|
||||
### `microsoft_ad_remove_group_member`
|
||||
|
||||
Remove a member from a group in Azure AD (Microsoft Entra ID)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupId` | string | Yes | Group ID |
|
||||
| `memberId` | string | Yes | User ID of the member to remove |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `removed` | boolean | Whether the member was removed successfully |
|
||||
| `groupId` | string | Group ID |
|
||||
| `memberId` | string | Member ID that was removed |
|
||||
|
||||
|
||||
516
apps/docs/content/docs/en/tools/okta.mdx
Normal file
516
apps/docs/content/docs/en/tools/okta.mdx
Normal file
@@ -0,0 +1,516 @@
|
||||
---
|
||||
title: Okta
|
||||
description: Manage users and groups in Okta
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="okta"
|
||||
color="#191919"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Okta](https://www.okta.com/) is an identity and access management platform that provides secure authentication, authorization, and user management for organizations.
|
||||
|
||||
With the Okta integration in Sim, you can:
|
||||
|
||||
- **List and search users**: Retrieve users from your Okta org with SCIM search expressions and filters
|
||||
- **Manage user lifecycle**: Create, activate, deactivate, suspend, unsuspend, and delete users
|
||||
- **Update user profiles**: Modify user attributes like name, email, phone, title, and department
|
||||
- **Reset passwords**: Trigger password reset flows with optional email notification
|
||||
- **Manage groups**: Create, update, delete, and list groups in your organization
|
||||
- **Manage group membership**: Add or remove users from groups, and list group members
|
||||
|
||||
In Sim, the Okta integration enables your agents to automate identity management tasks as part of their workflows. This allows for scenarios such as onboarding new employees, offboarding departing users, managing group-based access, auditing user status, and responding to security events by suspending or deactivating accounts.
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues with the Okta integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `okta_list_users`
|
||||
|
||||
List all users in your Okta organization with optional search and filtering
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `search` | string | No | Okta search expression \(e.g., profile.firstName eq "John" or profile.email co "example.com"\) |
|
||||
| `filter` | string | No | Okta filter expression \(e.g., status eq "ACTIVE"\) |
|
||||
| `limit` | number | No | Maximum number of users to return \(default: 200, max: 200\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `users` | array | Array of Okta user objects |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `status` | string | User status \(ACTIVE, STAGED, PROVISIONED, etc.\) |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `login` | string | Login \(usually email\) |
|
||||
| ↳ `mobilePhone` | string | Mobile phone |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `department` | string | Department |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| ↳ `lastLogin` | string | Last login timestamp |
|
||||
| ↳ `lastUpdated` | string | Last update timestamp |
|
||||
| ↳ `activated` | string | Activation timestamp |
|
||||
| ↳ `statusChanged` | string | Status change timestamp |
|
||||
| `count` | number | Number of users returned |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_get_user`
|
||||
|
||||
Get a specific user by ID or login from your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login \(email\) to look up |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `status` | string | User status |
|
||||
| `firstName` | string | First name |
|
||||
| `lastName` | string | Last name |
|
||||
| `email` | string | Email address |
|
||||
| `login` | string | Login \(usually email\) |
|
||||
| `mobilePhone` | string | Mobile phone |
|
||||
| `secondEmail` | string | Secondary email |
|
||||
| `displayName` | string | Display name |
|
||||
| `title` | string | Job title |
|
||||
| `department` | string | Department |
|
||||
| `organization` | string | Organization |
|
||||
| `manager` | string | Manager name |
|
||||
| `managerId` | string | Manager ID |
|
||||
| `division` | string | Division |
|
||||
| `employeeNumber` | string | Employee number |
|
||||
| `userType` | string | User type |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `activated` | string | Activation timestamp |
|
||||
| `lastLogin` | string | Last login timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `statusChanged` | string | Status change timestamp |
|
||||
| `passwordChanged` | string | Password change timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_create_user`
|
||||
|
||||
Create a new user in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `firstName` | string | Yes | First name of the user |
|
||||
| `lastName` | string | Yes | Last name of the user |
|
||||
| `email` | string | Yes | Email address of the user |
|
||||
| `login` | string | No | Login for the user \(defaults to email if not provided\) |
|
||||
| `password` | string | No | Password for the user \(if not set, user will be emailed to set password\) |
|
||||
| `mobilePhone` | string | No | Mobile phone number |
|
||||
| `title` | string | No | Job title |
|
||||
| `department` | string | No | Department |
|
||||
| `activate` | boolean | No | Whether to activate the user immediately \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Created user ID |
|
||||
| `status` | string | User status |
|
||||
| `firstName` | string | First name |
|
||||
| `lastName` | string | Last name |
|
||||
| `email` | string | Email address |
|
||||
| `login` | string | Login |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_update_user`
|
||||
|
||||
Update a user profile in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to update |
|
||||
| `firstName` | string | No | Updated first name |
|
||||
| `lastName` | string | No | Updated last name |
|
||||
| `email` | string | No | Updated email address |
|
||||
| `login` | string | No | Updated login |
|
||||
| `mobilePhone` | string | No | Updated mobile phone number |
|
||||
| `title` | string | No | Updated job title |
|
||||
| `department` | string | No | Updated department |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `status` | string | User status |
|
||||
| `firstName` | string | First name |
|
||||
| `lastName` | string | Last name |
|
||||
| `email` | string | Email address |
|
||||
| `login` | string | Login |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_activate_user`
|
||||
|
||||
Activate a user in your Okta organization. Can only be performed on users with STAGED or DEPROVISIONED status. Optionally sends an activation email.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to activate |
|
||||
| `sendEmail` | boolean | No | Send activation email to the user \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Activated user ID |
|
||||
| `activated` | boolean | Whether the user was activated |
|
||||
| `activationUrl` | string | Activation URL \(only returned when sendEmail is false\) |
|
||||
| `activationToken` | string | Activation token \(only returned when sendEmail is false\) |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_deactivate_user`
|
||||
|
||||
Deactivate a user in your Okta organization. This transitions the user to DEPROVISIONED status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to deactivate |
|
||||
| `sendEmail` | boolean | No | Send deactivation email to admin \(default: false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Deactivated user ID |
|
||||
| `deactivated` | boolean | Whether the user was deactivated |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_suspend_user`
|
||||
|
||||
Suspend a user in your Okta organization. Only users with ACTIVE status can be suspended. Suspended users cannot log in but retain group and app assignments.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to suspend |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Suspended user ID |
|
||||
| `suspended` | boolean | Whether the user was suspended |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_unsuspend_user`
|
||||
|
||||
Unsuspend a previously suspended user in your Okta organization. Returns the user to ACTIVE status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to unsuspend |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Unsuspended user ID |
|
||||
| `unsuspended` | boolean | Whether the user was unsuspended |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_reset_password`
|
||||
|
||||
Generate a one-time token to reset a user password. Can email the reset link to the user or return it directly. Transitions the user to RECOVERY status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID or login to reset password for |
|
||||
| `sendEmail` | boolean | No | Send password reset email to the user \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | User ID |
|
||||
| `resetPasswordUrl` | string | Password reset URL \(only returned when sendEmail is false\) |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_delete_user`
|
||||
|
||||
Permanently delete a user from your Okta organization. Can only be performed on DEPROVISIONED users. If the user is active, this will first deactivate them and a second call is needed to delete.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `userId` | string | Yes | User ID to delete |
|
||||
| `sendEmail` | boolean | No | Send deactivation email to admin \(default: false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `userId` | string | Deleted user ID |
|
||||
| `deleted` | boolean | Whether the user was deleted |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_list_groups`
|
||||
|
||||
List all groups in your Okta organization with optional search and filtering
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `search` | string | No | Okta search expression for groups \(e.g., profile.name sw "Engineering" or type eq "OKTA_GROUP"\) |
|
||||
| `filter` | string | No | Okta filter expression \(e.g., type eq "OKTA_GROUP"\) |
|
||||
| `limit` | number | No | Maximum number of groups to return \(default: 10000, max: 10000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groups` | array | Array of Okta group objects |
|
||||
| ↳ `id` | string | Group ID |
|
||||
| ↳ `name` | string | Group name |
|
||||
| ↳ `description` | string | Group description |
|
||||
| ↳ `type` | string | Group type \(OKTA_GROUP, APP_GROUP, BUILT_IN\) |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| ↳ `lastUpdated` | string | Last update timestamp |
|
||||
| ↳ `lastMembershipUpdated` | string | Last membership change timestamp |
|
||||
| `count` | number | Number of groups returned |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_get_group`
|
||||
|
||||
Get a specific group by ID from your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to look up |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Group ID |
|
||||
| `name` | string | Group name |
|
||||
| `description` | string | Group description |
|
||||
| `type` | string | Group type |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `lastMembershipUpdated` | string | Last membership change timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_create_group`
|
||||
|
||||
Create a new group in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `name` | string | Yes | Name of the group |
|
||||
| `description` | string | No | Description of the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Created group ID |
|
||||
| `name` | string | Group name |
|
||||
| `description` | string | Group description |
|
||||
| `type` | string | Group type |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `lastMembershipUpdated` | string | Last membership change timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_update_group`
|
||||
|
||||
Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement).
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to update |
|
||||
| `name` | string | Yes | Updated group name |
|
||||
| `description` | string | No | Updated group description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Group ID |
|
||||
| `name` | string | Group name |
|
||||
| `description` | string | Group description |
|
||||
| `type` | string | Group type |
|
||||
| `created` | string | Creation timestamp |
|
||||
| `lastUpdated` | string | Last update timestamp |
|
||||
| `lastMembershipUpdated` | string | Last membership change timestamp |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_delete_group`
|
||||
|
||||
Delete a group from your Okta organization. Groups of OKTA_GROUP or APP_GROUP type can be removed.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groupId` | string | Deleted group ID |
|
||||
| `deleted` | boolean | Whether the group was deleted |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_add_user_to_group`
|
||||
|
||||
Add a user to a group in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to add the user to |
|
||||
| `userId` | string | Yes | User ID to add to the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groupId` | string | Group ID |
|
||||
| `userId` | string | User ID added to the group |
|
||||
| `added` | boolean | Whether the user was added |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_remove_user_from_group`
|
||||
|
||||
Remove a user from a group in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to remove the user from |
|
||||
| `userId` | string | Yes | User ID to remove from the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `groupId` | string | Group ID |
|
||||
| `userId` | string | User ID removed from the group |
|
||||
| `removed` | boolean | Whether the user was removed |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
### `okta_list_group_members`
|
||||
|
||||
List all members of a specific group in your Okta organization
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Okta API token for authentication |
|
||||
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
|
||||
| `groupId` | string | Yes | Group ID to list members for |
|
||||
| `limit` | number | No | Maximum number of members to return \(default: 1000, max: 1000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | Array of group member user objects |
|
||||
| ↳ `id` | string | User ID |
|
||||
| ↳ `status` | string | User status |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `login` | string | Login |
|
||||
| ↳ `mobilePhone` | string | Mobile phone |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `department` | string | Department |
|
||||
| ↳ `created` | string | Creation timestamp |
|
||||
| ↳ `lastLogin` | string | Last login timestamp |
|
||||
| ↳ `lastUpdated` | string | Last update timestamp |
|
||||
| ↳ `activated` | string | Activation timestamp |
|
||||
| ↳ `statusChanged` | string | Status change timestamp |
|
||||
| `count` | number | Number of members returned |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
|
||||
262
apps/docs/content/docs/en/tools/workday.mdx
Normal file
262
apps/docs/content/docs/en/tools/workday.mdx
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: Workday
|
||||
description: Manage workers, hiring, onboarding, and HR operations in Workday
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="workday"
|
||||
color="#F5F0EB"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Workday HRIS into your workflow. Create pre-hires, hire employees, manage worker profiles, assign onboarding plans, handle job changes, retrieve compensation data, and process terminations.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `workday_get_worker`
|
||||
|
||||
Retrieve a specific worker profile including personal, employment, and organization data.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID to retrieve \(e.g., 3aa5550b7fe348b98d7b5741afc65534\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `worker` | json | Worker profile with personal, employment, and organization data |
|
||||
|
||||
### `workday_list_workers`
|
||||
|
||||
List or search workers with optional filtering and pagination.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `limit` | number | No | Maximum number of workers to return \(default: 20\) |
|
||||
| `offset` | number | No | Number of records to skip for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `workers` | array | Array of worker profiles |
|
||||
| `total` | number | Total number of matching workers |
|
||||
|
||||
### `workday_create_prehire`
|
||||
|
||||
Create a new pre-hire (applicant) record in Workday. This is typically the first step before hiring an employee.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `legalName` | string | Yes | Full legal name of the pre-hire \(e.g., "Jane Doe"\) |
|
||||
| `email` | string | No | Email address of the pre-hire |
|
||||
| `phoneNumber` | string | No | Phone number of the pre-hire |
|
||||
| `address` | string | No | Address of the pre-hire |
|
||||
| `countryCode` | string | No | ISO 3166-1 Alpha-2 country code \(defaults to US\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `preHireId` | string | ID of the created pre-hire record |
|
||||
| `descriptor` | string | Display name of the pre-hire |
|
||||
|
||||
### `workday_hire_employee`
|
||||
|
||||
Hire a pre-hire into an employee position. Converts an applicant into an active employee record with position, start date, and manager assignment.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `preHireId` | string | Yes | Pre-hire \(applicant\) ID to convert into an employee |
|
||||
| `positionId` | string | Yes | Position ID to assign the new hire to |
|
||||
| `hireDate` | string | Yes | Hire date in ISO 8601 format \(e.g., 2025-06-01\) |
|
||||
| `employeeType` | string | No | Employee type \(e.g., Regular, Temporary, Contractor\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `workerId` | string | Worker ID of the newly hired employee |
|
||||
| `employeeId` | string | Employee ID assigned to the new hire |
|
||||
| `eventId` | string | Event ID of the hire business process |
|
||||
| `hireDate` | string | Effective hire date |
|
||||
|
||||
### `workday_update_worker`
|
||||
|
||||
Update fields on an existing worker record in Workday.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID to update |
|
||||
| `fields` | json | Yes | Fields to update as JSON \(e.g., \{"businessTitle": "Senior Engineer", "primaryWorkEmail": "new@company.com"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | Event ID of the change personal information business process |
|
||||
| `workerId` | string | Worker ID that was updated |
|
||||
|
||||
### `workday_assign_onboarding`
|
||||
|
||||
Create or update an onboarding plan assignment for a worker. Sets up onboarding stages and manages the assignment lifecycle.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID to assign the onboarding plan to |
|
||||
| `onboardingPlanId` | string | Yes | Onboarding plan ID to assign |
|
||||
| `actionEventId` | string | Yes | Action event ID that enables the onboarding plan \(e.g., the hiring event ID\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `assignmentId` | string | Onboarding plan assignment ID |
|
||||
| `workerId` | string | Worker ID the plan was assigned to |
|
||||
| `planId` | string | Onboarding plan ID that was assigned |
|
||||
|
||||
### `workday_get_organizations`
|
||||
|
||||
Retrieve organizations, departments, and cost centers from Workday.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `type` | string | No | Organization type filter \(e.g., Supervisory, Cost_Center, Company, Region\) |
|
||||
| `limit` | number | No | Maximum number of organizations to return \(default: 20\) |
|
||||
| `offset` | number | No | Number of records to skip for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | array | Array of organization records |
|
||||
| `total` | number | Total number of matching organizations |
|
||||
|
||||
### `workday_change_job`
|
||||
|
||||
Perform a job change for a worker including transfers, promotions, demotions, and lateral moves.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID for the job change |
|
||||
| `effectiveDate` | string | Yes | Effective date for the job change in ISO 8601 format \(e.g., 2025-06-01\) |
|
||||
| `newPositionId` | string | No | New position ID \(for transfers\) |
|
||||
| `newJobProfileId` | string | No | New job profile ID \(for role changes\) |
|
||||
| `newLocationId` | string | No | New work location ID \(for relocations\) |
|
||||
| `newSupervisoryOrgId` | string | No | Target supervisory organization ID \(for org transfers\) |
|
||||
| `reason` | string | Yes | Reason for the job change \(e.g., Promotion, Transfer, Reorganization\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | Job change event ID |
|
||||
| `workerId` | string | Worker ID the job change was applied to |
|
||||
| `effectiveDate` | string | Effective date of the job change |
|
||||
|
||||
### `workday_get_compensation`
|
||||
|
||||
Retrieve compensation plan details for a specific worker.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID to retrieve compensation data for |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `compensationPlans` | array | Array of compensation plan details |
|
||||
| ↳ `id` | string | Compensation plan ID |
|
||||
| ↳ `planName` | string | Name of the compensation plan |
|
||||
| ↳ `amount` | number | Compensation amount |
|
||||
| ↳ `currency` | string | Currency code |
|
||||
| ↳ `frequency` | string | Pay frequency |
|
||||
|
||||
### `workday_terminate_worker`
|
||||
|
||||
Initiate a worker termination in Workday. Triggers the Terminate Employee business process.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
|
||||
| `tenant` | string | Yes | Workday tenant name |
|
||||
| `username` | string | Yes | Integration System User username |
|
||||
| `password` | string | Yes | Integration System User password |
|
||||
| `workerId` | string | Yes | Worker ID to terminate |
|
||||
| `terminationDate` | string | Yes | Termination date in ISO 8601 format \(e.g., 2025-06-01\) |
|
||||
| `reason` | string | Yes | Termination reason \(e.g., Resignation, End_of_Contract, Retirement\) |
|
||||
| `notificationDate` | string | No | Date the termination was communicated in ISO 8601 format |
|
||||
| `lastDayOfWork` | string | No | Last day of work in ISO 8601 format \(defaults to termination date\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | Termination event ID |
|
||||
| `workerId` | string | Worker ID that was terminated |
|
||||
| `terminationDate` | string | Effective termination date |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -53,24 +54,6 @@ const PASSWORD_VALIDATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
const validateCallbackUrl = (url: string): boolean => {
|
||||
try {
|
||||
if (url.startsWith('/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
if (url.startsWith(currentOrigin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error validating callback URL:', { error, url })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -99,15 +82,21 @@ export default function LoginPage({
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [_mounted, setMounted] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
|
||||
const invalidCallbackRef = useRef(false)
|
||||
if (callbackUrlParam && !isValidCallbackUrl && !invalidCallbackRef.current) {
|
||||
invalidCallbackRef.current = true
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
|
||||
}
|
||||
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
|
||||
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
|
||||
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
@@ -120,30 +109,11 @@ export default function LoginPage({
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
if (searchParams) {
|
||||
const callback = searchParams.get('callbackUrl')
|
||||
if (callback) {
|
||||
if (validateCallbackUrl(callback)) {
|
||||
setCallbackUrl(callback)
|
||||
} else {
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
|
||||
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
||||
if (resetSuccess) {
|
||||
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(() =>
|
||||
searchParams?.get('resetSuccess') === 'true'
|
||||
? 'Password reset successful. Please sign in with your new password.'
|
||||
: null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -205,7 +175,7 @@ export default function LoginPage({
|
||||
}
|
||||
|
||||
try {
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
const result = await client.signIn.email(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -22,14 +22,9 @@ function ResetPasswordContent() {
|
||||
text: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: 'Invalid or missing reset token. Please request a new password reset link.',
|
||||
})
|
||||
}
|
||||
}, [token])
|
||||
const tokenError = !token
|
||||
? 'Invalid or missing reset token. Please request a new password reset link.'
|
||||
: null
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
try {
|
||||
@@ -87,8 +82,8 @@ function ResetPasswordContent() {
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
statusType={tokenError ? 'error' : statusMessage.type}
|
||||
statusMessage={tokenError ?? statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -82,49 +82,32 @@ function SignupFormContent({
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
[searchParams]
|
||||
)
|
||||
const isInviteFlow = useMemo(
|
||||
() =>
|
||||
searchParams.get('invite_flow') === 'true' ||
|
||||
redirectUrl.startsWith('/invite/') ||
|
||||
redirectUrl.startsWith('/credential-account/'),
|
||||
[searchParams, redirectUrl]
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const emailParam = searchParams.get('email')
|
||||
if (emailParam) {
|
||||
setEmail(emailParam)
|
||||
}
|
||||
|
||||
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
|
||||
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
|
||||
if (
|
||||
redirectParam.startsWith('/invite/') ||
|
||||
redirectParam.startsWith('/credential-account/')
|
||||
) {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function Collaboration() {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href='/studio/multiplayer'
|
||||
href='/blog/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='relative mx-4 mb-6 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:mx-8 md:absolute md:bottom-10 md:left-[80px] md:z-20 md:mx-0 md:mb-0'
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AnimatePresence, motion, useInView } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { Lock } from '@/components/emcn/icons'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
/** Consistent color per actor — same pattern as Collaboration section cursors. */
|
||||
const ACTOR_COLORS: Record<string, string> = {
|
||||
@@ -32,26 +33,7 @@ const ACTOR_COLORS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** Left accent bar opacity by recency — newest is brightest. */
|
||||
const ACCENT_OPACITIES = [0.75, 0.45, 0.28, 0.15, 0.07] as const
|
||||
|
||||
/** Human-readable label per resource type. */
|
||||
const RESOURCE_TYPE_LABEL: Record<string, string> = {
|
||||
workflow: 'Workflow',
|
||||
member: 'Member',
|
||||
byok_key: 'BYOK Key',
|
||||
api_key: 'API Key',
|
||||
permission_group: 'Permission Group',
|
||||
credential_set: 'Credential Set',
|
||||
knowledge_base: 'Knowledge Base',
|
||||
environment: 'Environment',
|
||||
mcp_server: 'MCP Server',
|
||||
file: 'File',
|
||||
webhook: 'Webhook',
|
||||
chat: 'Chat',
|
||||
table: 'Table',
|
||||
folder: 'Folder',
|
||||
document: 'Document',
|
||||
}
|
||||
const ACCENT_OPACITIES = [0.75, 0.5, 0.35, 0.22, 0.12, 0.05] as const
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
@@ -150,7 +132,7 @@ const ENTRY_TEMPLATES: Omit<LogEntry, 'id' | 'insertedAt'>[] = [
|
||||
{ actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' },
|
||||
]
|
||||
|
||||
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 240_000, 540_000]
|
||||
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 180_000, 360_000, 600_000]
|
||||
|
||||
const MARQUEE_KEYFRAMES = `
|
||||
@keyframes marquee {
|
||||
@@ -188,10 +170,9 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6'
|
||||
const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04
|
||||
const timeAgo = formatTimeAgo(entry.insertedAt)
|
||||
const resourceLabel = RESOURCE_TYPE_LABEL[entry.resourceType]
|
||||
|
||||
return (
|
||||
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#191919] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#1C1C1C] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
|
||||
{/* Left accent bar — brightness encodes recency */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
@@ -216,7 +197,6 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
{/* Description — description hidden on mobile to avoid truncation */}
|
||||
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
|
||||
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
|
||||
<span className='hidden sm:inline'>
|
||||
@@ -224,13 +204,6 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Resource type label — formatted name, neutral so it doesn't compete with actor colors */}
|
||||
{resourceLabel && (
|
||||
<span className='ml-auto shrink-0 rounded border border-[#2A2A2A] px-[7px] py-[3px] font-[430] font-season text-[#F6F6F6]/25 text-[10px] leading-none tracking-[0.04em]'>
|
||||
{resourceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -238,11 +211,11 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
|
||||
function AuditLogPreview() {
|
||||
const counterRef = useRef(ENTRY_TEMPLATES.length)
|
||||
const templateIndexRef = useRef(5 % ENTRY_TEMPLATES.length)
|
||||
const templateIndexRef = useRef(6 % ENTRY_TEMPLATES.length)
|
||||
|
||||
const now = Date.now()
|
||||
const [entries, setEntries] = useState<LogEntry[]>(() =>
|
||||
ENTRY_TEMPLATES.slice(0, 5).map((t, i) => ({
|
||||
ENTRY_TEMPLATES.slice(0, 6).map((t, i) => ({
|
||||
...t,
|
||||
id: i,
|
||||
insertedAt: now - INITIAL_OFFSETS_MS[i],
|
||||
@@ -257,7 +230,7 @@ function AuditLogPreview() {
|
||||
|
||||
setEntries((prev) => [
|
||||
{ ...template, id: counterRef.current++, insertedAt: Date.now() },
|
||||
...prev.slice(0, 4),
|
||||
...prev.slice(0, 5),
|
||||
])
|
||||
}, 2600)
|
||||
|
||||
@@ -271,60 +244,212 @@ function AuditLogPreview() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mx-6 mt-6 overflow-hidden rounded-[8px] border border-[#2A2A2A] md:mx-8 md:mt-8'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between border-[#2A2A2A] border-b bg-[#161616] px-4 py-[10px]'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Pulsing live indicator */}
|
||||
<span className='relative flex h-[8px] w-[8px]'>
|
||||
<span
|
||||
className='absolute inline-flex h-full w-full animate-ping rounded-full opacity-50'
|
||||
style={{ backgroundColor: '#33C482' }}
|
||||
/>
|
||||
<span
|
||||
className='relative inline-flex h-[8px] w-[8px] rounded-full'
|
||||
style={{ backgroundColor: '#33C482' }}
|
||||
/>
|
||||
</span>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/40 text-[11px] uppercase tracking-[0.08em]'>
|
||||
Audit Log
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
|
||||
Export
|
||||
</span>
|
||||
<span className='rounded border border-[#2A2A2A] px-[8px] py-[3px] font-[430] font-season text-[#F6F6F6]/20 text-[11px] tracking-[0.02em]'>
|
||||
Filter
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-5 overflow-hidden px-6 md:mt-6 md:px-8'>
|
||||
<AnimatePresence mode='popLayout' initial={false}>
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
layout
|
||||
initial={{ y: -48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
},
|
||||
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
opacity: { duration: 0.25 },
|
||||
}}
|
||||
>
|
||||
<AuditRow entry={entry} index={index} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PermissionFeature {
|
||||
name: string
|
||||
key: string
|
||||
defaultEnabled: boolean
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
interface PermissionCategory {
|
||||
label: string
|
||||
color: string
|
||||
features: PermissionFeature[]
|
||||
}
|
||||
|
||||
const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
{
|
||||
label: 'Providers',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
{ key: 'openai', name: 'OpenAI', defaultEnabled: true, providerId: 'openai' },
|
||||
{ key: 'anthropic', name: 'Anthropic', defaultEnabled: true, providerId: 'anthropic' },
|
||||
{ key: 'google', name: 'Google', defaultEnabled: false, providerId: 'google' },
|
||||
{ key: 'xai', name: 'xAI', defaultEnabled: true, providerId: 'xai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Workspace',
|
||||
color: '#2ABBF8',
|
||||
features: [
|
||||
{ key: 'knowledge-base', name: 'Knowledge Base', defaultEnabled: true },
|
||||
{ key: 'tables', name: 'Tables', defaultEnabled: true },
|
||||
{ key: 'copilot', name: 'Copilot', defaultEnabled: false },
|
||||
{ key: 'environment', name: 'Environment', defaultEnabled: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
color: '#33C482',
|
||||
features: [
|
||||
{ key: 'mcp-tools', name: 'MCP Tools', defaultEnabled: true },
|
||||
{ key: 'custom-tools', name: 'Custom Tools', defaultEnabled: false },
|
||||
{ key: 'skills', name: 'Skills', defaultEnabled: true },
|
||||
{ key: 'invitations', name: 'Invitations', defaultEnabled: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const INITIAL_ACCESS_STATE = Object.fromEntries(
|
||||
PERMISSION_CATEGORIES.flatMap((category) =>
|
||||
category.features.map((feature) => [feature.key, feature.defaultEnabled])
|
||||
)
|
||||
)
|
||||
|
||||
function CheckboxIcon({ checked, color }: { checked: boolean; color: string }) {
|
||||
return (
|
||||
<div
|
||||
className='h-[6px] w-[6px] shrink-0 rounded-full transition-colors duration-200'
|
||||
style={{
|
||||
backgroundColor: checked ? color : 'transparent',
|
||||
border: checked ? 'none' : '1.5px solid #3A3A3A',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
|
||||
if (!providerId) return null
|
||||
|
||||
const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
|
||||
if (!ProviderIcon) return null
|
||||
|
||||
return (
|
||||
<div className='relative flex h-[14px] w-[14px] shrink-0 items-center justify-center opacity-50 brightness-0 invert'>
|
||||
<ProviderIcon className='!h-[14px] !w-[14px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessControlPanel() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-40px' })
|
||||
const [accessState, setAccessState] = useState<Record<string, boolean>>(INITIAL_ACCESS_STATE)
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className='lg:hidden'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => {
|
||||
const offsetBefore = PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
|
||||
duration: 0.3,
|
||||
}}
|
||||
onClick={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Log entries — new items push existing ones down */}
|
||||
<div className='overflow-hidden'>
|
||||
<AnimatePresence mode='popLayout' initial={false}>
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
layout
|
||||
initial={{ y: -48, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 380,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
},
|
||||
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
opacity: { duration: 0.25 },
|
||||
}}
|
||||
>
|
||||
<AuditRow entry={entry} index={index} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{/* Desktop — categorized grid */}
|
||||
<div className='hidden lg:block'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
const currentIndex =
|
||||
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
0
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.1 + currentIndex * 0.04,
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
onClick={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -420,7 +545,37 @@ export default function Enterprise() {
|
||||
</div>
|
||||
|
||||
<div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'>
|
||||
<AuditLogPreview />
|
||||
<div className='grid grid-cols-1 border-[#2A2A2A] border-b lg:grid-cols-[1fr_420px]'>
|
||||
{/* Audit Trail */}
|
||||
<div className='border-[#2A2A2A] lg:border-r'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Audit Trail
|
||||
</h3>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Every action is captured with full actor attribution.
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogPreview />
|
||||
<div className='h-6 md:h-8' />
|
||||
</div>
|
||||
|
||||
{/* Access Control */}
|
||||
<div className='border-[#2A2A2A] border-t lg:border-t-0'>
|
||||
<div className='px-6 pt-6 md:px-8 md:pt-8'>
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Access Control
|
||||
</h3>
|
||||
<p className='mt-[6px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Restrict providers, surfaces, and tools per group.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-5 px-6 pb-6 md:mt-6 md:px-8 md:pb-8'>
|
||||
<AccessControlPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrustStrip />
|
||||
|
||||
{/* Scrolling feature ticker */}
|
||||
|
||||
@@ -11,15 +11,19 @@ interface FooterItem {
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
|
||||
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
|
||||
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
|
||||
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
|
||||
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
]
|
||||
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
@@ -39,6 +43,7 @@ const BLOCK_LINKS: FooterItem[] = [
|
||||
]
|
||||
|
||||
const INTEGRATION_LINKS: FooterItem[] = [
|
||||
{ label: 'All Integrations →', href: '/integrations' },
|
||||
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
|
||||
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
|
||||
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const FEATURED_POST = {
|
||||
title: 'Build with Sim for Enterprise',
|
||||
slug: 'enterprise',
|
||||
image: '/blog/thumbnails/enterprise.webp',
|
||||
} as const
|
||||
|
||||
const POSTS = [
|
||||
{ title: 'Introducing Sim v0.5', slug: 'v0-5', image: '/blog/thumbnails/v0-5.webp' },
|
||||
{ title: '$7M Series A', slug: 'series-a', image: '/blog/thumbnails/series-a.webp' },
|
||||
{
|
||||
title: 'Realtime Collaboration',
|
||||
slug: 'multiplayer',
|
||||
image: '/blog/thumbnails/multiplayer.webp',
|
||||
},
|
||||
{ title: 'Inside the Executor', slug: 'executor', image: '/blog/thumbnails/executor.webp' },
|
||||
{ title: 'Inside Sim Copilot', slug: 'copilot', image: '/blog/thumbnails/copilot.webp' },
|
||||
] as const
|
||||
export interface NavBlogPost {
|
||||
slug: string
|
||||
title: string
|
||||
ogImage: string
|
||||
}
|
||||
|
||||
function BlogCard({
|
||||
slug,
|
||||
@@ -63,34 +51,32 @@ function BlogCard({
|
||||
)
|
||||
}
|
||||
|
||||
export function BlogDropdown() {
|
||||
interface BlogDropdownProps {
|
||||
posts: NavBlogPost[]
|
||||
}
|
||||
|
||||
export function BlogDropdown({ posts }: BlogDropdownProps) {
|
||||
const [featured, ...rest] = posts
|
||||
|
||||
if (!featured) return null
|
||||
|
||||
return (
|
||||
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
|
||||
<div className='grid grid-cols-3 gap-[8px]'>
|
||||
<BlogCard
|
||||
slug={FEATURED_POST.slug}
|
||||
image={FEATURED_POST.image}
|
||||
title={FEATURED_POST.title}
|
||||
slug={featured.slug}
|
||||
image={featured.ogImage}
|
||||
title={featured.title}
|
||||
imageHeight='190px'
|
||||
titleSize='13px'
|
||||
className='col-span-2 row-span-2'
|
||||
/>
|
||||
|
||||
{POSTS.slice(0, 2).map((post) => (
|
||||
{rest.map((post) => (
|
||||
<BlogCard
|
||||
key={post.slug}
|
||||
slug={post.slug}
|
||||
image={post.image}
|
||||
title={post.title}
|
||||
imageHeight='72px'
|
||||
/>
|
||||
))}
|
||||
|
||||
{POSTS.slice(2).map((post) => (
|
||||
<BlogCard
|
||||
key={post.slug}
|
||||
slug={post.slug}
|
||||
image={post.image}
|
||||
image={post.ogImage}
|
||||
title={post.title}
|
||||
imageHeight='72px'
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,10 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BlogDropdown } from '@/app/(home)/components/navbar/components/blog-dropdown'
|
||||
import {
|
||||
BlogDropdown,
|
||||
type NavBlogPost,
|
||||
} from '@/app/(home)/components/navbar/components/blog-dropdown'
|
||||
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
@@ -23,7 +26,7 @@ interface NavLink {
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
|
||||
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
{ label: 'Pricing', href: '/#pricing' },
|
||||
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
|
||||
]
|
||||
|
||||
@@ -32,9 +35,10 @@ const LINK_CELL = 'flex items-center px-[14px]'
|
||||
|
||||
interface NavbarProps {
|
||||
logoOnly?: boolean
|
||||
blogPosts?: NavBlogPost[]
|
||||
}
|
||||
|
||||
export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
|
||||
@@ -161,7 +165,7 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
|
||||
}}
|
||||
>
|
||||
{dropdown === 'docs' && <DocsDropdown />}
|
||||
{dropdown === 'blog' && <BlogDropdown />}
|
||||
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import {
|
||||
@@ -32,11 +33,18 @@ import {
|
||||
* enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials).
|
||||
*/
|
||||
export default async function Landing() {
|
||||
const allPosts = await getAllPostMeta()
|
||||
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
|
||||
const recentPosts = allPosts.filter((p) => p !== featuredPost).slice(0, 4)
|
||||
const blogPosts = [featuredPost, ...recentPosts]
|
||||
.filter(Boolean)
|
||||
.map((p) => ({ slug: p.slug, title: p.title, ogImage: p.ogImage }))
|
||||
|
||||
return (
|
||||
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||
<StructuredData />
|
||||
<header>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<main>
|
||||
<Hero />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FAQItem } from '@/app/(landing)/integrations/data/types'
|
||||
|
||||
interface IntegrationFAQProps {
|
||||
faqs: FAQItem[]
|
||||
}
|
||||
|
||||
export function IntegrationFAQ({ faqs }: IntegrationFAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<div className='divide-y divide-[#2A2A2A]'>
|
||||
{faqs.map(({ question, answer }, index) => {
|
||||
const isOpen = openIndex === index
|
||||
return (
|
||||
<div key={question}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
className='flex w-full items-start justify-between gap-4 py-5 text-left'
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-[500] text-[15px] leading-snug transition-colors',
|
||||
isOpen ? 'text-[#ECECEC]' : 'text-[#999] hover:text-[#ECECEC]'
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className='pb-5'>
|
||||
<p className='text-[#999] text-[14px] leading-[1.75]'>{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
interface TemplateCardButtonProps {
|
||||
prompt: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function TemplateCardButton({ prompt, children }: TemplateCardButtonProps) {
|
||||
const router = useRouter()
|
||||
|
||||
function handleClick() {
|
||||
LandingPromptStorage.store(prompt)
|
||||
router.push('/signup')
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClick}
|
||||
className='group flex w-full flex-col items-start rounded-lg border border-[#2A2A2A] bg-[#242424] p-5 text-left transition-colors hover:border-[#3d3d3d] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
761
apps/sim/app/(landing)/integrations/[slug]/page.tsx
Normal file
761
apps/sim/app/(landing)/integrations/[slug]/page.tsx
Normal file
@@ -0,0 +1,761 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts'
|
||||
import { IntegrationIcon } from '../components/integration-icon'
|
||||
import { blockTypeToIconMap } from '../data/icon-mapping'
|
||||
import integrations from '../data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from '../data/popular-workflows'
|
||||
import type { AuthType, FAQItem, Integration } from '../data/types'
|
||||
import { IntegrationFAQ } from './components/integration-faq'
|
||||
import { TemplateCardButton } from './components/template-card-button'
|
||||
|
||||
const allIntegrations = integrations as Integration[]
|
||||
const INTEGRATION_COUNT = allIntegrations.length
|
||||
|
||||
/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */
|
||||
const byName = new Map(allIntegrations.map((i) => [i.name, i]))
|
||||
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
|
||||
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
|
||||
|
||||
/** Returns workflow pairs that feature the given integration on either side. */
|
||||
function getPairsFor(name: string) {
|
||||
return POPULAR_WORKFLOWS.filter((p) => p.from === name || p.to === name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` related integration slugs.
|
||||
*
|
||||
* Scoring:
|
||||
* +100 — integration appears as a workflow pair partner (explicit editorial signal)
|
||||
* +N — N operation names shared with the current integration (semantic similarity)
|
||||
*
|
||||
* This means genuine partners always rank first; operation-similar integrations
|
||||
* (e.g. Slack → Teams → Discord for "Send Message") fill the rest organically.
|
||||
*/
|
||||
function getRelatedSlugs(
|
||||
name: string,
|
||||
slug: string,
|
||||
operations: Integration['operations'],
|
||||
limit = 6
|
||||
): string[] {
|
||||
const partners = new Set(getPairsFor(name).map((p) => (p.from === name ? p.to : p.from)))
|
||||
const currentOps = new Set(operations.map((o) => o.name.toLowerCase()))
|
||||
|
||||
return allIntegrations
|
||||
.filter((i) => i.slug !== slug)
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
score:
|
||||
(partners.has(i.name) ? 100 : 0) +
|
||||
i.operations.filter((o) => currentOps.has(o.name.toLowerCase())).length,
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ slug: s }) => s)
|
||||
}
|
||||
|
||||
const AUTH_STEP: Record<AuthType, string> = {
|
||||
oauth: 'Authenticate with one-click OAuth — no credentials to copy-paste.',
|
||||
'api-key': 'Add your API key to authenticate — find it in your account settings.',
|
||||
none: 'Authenticate your account to connect.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates targeted FAQs from integration metadata.
|
||||
* Questions mirror real search queries to drive FAQPage rich snippets.
|
||||
*/
|
||||
function buildFAQs(integration: Integration): FAQItem[] {
|
||||
const { name, description, operations, triggers, authType } = integration
|
||||
const topOps = operations.slice(0, 5)
|
||||
const topOpNames = topOps.map((o) => o.name)
|
||||
const pairs = getPairsFor(name)
|
||||
const authStep = AUTH_STEP[authType]
|
||||
|
||||
const faqs: FAQItem[] = [
|
||||
{
|
||||
question: `What is Sim's ${name} integration?`,
|
||||
answer: `Sim's ${name} integration lets you build AI-powered workflows that automate tasks in ${name} without writing code. ${description} You can connect ${name} to hundreds of other services in the same workflow — from CRMs and spreadsheets to messaging tools and databases.`,
|
||||
},
|
||||
{
|
||||
question: `What can I automate with ${name} in Sim?`,
|
||||
answer:
|
||||
topOpNames.length > 0
|
||||
? `With Sim you can: ${topOpNames.join('; ')}${operations.length > 5 ? `; and ${operations.length - 5} more tools` : ''}. Each action runs inside an AI agent block, so you can combine ${name} with LLM reasoning, conditional logic, and data from any other connected service.`
|
||||
: `Sim lets you automate ${name} workflows by connecting it to an AI agent that can read from it, write to it, and chain it together with other services — all driven by natural-language instructions instead of rigid rules.`,
|
||||
},
|
||||
{
|
||||
question: `How do I connect ${name} to Sim?`,
|
||||
answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open a new workflow. (3) Drag a ${name} block onto the canvas. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your automation is live.`,
|
||||
},
|
||||
...(topOpNames.length >= 2
|
||||
? [
|
||||
{
|
||||
question: `How do I ${topOpNames[0].toLowerCase()} with ${name} in Sim?`,
|
||||
answer: `Add a ${name} block to your workflow and select "${topOpNames[0]}" as the tool. Fill in the required fields — you can reference outputs from earlier steps, such as text generated by an AI agent or data fetched from another integration. No code is required.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pairs.length > 0
|
||||
? [
|
||||
{
|
||||
question: `Can I connect ${name} to ${pairs[0].from === name ? pairs[0].to : pairs[0].from} with Sim?`,
|
||||
answer: `Yes. ${pairs[0].description} In Sim, you set this up by adding both a ${name} block and a ${pairs[0].from === name ? pairs[0].to : pairs[0].from} block to the same workflow and connecting them through an AI agent that orchestrates the logic between them.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(triggers.length > 0
|
||||
? [
|
||||
{
|
||||
question: `Can ${name} trigger a Sim workflow automatically?`,
|
||||
answer: `Yes. ${name} supports ${triggers.length} webhook trigger${triggers.length === 1 ? '' : 's'} that can instantly start a Sim workflow: ${triggers.map((t) => t.name).join(', ')}. No polling needed — the workflow fires the moment the event occurs in ${name}.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
question: `What ${name} tools does Sim support?`,
|
||||
answer:
|
||||
operations.length > 0
|
||||
? `Sim supports ${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}: ${operations.map((o) => o.name).join(', ')}.`
|
||||
: `Sim supports core ${name} tools for reading and writing data, triggering actions, and integrating with your other services. See the full list in the Sim documentation.`,
|
||||
},
|
||||
{
|
||||
question: `Is the ${name} integration free to use?`,
|
||||
answer: `Yes — Sim's free plan includes access to the ${name} integration and every other integration in the library. No credit card is needed to get started. Visit sim.ai to create your account.`,
|
||||
},
|
||||
]
|
||||
|
||||
return faqs
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allIntegrations.map((i) => ({ slug: i.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const integration = bySlug.get(slug)
|
||||
if (!integration) return {}
|
||||
|
||||
const { name, description, operations } = integration
|
||||
const opSample = operations
|
||||
.slice(0, 3)
|
||||
.map((o) => o.name)
|
||||
.join(', ')
|
||||
const metaDesc = `Automate ${name} with AI-powered workflows on Sim. ${description.slice(0, 100).trimEnd()}. Free to start.`
|
||||
|
||||
return {
|
||||
title: `${name} Integration`,
|
||||
description: metaDesc,
|
||||
keywords: [
|
||||
`${name} automation`,
|
||||
`${name} integration`,
|
||||
`automate ${name}`,
|
||||
`connect ${name}`,
|
||||
`${name} workflow`,
|
||||
`${name} AI automation`,
|
||||
...(opSample ? [`${name} ${opSample}`] : []),
|
||||
'workflow automation',
|
||||
'no-code automation',
|
||||
'AI agent workflow',
|
||||
],
|
||||
openGraph: {
|
||||
title: `${name} Integration — AI Workflow Automation | Sim`,
|
||||
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`,
|
||||
url: `https://sim.ai/integrations/${slug}`,
|
||||
type: 'website',
|
||||
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${name} Integration | Sim`,
|
||||
description: `Automate ${name} with AI-powered workflows. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
|
||||
},
|
||||
alternates: { canonical: `https://sim.ai/integrations/${slug}` },
|
||||
}
|
||||
}
|
||||
|
||||
export default async function IntegrationPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const integration = bySlug.get(slug)
|
||||
if (!integration) notFound()
|
||||
|
||||
const { name, description, longDescription, bgColor, docsUrl, operations, triggers, authType } =
|
||||
integration
|
||||
|
||||
const IconComponent = blockTypeToIconMap[integration.type]
|
||||
const faqs = buildFAQs(integration)
|
||||
const relatedSlugs = getRelatedSlugs(name, slug, operations)
|
||||
const relatedIntegrations = relatedSlugs
|
||||
.map((s) => bySlug.get(s))
|
||||
.filter((i): i is Integration => i !== undefined)
|
||||
const featuredPairs = getPairsFor(name)
|
||||
const baseType = integration.type.replace(/_v\d+$/, '')
|
||||
const matchingTemplates = TEMPLATES.filter(
|
||||
(t) =>
|
||||
t.integrationBlockTypes.includes(integration.type) ||
|
||||
t.integrationBlockTypes.includes(baseType)
|
||||
)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Integrations',
|
||||
item: 'https://sim.ai/integrations',
|
||||
},
|
||||
{ '@type': 'ListItem', position: 3, name, item: `https://sim.ai/integrations/${slug}` },
|
||||
],
|
||||
}
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: `${name} Integration`,
|
||||
description,
|
||||
url: `https://sim.ai/integrations/${slug}`,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
featureList: operations.map((o) => o.name),
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||
}
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: `How to automate ${name} with Sim`,
|
||||
description: `Step-by-step guide to connecting ${name} to AI-powered workflows in Sim.`,
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 1,
|
||||
name: 'Create a free Sim account',
|
||||
text: 'Sign up at sim.ai — no credit card required.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 2,
|
||||
name: `Add a ${name} block`,
|
||||
text: `Open a workflow, drag a ${name} block onto the canvas, and authenticate with your ${name} credentials.`,
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 3,
|
||||
name: 'Configure and run',
|
||||
text: `Choose the operation you want, connect it to an AI agent, and run your workflow. Automate anything in ${name} without code.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map(({ question, answer }) => ({
|
||||
'@type': 'Question',
|
||||
name: question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: answer },
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareAppJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
aria-label='Breadcrumb'
|
||||
className='mb-10 flex items-center gap-2 text-[#555] text-[13px]'
|
||||
>
|
||||
<Link href='/' className='transition-colors hover:text-[#999]'>
|
||||
Home
|
||||
</Link>
|
||||
<span aria-hidden='true'>/</span>
|
||||
<Link href='/integrations' className='transition-colors hover:text-[#999]'>
|
||||
Integrations
|
||||
</Link>
|
||||
<span aria-hidden='true'>/</span>
|
||||
<span className='text-[#999]'>{name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section aria-labelledby='integration-heading' className='mb-16'>
|
||||
<div className='mb-6 flex items-center gap-5'>
|
||||
<IntegrationIcon
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-16 w-16 rounded-xl'
|
||||
iconClassName='h-8 w-8'
|
||||
fallbackClassName='text-[26px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div>
|
||||
<p className='mb-0.5 text-[#555] text-[12px]'>Integration</p>
|
||||
<h1
|
||||
id='integration-heading'
|
||||
className='font-[500] text-[#ECECEC] text-[36px] leading-tight sm:text-[44px]'
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mb-8 max-w-[700px] text-[#999] text-[17px] leading-[1.7]'>{description}</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className='flex flex-wrap gap-[8px]'>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Start building free
|
||||
</a>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
View docs
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_300px]'>
|
||||
{/* Main column */}
|
||||
<div className='min-w-0 space-y-16'>
|
||||
{/* Overview */}
|
||||
{longDescription && (
|
||||
<section aria-labelledby='overview-heading'>
|
||||
<h2 id='overview-heading' className='mb-4 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Overview
|
||||
</h2>
|
||||
<p className='text-[#999] text-[15px] leading-[1.8]'>{longDescription}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* How to automate — targets "how to connect X" queries */}
|
||||
<section aria-labelledby='how-it-works-heading'>
|
||||
<h2 id='how-it-works-heading' className='mb-6 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
How to automate {name} with Sim
|
||||
</h2>
|
||||
<ol className='space-y-4' aria-label='Steps to set up automation'>
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
title: 'Create a free account',
|
||||
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: `Add a ${name} block`,
|
||||
body:
|
||||
authType === 'oauth'
|
||||
? `Open a workflow, drag a ${name} block onto the canvas, and connect your account with one-click OAuth.`
|
||||
: authType === 'api-key'
|
||||
? `Open a workflow, drag a ${name} block onto the canvas, and paste in your ${name} API key.`
|
||||
: `Open a workflow, drag a ${name} block onto the canvas, and authenticate your account.`,
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Configure, connect, and run',
|
||||
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
|
||||
},
|
||||
].map(({ step, title, body }) => (
|
||||
<li key={step} className='flex gap-4'>
|
||||
<span
|
||||
className='mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#3d3d3d] font-[500] text-[#555] text-[11px]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className='mb-1 font-[500] text-[#ECECEC] text-[15px]'>{title}</h3>
|
||||
<p className='text-[#999] text-[14px] leading-relaxed'>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Triggers */}
|
||||
{triggers.length > 0 && (
|
||||
<section aria-labelledby='triggers-heading'>
|
||||
<h2 id='triggers-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Triggers
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
These events in {name} can automatically start a Sim workflow — no polling
|
||||
required.
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
|
||||
aria-label={`${name} triggers`}
|
||||
>
|
||||
{triggers.map((trigger) => (
|
||||
<li
|
||||
key={trigger.id}
|
||||
className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-4'
|
||||
>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<span className='inline-flex items-center gap-1 rounded-[4px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC] text-[11px]'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-2.5 w-2.5'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2.5}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<polygon points='13 2 3 14 12 14 11 22 21 10 12 10 13 2' />
|
||||
</svg>
|
||||
Trigger
|
||||
</span>
|
||||
</div>
|
||||
<p className='font-[500] text-[#ECECEC] text-[13px]'>{trigger.name}</p>
|
||||
{trigger.description && (
|
||||
<p className='mt-1 text-[#999] text-[12px] leading-relaxed'>
|
||||
{trigger.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Popular workflows featuring this integration */}
|
||||
{featuredPairs.length > 0 && (
|
||||
<section aria-labelledby='workflows-heading'>
|
||||
<h2 id='workflows-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Popular workflows with {name}
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
Common automation patterns teams build on Sim using {name}.
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-4 sm:grid-cols-2'
|
||||
aria-label='Popular workflow combinations'
|
||||
>
|
||||
{featuredPairs.map(({ from, to, headline, description: desc }) => {
|
||||
const fromInt = byName.get(from)
|
||||
const toInt = byName.get(to)
|
||||
const FromIcon = fromInt ? blockTypeToIconMap[fromInt.type] : undefined
|
||||
const ToIcon = toInt ? blockTypeToIconMap[toInt.type] : undefined
|
||||
const fromBg = fromInt?.bgColor ?? '#6B7280'
|
||||
const toBg = toInt?.bgColor ?? '#6B7280'
|
||||
|
||||
return (
|
||||
<li key={`${from}-${to}`}>
|
||||
<div className='h-full rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<div className='mb-3 flex items-center gap-2 text-[12px]'>
|
||||
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
|
||||
{FromIcon && (
|
||||
<IntegrationIcon
|
||||
bgColor={fromBg}
|
||||
name={from}
|
||||
Icon={FromIcon}
|
||||
as='span'
|
||||
className='h-3.5 w-3.5 rounded-[2px]'
|
||||
iconClassName='h-2.5 w-2.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{from}
|
||||
</span>
|
||||
<span className='text-[#555]' aria-hidden='true'>
|
||||
→
|
||||
</span>
|
||||
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
|
||||
{ToIcon && (
|
||||
<IntegrationIcon
|
||||
bgColor={toBg}
|
||||
name={to}
|
||||
Icon={ToIcon}
|
||||
as='span'
|
||||
className='h-3.5 w-3.5 rounded-[2px]'
|
||||
iconClassName='h-2.5 w-2.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{to}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mb-1 font-[500] text-[#ECECEC] text-[14px]'>{headline}</p>
|
||||
<p className='text-[#999] text-[13px] leading-relaxed'>{desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Workflow templates */}
|
||||
{matchingTemplates.length > 0 && (
|
||||
<section aria-labelledby='templates-heading'>
|
||||
<h2 id='templates-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Workflow templates
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
Ready-to-use workflows featuring {name}. Click any to build it instantly.
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
|
||||
aria-label='Workflow templates'
|
||||
>
|
||||
{matchingTemplates.map((template) => {
|
||||
const allTypes = [
|
||||
integration.type,
|
||||
...template.integrationBlockTypes.filter((bt) => bt !== integration.type),
|
||||
]
|
||||
|
||||
return (
|
||||
<li key={template.title}>
|
||||
<TemplateCardButton prompt={template.prompt}>
|
||||
{/* Integration icons */}
|
||||
<div className='mb-3 flex items-center gap-1.5'>
|
||||
{allTypes.map((bt) => {
|
||||
const int = byType.get(bt)
|
||||
const intName = int?.name ?? bt
|
||||
return (
|
||||
<IntegrationIcon
|
||||
key={bt}
|
||||
bgColor={int?.bgColor ?? '#6B7280'}
|
||||
name={intName}
|
||||
Icon={blockTypeToIconMap[bt]}
|
||||
className='h-6 w-6 rounded-[5px]'
|
||||
iconClassName='h-3.5 w-3.5'
|
||||
fallbackClassName='text-[9px]'
|
||||
title={intName}
|
||||
aria-label={intName}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className='mb-3 font-[500] text-[#ECECEC] text-[13px] leading-snug'>
|
||||
{template.title}
|
||||
</p>
|
||||
|
||||
<span className='text-[#555] text-[12px] transition-colors group-hover:text-[#999]'>
|
||||
Try this workflow →
|
||||
</span>
|
||||
</TemplateCardButton>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tools */}
|
||||
{operations.length > 0 && (
|
||||
<section aria-labelledby='tools-heading'>
|
||||
<h2 id='tools-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Supported tools
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in
|
||||
Sim
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-2 sm:grid-cols-2'
|
||||
aria-label={`${name} supported tools`}
|
||||
>
|
||||
{operations.map((op) => (
|
||||
<li
|
||||
key={op.name}
|
||||
className='rounded-[6px] border border-[#2A2A2A] bg-[#242424] px-3.5 py-3'
|
||||
>
|
||||
<p className='font-[500] text-[#ECECEC] text-[13px]'>{op.name}</p>
|
||||
{op.description && (
|
||||
<p className='mt-0.5 text-[#555] text-[12px] leading-relaxed'>
|
||||
{op.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* FAQ */}
|
||||
<section aria-labelledby='faq-heading'>
|
||||
<h2 id='faq-heading' className='mb-8 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<IntegrationFAQ faqs={faqs} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className='space-y-5' aria-label='Integration details'>
|
||||
{/* Quick details */}
|
||||
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>Details</h3>
|
||||
<dl className='space-y-3 text-[13px]'>
|
||||
{operations.length > 0 && (
|
||||
<div>
|
||||
<dt className='text-[#555]'>Tools</dt>
|
||||
<dd className='text-[#ECECEC]'>{operations.length} supported</dd>
|
||||
</div>
|
||||
)}
|
||||
{triggers.length > 0 && (
|
||||
<div>
|
||||
<dt className='text-[#555]'>Triggers</dt>
|
||||
<dd className='text-[#ECECEC]'>{triggers.length} available</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className='text-[#555]'>Auth</dt>
|
||||
<dd className='text-[#ECECEC]'>
|
||||
{authType === 'oauth'
|
||||
? 'One-click OAuth'
|
||||
: authType === 'api-key'
|
||||
? 'API key'
|
||||
: 'None required'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-[#555]'>Pricing</dt>
|
||||
<dd className='text-[#ECECEC]'>Free to start</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='mt-5 flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[#1C1C1C] text-[13px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Get started free
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Docs */}
|
||||
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<h3 className='mb-2 font-[500] text-[#ECECEC] text-[14px]'>Documentation</h3>
|
||||
<p className='mb-4 text-[#999] text-[13px] leading-relaxed'>
|
||||
Full API reference, authentication setup, and usage examples for {name}.
|
||||
</p>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1.5 text-[#999] text-[13px] transition-colors hover:text-[#ECECEC]'
|
||||
>
|
||||
docs.sim.ai
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Related integrations — internal linking for SEO */}
|
||||
{relatedIntegrations.length > 0 && (
|
||||
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>Related integrations</h3>
|
||||
<ul className='space-y-2'>
|
||||
{relatedIntegrations.map((rel) => (
|
||||
<li key={rel.slug}>
|
||||
<Link
|
||||
href={`/integrations/${rel.slug}`}
|
||||
className='flex items-center gap-2.5 rounded-[6px] p-1.5 text-[#999] text-[13px] transition-colors hover:bg-[#2A2A2A] hover:text-[#ECECEC]'
|
||||
>
|
||||
<IntegrationIcon
|
||||
bgColor={rel.bgColor}
|
||||
name={rel.name}
|
||||
Icon={blockTypeToIconMap[rel.type]}
|
||||
as='span'
|
||||
className='h-6 w-6 rounded-[4px]'
|
||||
iconClassName='h-3.5 w-3.5'
|
||||
fallbackClassName='text-[10px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{rel.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href='/integrations'
|
||||
className='mt-4 block text-[#555] text-[12px] transition-colors hover:text-[#999]'
|
||||
>
|
||||
All integrations →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<section
|
||||
aria-labelledby='cta-heading'
|
||||
className='mt-20 rounded-xl border border-[#2A2A2A] bg-[#242424] p-8 text-center sm:p-12'
|
||||
>
|
||||
<h2
|
||||
id='cta-heading'
|
||||
className='mb-3 font-[500] text-[#ECECEC] text-[28px] sm:text-[34px]'
|
||||
>
|
||||
Start automating {name} today
|
||||
</h2>
|
||||
<p className='mx-auto mb-8 max-w-[480px] text-[#999] text-[16px] leading-relaxed'>
|
||||
Build your first AI workflow with {name} in minutes. Connect to every tool your team
|
||||
uses. Free to start — no credit card required.
|
||||
</p>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build for free →
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import type { Integration } from '@/app/(landing)/integrations/data/types'
|
||||
import { IntegrationIcon } from './integration-icon'
|
||||
|
||||
interface IntegrationCardProps {
|
||||
integration: Integration
|
||||
IconComponent?: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
export function IntegrationCard({ integration, IconComponent }: IntegrationCardProps) {
|
||||
const { slug, name, description, bgColor, operationCount, triggerCount } = integration
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/integrations/${slug}`}
|
||||
className='group flex flex-col rounded-lg border border-[#2A2A2A] bg-[#242424] p-4 transition-colors hover:border-[#3d3d3d] hover:bg-[#2A2A2A]'
|
||||
aria-label={`${name} integration`}
|
||||
>
|
||||
<IntegrationIcon
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='mb-3 h-10 w-10 rounded-lg'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className='mb-1 font-[500] text-[#ECECEC] text-[14px] leading-snug'>{name}</h3>
|
||||
|
||||
{/* Description — clamped to 2 lines */}
|
||||
<p className='mb-3 line-clamp-2 flex-1 text-[#999] text-[12px] leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Footer row */}
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
{operationCount > 0 && (
|
||||
<Badge className='border-0 bg-[#333] text-[#999] text-[11px]'>
|
||||
{operationCount} {operationCount === 1 ? 'tool' : 'tools'}
|
||||
</Badge>
|
||||
)}
|
||||
{triggerCount > 0 && (
|
||||
<Badge className='border-0 bg-[#333] text-[#999] text-[11px]'>
|
||||
{triggerCount} {triggerCount === 1 ? 'trigger' : 'triggers'}
|
||||
</Badge>
|
||||
)}
|
||||
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[#999]'>
|
||||
Learn more →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
|
||||
import type { Integration } from '@/app/(landing)/integrations/data/types'
|
||||
import { IntegrationCard } from './integration-card'
|
||||
|
||||
interface IntegrationGridProps {
|
||||
integrations: Integration[]
|
||||
}
|
||||
|
||||
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return integrations
|
||||
return integrations.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.description.toLowerCase().includes(q) ||
|
||||
i.operations.some(
|
||||
(op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
|
||||
) ||
|
||||
i.triggers.some((t) => t.name.toLowerCase().includes(q))
|
||||
)
|
||||
}, [integrations, query])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='relative mb-8 max-w-[480px]'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<circle cx={11} cy={11} r={8} />
|
||||
<path d='m21 21-4.35-4.35' />
|
||||
</svg>
|
||||
<Input
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className='pl-9'
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className='py-12 text-center text-[#555] text-[15px]'>
|
||||
No integrations found for “{query}”
|
||||
</p>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
|
||||
{filtered.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.type}
|
||||
integration={integration}
|
||||
IconComponent={blockTypeToIconMap[integration.type]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isLightBg } from '@/app/(landing)/integrations/data/utils'
|
||||
|
||||
interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
|
||||
bgColor: string
|
||||
/** Integration name — used for the fallback initial letter. */
|
||||
name: string
|
||||
/** Optional icon component. When absent, renders the first letter of `name`. */
|
||||
Icon?: ComponentType<SVGProps<SVGSVGElement>> | null
|
||||
/** Tailwind size + rounding classes for the container. Default: `h-10 w-10 rounded-lg` */
|
||||
className?: string
|
||||
/** Tailwind size classes for the icon SVG. Default: `h-5 w-5` */
|
||||
iconClassName?: string
|
||||
/** Tailwind text-size class for the fallback letter. Default: `text-[15px]` */
|
||||
fallbackClassName?: string
|
||||
/** Rendered HTML element. Default: `div` */
|
||||
as?: ElementType
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored icon box used across integration listing and detail pages.
|
||||
* Renders an integration icon over a brand-colored background, falling back
|
||||
* to the integration's initial letter when no icon is available.
|
||||
*/
|
||||
export function IntegrationIcon({
|
||||
bgColor,
|
||||
name,
|
||||
Icon,
|
||||
className,
|
||||
iconClassName = 'h-5 w-5',
|
||||
fallbackClassName = 'text-[15px]',
|
||||
as: Tag = 'div',
|
||||
...rest
|
||||
}: IntegrationIconProps) {
|
||||
const isLight = isLightBg(bgColor)
|
||||
const fgColor = isLight ? 'text-[#1C1C1C]' : 'text-white'
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cn('flex shrink-0 items-center justify-center', className)}
|
||||
style={{ background: bgColor }}
|
||||
{...rest}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={cn(iconClassName, fgColor)} />
|
||||
) : (
|
||||
<span className={cn('font-[500] leading-none', fallbackClassName, fgColor)}>
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
347
apps/sim/app/(landing)/integrations/data/icon-mapping.ts
Normal file
347
apps/sim/app/(landing)/integrations/data/icon-mapping.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
// Auto-generated file - do not edit manually
|
||||
// Generated by scripts/generate-docs.ts
|
||||
// Maps block types to their icon component references for the integrations page
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
A2AIcon,
|
||||
AhrefsIcon,
|
||||
AirtableIcon,
|
||||
AirweaveIcon,
|
||||
AlgoliaIcon,
|
||||
AmplitudeIcon,
|
||||
ApifyIcon,
|
||||
ApolloIcon,
|
||||
ArxivIcon,
|
||||
AsanaIcon,
|
||||
AshbyIcon,
|
||||
AttioIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
BrandfetchIcon,
|
||||
BrowserUseIcon,
|
||||
CalComIcon,
|
||||
CalendlyIcon,
|
||||
CirclebackIcon,
|
||||
ClayIcon,
|
||||
ClerkIcon,
|
||||
CloudflareIcon,
|
||||
ConfluenceIcon,
|
||||
CursorIcon,
|
||||
DatabricksIcon,
|
||||
DatadogIcon,
|
||||
DevinIcon,
|
||||
DiscordIcon,
|
||||
DocumentIcon,
|
||||
DocuSignIcon,
|
||||
DropboxIcon,
|
||||
DsPyIcon,
|
||||
DubIcon,
|
||||
DuckDuckGoIcon,
|
||||
DynamoDBIcon,
|
||||
ElasticsearchIcon,
|
||||
ElevenLabsIcon,
|
||||
EnrichSoIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GammaIcon,
|
||||
GithubIcon,
|
||||
GitLabIcon,
|
||||
GmailIcon,
|
||||
GongIcon,
|
||||
GoogleAdsIcon,
|
||||
GoogleBigQueryIcon,
|
||||
GoogleBooksIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleContactsIcon,
|
||||
GoogleDocsIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleFormsIcon,
|
||||
GoogleGroupsIcon,
|
||||
GoogleIcon,
|
||||
GoogleMapsIcon,
|
||||
GoogleMeetIcon,
|
||||
GooglePagespeedIcon,
|
||||
GoogleSheetsIcon,
|
||||
GoogleSlidesIcon,
|
||||
GoogleTasksIcon,
|
||||
GoogleTranslateIcon,
|
||||
GoogleVaultIcon,
|
||||
GrafanaIcon,
|
||||
GrainIcon,
|
||||
GreenhouseIcon,
|
||||
GreptileIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
ImageIcon,
|
||||
IncidentioIcon,
|
||||
IntercomIcon,
|
||||
JinaAIIcon,
|
||||
JiraIcon,
|
||||
JiraServiceManagementIcon,
|
||||
KalshiIcon,
|
||||
LangsmithIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
LinkupIcon,
|
||||
LoopsIcon,
|
||||
LumaIcon,
|
||||
MailchimpIcon,
|
||||
MailgunIcon,
|
||||
MailServerIcon,
|
||||
Mem0Icon,
|
||||
MicrosoftDataverseIcon,
|
||||
MicrosoftExcelIcon,
|
||||
MicrosoftOneDriveIcon,
|
||||
MicrosoftPlannerIcon,
|
||||
MicrosoftSharepointIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
MistralIcon,
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NotionIcon,
|
||||
ObsidianIcon,
|
||||
OktaIcon,
|
||||
OnePasswordIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
PackageSearchIcon,
|
||||
PagerDutyIcon,
|
||||
ParallelIcon,
|
||||
PerplexityIcon,
|
||||
PineconeIcon,
|
||||
PipedriveIcon,
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
RDSIcon,
|
||||
RedditIcon,
|
||||
RedisIcon,
|
||||
ReductoIcon,
|
||||
ResendIcon,
|
||||
RevenueCatIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SearchIcon,
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
ServiceNowIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SimilarwebIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SQSIcon,
|
||||
SshIcon,
|
||||
STTIcon,
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
TinybirdIcon,
|
||||
TranslateIcon,
|
||||
TrelloIcon,
|
||||
TTSIcon,
|
||||
TwilioIcon,
|
||||
TypeformIcon,
|
||||
UpstashIcon,
|
||||
VercelIcon,
|
||||
VideoIcon,
|
||||
WealthboxIcon,
|
||||
WebflowIcon,
|
||||
WhatsAppIcon,
|
||||
WikipediaIcon,
|
||||
WordpressIcon,
|
||||
WorkdayIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
ZendeskIcon,
|
||||
ZepIcon,
|
||||
ZoomIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
a2a: A2AIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
airtable: AirtableIcon,
|
||||
airweave: AirweaveIcon,
|
||||
algolia: AlgoliaIcon,
|
||||
amplitude: AmplitudeIcon,
|
||||
apify: ApifyIcon,
|
||||
apollo: ApolloIcon,
|
||||
arxiv: ArxivIcon,
|
||||
asana: AsanaIcon,
|
||||
ashby: AshbyIcon,
|
||||
attio: AttioIcon,
|
||||
box: BoxCompanyIcon,
|
||||
brandfetch: BrandfetchIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
calcom: CalComIcon,
|
||||
calendly: CalendlyIcon,
|
||||
circleback: CirclebackIcon,
|
||||
clay: ClayIcon,
|
||||
clerk: ClerkIcon,
|
||||
cloudflare: CloudflareIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
databricks: DatabricksIcon,
|
||||
datadog: DatadogIcon,
|
||||
devin: DevinIcon,
|
||||
discord: DiscordIcon,
|
||||
docusign: DocuSignIcon,
|
||||
dropbox: DropboxIcon,
|
||||
dspy: DsPyIcon,
|
||||
dub: DubIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
gamma: GammaIcon,
|
||||
github_v2: GithubIcon,
|
||||
gitlab: GitLabIcon,
|
||||
gmail_v2: GmailIcon,
|
||||
gong: GongIcon,
|
||||
google_ads: GoogleAdsIcon,
|
||||
google_bigquery: GoogleBigQueryIcon,
|
||||
google_books: GoogleBooksIcon,
|
||||
google_calendar_v2: GoogleCalendarIcon,
|
||||
google_contacts: GoogleContactsIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_maps: GoogleMapsIcon,
|
||||
google_meet: GoogleMeetIcon,
|
||||
google_pagespeed: GooglePagespeedIcon,
|
||||
google_search: GoogleIcon,
|
||||
google_sheets_v2: GoogleSheetsIcon,
|
||||
google_slides_v2: GoogleSlidesIcon,
|
||||
google_tasks: GoogleTasksIcon,
|
||||
google_translate: GoogleTranslateIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
greenhouse: GreenhouseIcon,
|
||||
greptile: GreptileIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
intercom_v2: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
jira_service_management: JiraServiceManagementIcon,
|
||||
kalshi_v2: KalshiIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
lemlist: LemlistIcon,
|
||||
linear: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linkup: LinkupIcon,
|
||||
loops: LoopsIcon,
|
||||
luma: LumaIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_dataverse: MicrosoftDataverseIcon,
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
mistral_parse_v3: MistralIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
notion_v2: NotionIcon,
|
||||
obsidian: ObsidianIcon,
|
||||
okta: OktaIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
onepassword: OnePasswordIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
pagerduty: PagerDutyIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
pinecone: PineconeIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
redis: RedisIcon,
|
||||
reducto_v2: ReductoIcon,
|
||||
resend: ResendIcon,
|
||||
revenuecat: RevenueCatIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
search: SearchIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
similarweb: SimilarwebIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
sqs: SQSIcon,
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
stripe: StripeIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract_v2: TextractIcon,
|
||||
tinybird: TinybirdIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
tts: TTSIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
upstash: UpstashIcon,
|
||||
vercel: VercelIcon,
|
||||
video_generator_v2: VideoIcon,
|
||||
vision_v2: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
wordpress: WordpressIcon,
|
||||
workday: WorkdayIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zoom: ZoomIcon,
|
||||
}
|
||||
11050
apps/sim/app/(landing)/integrations/data/integrations.json
Normal file
11050
apps/sim/app/(landing)/integrations/data/integrations.json
Normal file
File diff suppressed because it is too large
Load Diff
117
apps/sim/app/(landing)/integrations/data/popular-workflows.ts
Normal file
117
apps/sim/app/(landing)/integrations/data/popular-workflows.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Curated popular workflow pairs used on both the /integrations listing page
|
||||
* and individual /integrations/[slug] pages.
|
||||
*
|
||||
* Each pair targets specific long-tail search queries like "notion to slack automation".
|
||||
* The headline and description are written to be both human-readable and keyword-rich.
|
||||
*/
|
||||
|
||||
export interface WorkflowPair {
|
||||
/** Integration name (must match `name` field in integrations.json) */
|
||||
from: string
|
||||
/** Integration name (must match `name` field in integrations.json) */
|
||||
to: string
|
||||
headline: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const POPULAR_WORKFLOWS: WorkflowPair[] = [
|
||||
{
|
||||
from: 'Slack',
|
||||
to: 'Notion',
|
||||
headline: 'Archive Slack conversations to Notion',
|
||||
description:
|
||||
'Capture important Slack messages as Notion pages or database entries — ideal for meeting notes, decision logs, and knowledge bases.',
|
||||
},
|
||||
{
|
||||
from: 'Notion',
|
||||
to: 'Slack',
|
||||
headline: 'Notify your team from Notion',
|
||||
description:
|
||||
'Post Slack messages automatically when Notion pages are created or updated so the whole team stays aligned without manual check-ins.',
|
||||
},
|
||||
{
|
||||
from: 'GitHub',
|
||||
to: 'Jira',
|
||||
headline: 'Link GitHub pull requests to Jira tickets',
|
||||
description:
|
||||
'Transition Jira issues when PRs are opened or merged, keeping your project board accurate without any manual updates.',
|
||||
},
|
||||
{
|
||||
from: 'GitHub',
|
||||
to: 'Linear',
|
||||
headline: 'Sync GitHub events with Linear issues',
|
||||
description:
|
||||
'Create Linear issues from GitHub activity, update status on merge, and keep your engineering workflow tightly connected.',
|
||||
},
|
||||
{
|
||||
from: 'Gmail',
|
||||
to: 'Notion',
|
||||
headline: 'Save incoming emails to Notion databases',
|
||||
description:
|
||||
'Extract structured data from Gmail and store it in Notion — ideal for lead capture, support tickets, and meeting scheduling.',
|
||||
},
|
||||
{
|
||||
from: 'HubSpot',
|
||||
to: 'Slack',
|
||||
headline: 'Get HubSpot deal alerts in Slack',
|
||||
description:
|
||||
'Receive instant Slack notifications when HubSpot deals advance, contacts are created, or revenue milestones are hit.',
|
||||
},
|
||||
{
|
||||
from: 'Google Sheets',
|
||||
to: 'Slack',
|
||||
headline: 'Send Slack messages from Google Sheets',
|
||||
description:
|
||||
'Watch a spreadsheet for new rows or changes, then post formatted Slack updates to keep stakeholders informed in real time.',
|
||||
},
|
||||
{
|
||||
from: 'Salesforce',
|
||||
to: 'Slack',
|
||||
headline: 'Push Salesforce pipeline updates to Slack',
|
||||
description:
|
||||
'Alert your sales team in Slack when Salesforce opportunities advance, close, or need immediate attention.',
|
||||
},
|
||||
{
|
||||
from: 'Airtable',
|
||||
to: 'Gmail',
|
||||
headline: 'Trigger Gmail from Airtable records',
|
||||
description:
|
||||
'Send personalised Gmail messages when Airtable records are created or updated — great for onboarding flows and follow-up sequences.',
|
||||
},
|
||||
{
|
||||
from: 'Linear',
|
||||
to: 'Slack',
|
||||
headline: 'Linear issue updates in Slack',
|
||||
description:
|
||||
'Post Slack messages when Linear issues are created, assigned, or completed so your team is always in the loop.',
|
||||
},
|
||||
{
|
||||
from: 'Jira',
|
||||
to: 'Confluence',
|
||||
headline: 'Auto-generate Confluence pages from Jira sprints',
|
||||
description:
|
||||
'Create Confluence documentation from Jira sprint data automatically, eliminating manual reporting at the end of every sprint.',
|
||||
},
|
||||
{
|
||||
from: 'Google Sheets',
|
||||
to: 'Notion',
|
||||
headline: 'Sync Google Sheets data into Notion',
|
||||
description:
|
||||
'Transform spreadsheet rows into structured Notion database entries for richer documentation and cross-team project tracking.',
|
||||
},
|
||||
{
|
||||
from: 'GitHub',
|
||||
to: 'Slack',
|
||||
headline: 'Get GitHub activity alerts in Slack',
|
||||
description:
|
||||
'Post Slack notifications for new PRs, commits, issues, or deployments so your engineering team never misses a critical event.',
|
||||
},
|
||||
{
|
||||
from: 'HubSpot',
|
||||
to: 'Gmail',
|
||||
headline: 'Send personalised emails from HubSpot events',
|
||||
description:
|
||||
'Trigger Gmail messages when HubSpot contacts enter a lifecycle stage, ensuring timely and relevant outreach without manual effort.',
|
||||
},
|
||||
]
|
||||
37
apps/sim/app/(landing)/integrations/data/types.ts
Normal file
37
apps/sim/app/(landing)/integrations/data/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared types for the integrations section of the landing site.
|
||||
// Mirrors the shape written by scripts/generate-docs.ts → writeIntegrationsJson().
|
||||
|
||||
export type AuthType = 'oauth' | 'api-key' | 'none'
|
||||
|
||||
export interface TriggerInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface OperationInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
type: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
longDescription: string
|
||||
bgColor: string
|
||||
iconName: string
|
||||
docsUrl: string
|
||||
operations: OperationInfo[]
|
||||
operationCount: number
|
||||
triggers: TriggerInfo[]
|
||||
triggerCount: number
|
||||
authType: AuthType
|
||||
category: string
|
||||
}
|
||||
15
apps/sim/app/(landing)/integrations/data/utils.ts
Normal file
15
apps/sim/app/(landing)/integrations/data/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Utility helpers for the integrations landing pages.
|
||||
* Shared across the listing grid, individual integration cards, and slug pages.
|
||||
*/
|
||||
|
||||
/** bgColor values that are visually light and require dark icon text. */
|
||||
const LIGHT_BG = new Set(['#e0e0e0', '#f5f5f5', '#ffffff', '#ececec', '#f0f0f0'])
|
||||
|
||||
/**
|
||||
* Returns true when `bgColor` is a light color that requires dark foreground text.
|
||||
* Handles gradient strings safely — they always use light foreground (white).
|
||||
*/
|
||||
export function isLightBg(bgColor: string): boolean {
|
||||
return LIGHT_BG.has(bgColor.toLowerCase())
|
||||
}
|
||||
43
apps/sim/app/(landing)/integrations/layout.tsx
Normal file
43
apps/sim/app/(landing)/integrations/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function IntegrationsLayout({ children }: { children: React.ReactNode }) {
|
||||
const orgJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: 'https://sim.ai',
|
||||
logo: 'https://sim.ai/logo/primary/small.png',
|
||||
sameAs: ['https://x.com/simdotai'],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim',
|
||||
url: 'https://sim.ai',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://sim.ai/search?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='dark flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
apps/sim/app/(landing)/integrations/page.tsx
Normal file
165
apps/sim/app/(landing)/integrations/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { IntegrationGrid } from './components/integration-grid'
|
||||
import { blockTypeToIconMap } from './data/icon-mapping'
|
||||
import integrations from './data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from './data/popular-workflows'
|
||||
import type { Integration } from './data/types'
|
||||
|
||||
const allIntegrations = integrations as Integration[]
|
||||
const INTEGRATION_COUNT = allIntegrations.length
|
||||
|
||||
/**
|
||||
* Unique integration names that appear in popular workflow pairs.
|
||||
* Used for metadata keywords so they stay in sync automatically.
|
||||
*/
|
||||
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Integrations',
|
||||
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
|
||||
keywords: [
|
||||
'workflow automation integrations',
|
||||
'AI workflow automation',
|
||||
'no-code automation',
|
||||
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
|
||||
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
|
||||
],
|
||||
openGraph: {
|
||||
title: 'Integrations for AI Workflow Automation | Sim',
|
||||
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim. Build AI-powered pipelines that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
|
||||
url: 'https://sim.ai/integrations',
|
||||
type: 'website',
|
||||
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Integrations | Sim',
|
||||
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim's AI workflow automation.`,
|
||||
},
|
||||
alternates: { canonical: 'https://sim.ai/integrations' },
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Integrations',
|
||||
item: 'https://sim.ai/integrations',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Sim AI Workflow Integrations',
|
||||
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim for building AI-powered workflow automation.`,
|
||||
url: 'https://sim.ai/integrations',
|
||||
numberOfItems: INTEGRATION_COUNT,
|
||||
itemListElement: allIntegrations.map((integration, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
url: `https://sim.ai/integrations/${integration.slug}`,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
featureList: integration.operations.map((o) => o.name),
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
||||
/>
|
||||
|
||||
<div className='mx-auto max-w-[1200px] px-6 py-16 sm:px-8 md:px-12'>
|
||||
{/* Hero */}
|
||||
<section aria-labelledby='integrations-heading' className='mb-16'>
|
||||
<h1
|
||||
id='integrations-heading'
|
||||
className='mb-4 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'
|
||||
>
|
||||
Integrations
|
||||
</h1>
|
||||
<p className='max-w-[640px] text-[#999] text-[18px] leading-relaxed'>
|
||||
Connect every tool your team uses. Build AI-powered workflows that automate tasks across{' '}
|
||||
{TOP_NAMES.slice(0, 4).map((name, i, arr) => {
|
||||
const integration = allIntegrations.find((int) => int.name === name)
|
||||
const Icon = integration ? blockTypeToIconMap[integration.type] : undefined
|
||||
return (
|
||||
<span key={name} className='inline-flex items-center gap-[5px]'>
|
||||
{Icon && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-flex shrink-0'
|
||||
style={{ opacity: 0.65 }}
|
||||
>
|
||||
<Icon className='h-[0.85em] w-[0.85em]' />
|
||||
</span>
|
||||
)}
|
||||
{name}
|
||||
{i < arr.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{' and more.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Searchable grid — client component */}
|
||||
<section aria-labelledby='all-integrations-heading'>
|
||||
<h2 id='all-integrations-heading' className='mb-8 font-[500] text-[#ECECEC] text-[24px]'>
|
||||
All Integrations
|
||||
</h2>
|
||||
<IntegrationGrid integrations={allIntegrations} />
|
||||
</section>
|
||||
|
||||
{/* Integration request */}
|
||||
<div className='mt-16 flex flex-col items-start gap-3 border-[#2A2A2A] border-t pt-10 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<p className='font-[500] text-[#ECECEC] text-[15px]'>
|
||||
Don't see the integration you need?
|
||||
</p>
|
||||
<p className='mt-0.5 text-[#555] text-[13px]'>
|
||||
Let us know and we'll prioritize it.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim/issues/new?labels=integration+request&template=integration_request.md'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] shrink-0 items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Request an integration
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -38,7 +39,13 @@ function toCredentialResponse(
|
||||
scope: string | null
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||
// Some providers (e.g. Box) don't return scopes in their token response,
|
||||
// so the DB column stays empty. Fall back to the configured scopes for
|
||||
// the provider so the credential-selector doesn't show a false
|
||||
// "Additional permissions required" banner.
|
||||
const scopes = storedScope
|
||||
? storedScope.split(/[\s,]+/).filter(Boolean)
|
||||
: getCanonicalScopesForProvider(providerId)
|
||||
const [_, featureType = 'default'] = providerId.split('-')
|
||||
|
||||
return {
|
||||
|
||||
@@ -71,11 +71,6 @@ export const ChatInput: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust height on input change
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [inputValue])
|
||||
|
||||
// Close the input when clicking outside (only when empty)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -94,17 +89,14 @@ export const ChatInput: React.FC<{
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [inputValue])
|
||||
|
||||
// Handle focus and initial height when activated
|
||||
useEffect(() => {
|
||||
if (isActive && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
adjustTextareaHeight() // Adjust height when becoming active
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleActivate = () => {
|
||||
setIsActive(true)
|
||||
// Focus is now handled by the useEffect above
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
@@ -186,6 +178,7 @@ export const ChatInput: React.FC<{
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
// Handle voice start with smooth transition to voice-first mode
|
||||
|
||||
@@ -78,9 +78,10 @@ export function VoiceInterface({
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const isCallEndedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
currentStateRef.current = state
|
||||
}, [state])
|
||||
const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => {
|
||||
setState(next)
|
||||
currentStateRef.current = next
|
||||
}, [])
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null)
|
||||
@@ -97,9 +98,10 @@ export function VoiceInterface({
|
||||
(window as WindowWithSpeech).webkitSpeechRecognition
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted
|
||||
}, [isMuted])
|
||||
const updateIsMuted = useCallback((next: boolean) => {
|
||||
setIsMuted(next)
|
||||
isMutedRef.current = next
|
||||
}, [])
|
||||
|
||||
const setResponseTimeout = useCallback(() => {
|
||||
if (responseTimeoutRef.current) {
|
||||
@@ -108,7 +110,7 @@ export function VoiceInterface({
|
||||
|
||||
responseTimeoutRef.current = setTimeout(() => {
|
||||
if (currentStateRef.current === 'listening') {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
}
|
||||
}, 5000)
|
||||
}, [])
|
||||
@@ -123,10 +125,10 @@ export function VoiceInterface({
|
||||
useEffect(() => {
|
||||
if (isPlayingAudio && state !== 'agent_speaking') {
|
||||
clearResponseTimeout()
|
||||
setState('agent_speaking')
|
||||
updateState('agent_speaking')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(true)
|
||||
updateIsMuted(true)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
@@ -141,17 +143,17 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
} else if (!isPlayingAudio && state === 'agent_speaking') {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isPlayingAudio, state, clearResponseTimeout])
|
||||
}, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted])
|
||||
|
||||
const setupAudio = useCallback(async () => {
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ export function VoiceInterface({
|
||||
return
|
||||
}
|
||||
|
||||
setState('listening')
|
||||
updateState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
@@ -320,10 +322,10 @@ export function VoiceInterface({
|
||||
logger.error('Error starting recognition:', error)
|
||||
}
|
||||
}
|
||||
}, [isInitialized, isMuted, state])
|
||||
}, [isInitialized, isMuted, state, updateState])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
@@ -333,15 +335,15 @@ export function VoiceInterface({
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [updateState])
|
||||
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
onInterrupt?.()
|
||||
setState('listening')
|
||||
updateState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
@@ -356,14 +358,14 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state, onInterrupt])
|
||||
}, [state, onInterrupt, updateState, updateIsMuted])
|
||||
|
||||
const handleCallEnd = useCallback(() => {
|
||||
isCallEndedRef.current = true
|
||||
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
@@ -376,7 +378,7 @@ export function VoiceInterface({
|
||||
clearResponseTimeout()
|
||||
onInterrupt?.()
|
||||
onCallEnd?.()
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout])
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -397,7 +399,7 @@ export function VoiceInterface({
|
||||
}
|
||||
|
||||
const newMutedState = !isMuted
|
||||
setIsMuted(newMutedState)
|
||||
updateIsMuted(newMutedState)
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -410,7 +412,7 @@ export function VoiceInterface({
|
||||
} else if (state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening])
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupported) {
|
||||
|
||||
@@ -260,7 +260,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
|
||||
}, [file])
|
||||
|
||||
const handleOpenInFiles = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/files?fileId=${fileId}`)
|
||||
router.push(`/workspace/${workspaceId}/files?fileId=${encodeURIComponent(fileId)}`)
|
||||
}, [router, workspaceId, fileId])
|
||||
|
||||
return (
|
||||
@@ -344,10 +344,10 @@ interface EmbeddedFileProps {
|
||||
}
|
||||
|
||||
function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
|
||||
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
|
||||
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
|
||||
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
|
||||
|
||||
if (isLoading) return LOADING_SKELETON
|
||||
if (isLoading || (isFetching && !file)) return LOADING_SKELETON
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface TemplatePrompt {
|
||||
title: string
|
||||
prompt: string
|
||||
image?: string
|
||||
// Base block type keys from `blocks/registry.ts` for integrations used by this template.
|
||||
integrationBlockTypes: string[]
|
||||
modules: ModuleTag[]
|
||||
category: Category
|
||||
tags: Tag[]
|
||||
@@ -126,6 +128,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Create a self-healing CRM table that keeps track of all my customers by integrating with my existing data sources. Schedule a recurring job every morning to automatically pull updates from all relevant data sources and keep my CRM up to date.',
|
||||
image: '/templates/crm-light.png',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'sales', 'crm', 'sync', 'automation'],
|
||||
@@ -137,6 +140,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Create an agent that checks my Google Calendar each morning, researches every attendee and topic on the web, and prepares a brief for each meeting so I walk in fully prepared. Schedule it to run every weekday morning.',
|
||||
image: '/templates/meeting-prep-dark.png',
|
||||
integrationBlockTypes: ['google_calendar'],
|
||||
modules: ['agent', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'sales', 'research', 'automation'],
|
||||
@@ -148,6 +152,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Create a file of all my todos then go one by one and check off every time a todo is done. Look at my calendar and see what I have to do.',
|
||||
image: '/templates/todo-list-light.png',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['individual', 'automation'],
|
||||
@@ -159,6 +164,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Build an agent that takes a topic, searches the web for the latest information, summarizes key findings, and compiles them into a clean document I can review.',
|
||||
image: '/templates/research-assistant-dark.png',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['founder', 'research', 'content', 'individual'],
|
||||
@@ -170,6 +176,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Create a workflow that reads my Gmail inbox, identifies emails that need a response, and drafts contextual replies for each one. Schedule it to run every hour.',
|
||||
image: '/templates/gmail-agent-dark.png',
|
||||
integrationBlockTypes: ['gmail'],
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
@@ -181,6 +188,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
prompt:
|
||||
'Create a table that tracks all my expenses by pulling transactions from my connected accounts. Categorize each expense automatically and generate a weekly summary report.',
|
||||
image: '/templates/expense-tracker-light.png',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'scheduled', 'workflows'],
|
||||
category: 'popular',
|
||||
tags: ['finance', 'individual', 'reporting'],
|
||||
@@ -193,6 +201,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'RFP and proposal drafter',
|
||||
prompt:
|
||||
'Create a knowledge base from my past proposals, case studies, and company information. Then build an agent that drafts responses to new RFPs by matching requirements to relevant past work, generating tailored sections, and compiling a complete proposal file.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'content', 'enterprise'],
|
||||
@@ -202,6 +211,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Competitive battle cards',
|
||||
prompt:
|
||||
'Create an agent that deep-researches each of my competitors using web search — their product features, pricing, positioning, strengths, and weaknesses — and generates a structured battle card document for each one that my sales team can reference during calls.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research', 'content'],
|
||||
@@ -211,6 +221,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'QBR prep agent',
|
||||
prompt:
|
||||
'Build a workflow that compiles everything needed for a quarterly business review — pulling customer usage data, support ticket history, billing summary, and key milestones from my tables — and generates a polished QBR document ready to present.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'files', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'support', 'reporting'],
|
||||
@@ -220,6 +231,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'CRM knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Salesforce account so all deals, contacts, notes, and activities are automatically synced and searchable. Then build an agent I can ask things like "what\'s the history with Acme Corp?" or "who was involved in the last enterprise deal?" and get instant answers with CRM record citations.',
|
||||
integrationBlockTypes: ['salesforce'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'research'],
|
||||
@@ -229,6 +241,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'HubSpot deal search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my HubSpot account so all deals, contacts, and activity history are automatically synced and searchable. Then build an agent I can ask things like "what happened with the Stripe integration deal?" or "which deals closed last quarter over $50k?" and get answers with HubSpot record links.',
|
||||
integrationBlockTypes: ['hubspot'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'research'],
|
||||
@@ -238,6 +251,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Lead enrichment pipeline',
|
||||
prompt:
|
||||
'Build a workflow that watches my leads table for new entries, enriches each lead with company size, funding, tech stack, and decision-maker contacts using Apollo and web search, then updates the table with the enriched information.',
|
||||
integrationBlockTypes: ['apollo'],
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'automation', 'research'],
|
||||
@@ -247,6 +261,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Prospect researcher',
|
||||
prompt:
|
||||
'Create an agent that takes a company name, deep-researches them across the web, finds key decision-makers, recent news, funding rounds, and pain points, then compiles a prospect brief I can review before outreach.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'research'],
|
||||
@@ -256,6 +271,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Outbound sequence builder',
|
||||
prompt:
|
||||
'Build a workflow that reads leads from my table, researches each prospect and their company on the web, writes a personalized cold email tailored to their role and pain points, and sends it via Gmail. Schedule it to run daily to process new leads automatically.',
|
||||
integrationBlockTypes: ['gmail'],
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'communication', 'automation'],
|
||||
@@ -265,6 +281,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Deal pipeline tracker',
|
||||
prompt:
|
||||
'Create a table with columns for deal name, stage, amount, close date, and next steps. Build a workflow that syncs open deals from Salesforce into this table daily, and sends me a Slack summary each morning of deals that need attention or are at risk of slipping.',
|
||||
integrationBlockTypes: ['salesforce', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'monitoring', 'reporting'],
|
||||
@@ -274,6 +291,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Win/loss analyzer',
|
||||
prompt:
|
||||
'Build a workflow that pulls closed deals from HubSpot each week, analyzes patterns in wins vs losses — deal size, industry, sales cycle length, objections — and generates a report file with actionable insights on what to change. Schedule it to run every Monday.',
|
||||
integrationBlockTypes: ['hubspot'],
|
||||
modules: ['agent', 'files', 'scheduled', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'analysis', 'reporting'],
|
||||
@@ -283,6 +301,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Sales call analyzer',
|
||||
prompt:
|
||||
'Build a workflow that pulls call transcripts from Gong after each sales call, identifies key objections raised, action items promised, and competitor mentions, updates the deal record in my CRM, and posts a call summary with next steps to the Slack deal channel.',
|
||||
integrationBlockTypes: ['gong', 'slack'],
|
||||
modules: ['agent', 'tables', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'analysis', 'communication'],
|
||||
@@ -292,6 +311,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Webflow lead capture pipeline',
|
||||
prompt:
|
||||
'Create a workflow that monitors new Webflow form submissions, enriches each lead with company and contact data using Apollo and web search, adds them to a tracking table with a lead score, and sends a Slack notification to the sales team for high-potential leads.',
|
||||
integrationBlockTypes: ['webflow', 'apollo', 'slack'],
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'sales',
|
||||
tags: ['sales', 'crm', 'automation'],
|
||||
@@ -303,6 +323,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Customer support bot',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to my Notion or Google Docs so it stays synced with my product documentation automatically. Then build an agent that answers customer questions using it with sourced citations and deploy it as a chat endpoint.',
|
||||
integrationBlockTypes: ['notion', 'google_docs'],
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'communication', 'automation'],
|
||||
@@ -312,6 +333,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Slack Q&A bot',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Notion workspace so it stays synced with my company wiki. Then build a workflow that monitors Slack channels for questions and answers them using the knowledge base with source citations.',
|
||||
integrationBlockTypes: ['notion', 'slack'],
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'communication', 'team'],
|
||||
@@ -321,6 +343,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Customer feedback analyzer',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls support tickets and conversations from Intercom daily, categorizes them by theme and sentiment, tracks trends in a table, and sends a weekly Slack report highlighting the top feature requests and pain points.',
|
||||
integrationBlockTypes: ['intercom', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'product', 'analysis', 'reporting'],
|
||||
@@ -330,6 +353,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Churn risk detector',
|
||||
prompt:
|
||||
'Create a workflow that monitors customer activity — support ticket frequency, response sentiment, usage patterns — scores each account for churn risk in a table, and triggers a Slack alert to the account team when a customer crosses the risk threshold.',
|
||||
integrationBlockTypes: ['slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['support', 'sales', 'monitoring', 'analysis'],
|
||||
@@ -339,6 +363,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Discord community manager',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Google Docs or Notion with product documentation. Then build a workflow that monitors my Discord server for unanswered questions, answers them using the knowledge base, tracks common questions in a table, and sends a weekly community summary to Slack.',
|
||||
integrationBlockTypes: ['discord', 'google_docs', 'notion', 'slack'],
|
||||
modules: ['knowledge-base', 'tables', 'agent', 'scheduled', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['community', 'support', 'communication'],
|
||||
@@ -348,6 +373,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Survey response analyzer',
|
||||
prompt:
|
||||
'Create a workflow that pulls new Typeform responses daily, categorizes feedback by theme and sentiment, logs structured results to a table, and sends a Slack digest when a new batch of responses comes in with the key takeaways.',
|
||||
integrationBlockTypes: ['typeform', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['product', 'analysis', 'reporting'],
|
||||
@@ -357,6 +383,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Email knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Gmail so all my emails are automatically synced, chunked, and searchable. Then build an agent I can ask things like "what did Sarah say about the pricing proposal?" or "find the contract John sent last month" and get instant answers with the original email cited.',
|
||||
integrationBlockTypes: ['gmail'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'support',
|
||||
tags: ['individual', 'research', 'communication'],
|
||||
@@ -366,6 +393,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Support ticket knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Zendesk account so all past tickets, resolutions, and agent notes are automatically synced and searchable. Then build an agent my support team can ask things like "how do we usually resolve the SSO login issue?" or "has anyone reported this billing bug before?" to find past solutions instantly.',
|
||||
integrationBlockTypes: ['zendesk'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'support',
|
||||
tags: ['support', 'research', 'team'],
|
||||
@@ -377,6 +405,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Feature spec writer',
|
||||
prompt:
|
||||
'Create an agent that takes a rough feature idea or user story, researches how similar features work in competing products, and writes a complete product requirements document with user stories, acceptance criteria, edge cases, and technical considerations.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['product', 'engineering', 'research', 'content'],
|
||||
@@ -386,6 +415,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Jira knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Jira project so all tickets, comments, and resolutions are automatically synced and searchable. Then build an agent I can ask things like "how did we fix the auth timeout issue?" or "what was decided about the API redesign?" and get answers with ticket citations.',
|
||||
integrationBlockTypes: ['jira'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'research'],
|
||||
@@ -395,6 +425,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Linear knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Linear workspace so all issues, comments, project updates, and decisions are automatically synced and searchable. Then build an agent I can ask things like "why did we deprioritize the mobile app?" or "what was the root cause of the checkout bug?" and get answers traced back to specific issues.',
|
||||
integrationBlockTypes: ['linear'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'research', 'product'],
|
||||
@@ -404,6 +435,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Bug triage agent',
|
||||
prompt:
|
||||
'Build an agent that monitors Sentry for new errors, automatically triages them by severity and affected users, creates Linear tickets for critical issues with full stack traces, and sends a Slack notification to the on-call channel.',
|
||||
integrationBlockTypes: ['sentry', 'linear', 'slack'],
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'devops', 'automation'],
|
||||
@@ -413,6 +445,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'PR review assistant',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my GitHub repo so it stays synced with my style guide and coding standards. Then build a workflow that reviews new pull requests against it, checks for common issues and security vulnerabilities, and posts a review comment with specific suggestions.',
|
||||
integrationBlockTypes: ['github'],
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'automation'],
|
||||
@@ -422,6 +455,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Changelog generator',
|
||||
prompt:
|
||||
'Build a scheduled workflow that runs every Friday, pulls all merged PRs from GitHub for the week, categorizes changes as features, fixes, or improvements, and generates a user-facing changelog document with clear descriptions.',
|
||||
integrationBlockTypes: ['github'],
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'product', 'reporting', 'content'],
|
||||
@@ -431,6 +465,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Incident postmortem writer',
|
||||
prompt:
|
||||
'Create a workflow that when triggered after an incident, pulls the Slack thread from the incident channel, gathers relevant Sentry errors and deployment logs, and drafts a structured postmortem with timeline, root cause, and action items.',
|
||||
integrationBlockTypes: ['slack', 'sentry'],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'devops', 'analysis'],
|
||||
@@ -440,6 +475,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Documentation auto-updater',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my GitHub repository so code and docs stay synced. Then build a scheduled weekly workflow that detects API changes, compares them against the knowledge base to find outdated documentation, and either updates Notion pages directly or creates Linear tickets for the needed changes.',
|
||||
integrationBlockTypes: ['github', 'notion', 'linear'],
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'sync', 'automation'],
|
||||
@@ -449,6 +485,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Incident response coordinator',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Confluence or Notion with runbooks and incident procedures. Then build a workflow triggered by PagerDuty incidents that searches the runbooks, gathers related Datadog alerts, identifies the on-call rotation, and posts a comprehensive incident brief to Slack.',
|
||||
integrationBlockTypes: ['confluence', 'notion', 'pagerduty', 'datadog', 'slack'],
|
||||
modules: ['knowledge-base', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['devops', 'engineering', 'automation'],
|
||||
@@ -458,6 +495,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Sprint report generator',
|
||||
prompt:
|
||||
'Create a scheduled workflow that runs at the end of each sprint, pulls all completed, in-progress, and blocked Jira tickets, calculates velocity and carry-over, and generates a sprint summary document with charts and trends to share with the team.',
|
||||
integrationBlockTypes: ['jira'],
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'reporting', 'team'],
|
||||
@@ -467,6 +505,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Knowledge base sync',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Confluence workspace so all wiki pages are automatically synced and searchable. Then build a scheduled workflow that identifies stale pages not updated in 90 days and sends a Slack reminder to page owners to review them.',
|
||||
integrationBlockTypes: ['confluence', 'slack'],
|
||||
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['engineering', 'sync', 'team'],
|
||||
@@ -478,6 +517,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Long-form content writer',
|
||||
prompt:
|
||||
'Build a workflow that takes a topic or brief, researches it deeply across the web, generates a detailed outline, then writes a full long-form article with sections, examples, and a conclusion. Save the final draft as a document for review.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['content', 'research', 'marketing'],
|
||||
@@ -487,6 +527,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Case study generator',
|
||||
prompt:
|
||||
'Create a knowledge base from my customer data and interview notes, then build a workflow that generates a polished case study file with the challenge, solution, results, and a pull quote — formatted and ready to publish.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'sales'],
|
||||
@@ -496,6 +537,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Social media content calendar',
|
||||
prompt:
|
||||
'Build a workflow that generates a full month of social media content for my brand. Research trending topics in my industry, create a table with post dates, platforms, copy drafts, and hashtags, then schedule a weekly refresh to keep the calendar filled with fresh ideas.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'agent', 'scheduled', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
@@ -505,6 +547,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Multi-language content translator',
|
||||
prompt:
|
||||
'Create a workflow that takes a document or blog post and translates it into multiple target languages while preserving tone, formatting, and brand voice. Save each translation as a separate file and flag sections that may need human review for cultural nuance.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['content', 'enterprise', 'automation'],
|
||||
@@ -514,6 +557,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Content repurposer',
|
||||
prompt:
|
||||
'Build a workflow that takes a YouTube video URL, pulls the video details and description, researches the topic on the web for additional context, and generates a Twitter thread, LinkedIn post, and blog summary optimized for each platform.',
|
||||
integrationBlockTypes: ['youtube'],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
@@ -523,6 +567,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Social mention tracker',
|
||||
prompt:
|
||||
'Create a scheduled workflow that monitors Reddit and X for mentions of my brand and competitors, scores each mention by sentiment and reach, logs them to a table, and sends a daily Slack digest of notable mentions.',
|
||||
integrationBlockTypes: ['reddit', 'x', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'monitoring', 'analysis'],
|
||||
@@ -532,6 +577,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'SEO content brief generator',
|
||||
prompt:
|
||||
'Build a workflow that takes a target keyword, scrapes the top 10 ranking pages, analyzes their content structure and subtopics, then generates a detailed content brief with outline, word count target, questions to answer, and internal linking suggestions.',
|
||||
integrationBlockTypes: ['firecrawl'],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'research'],
|
||||
@@ -541,6 +587,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Newsletter curator',
|
||||
prompt:
|
||||
'Create a scheduled weekly workflow that scrapes my favorite industry news sites and blogs, picks the top stories relevant to my audience, writes summaries for each, and drafts a ready-to-send newsletter in Mailchimp.',
|
||||
integrationBlockTypes: ['mailchimp'],
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'communication'],
|
||||
@@ -550,6 +597,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'LinkedIn content engine',
|
||||
prompt:
|
||||
'Build a workflow that scrapes my company blog for new posts, generates LinkedIn posts with hooks, insights, and calls-to-action optimized for engagement, and saves drafts as files for my review before posting to LinkedIn.',
|
||||
integrationBlockTypes: ['linkedin'],
|
||||
modules: ['agent', 'files', 'scheduled', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
@@ -559,6 +607,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Blog auto-publisher',
|
||||
prompt:
|
||||
'Build a workflow that takes a draft document, optimizes it for SEO by researching target keywords, formats it for WordPress with proper headings and meta description, and publishes it as a draft post for final review.',
|
||||
integrationBlockTypes: ['wordpress'],
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['marketing', 'content', 'automation'],
|
||||
@@ -570,6 +619,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Personal knowledge assistant',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to my Google Drive, Notion, or Obsidian so all my notes, docs, and articles are automatically synced and embedded. Then build an agent that I can ask anything — it should answer with citations and deploy as a chat endpoint.',
|
||||
integrationBlockTypes: ['google_drive', 'notion', 'obsidian'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'research', 'team'],
|
||||
@@ -579,6 +629,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Slack knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Slack workspace so all channel conversations and threads are automatically synced and searchable. Then build an agent I can ask things like "what did the team decide about the launch date?" or "what was the outcome of the design review?" and get answers with links to the original messages.',
|
||||
integrationBlockTypes: ['slack'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'research', 'communication'],
|
||||
@@ -588,6 +639,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Notion knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Notion workspace so all pages, databases, meeting notes, and wikis are automatically synced and searchable. Then build an agent I can ask things like "what\'s our refund policy?" or "what was decided in the Q3 planning doc?" and get instant answers with page links.',
|
||||
integrationBlockTypes: ['notion'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'research'],
|
||||
@@ -597,6 +649,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Google Drive knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base connected to my Google Drive so all documents, spreadsheets, and presentations are automatically synced and searchable. Then build an agent I can ask things like "find the board deck from last quarter" or "what were the KPIs in the marketing plan?" and get answers with doc links.',
|
||||
integrationBlockTypes: ['google_drive'],
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'team', 'research'],
|
||||
@@ -606,6 +659,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Document summarizer',
|
||||
prompt:
|
||||
'Create a workflow that takes any uploaded document — PDF, contract, report, research paper — and generates a structured summary with key takeaways, action items, important dates, and a one-paragraph executive overview.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['files', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'analysis', 'team'],
|
||||
@@ -615,6 +669,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Bulk data classifier',
|
||||
prompt:
|
||||
'Build a workflow that takes a table of unstructured data — support tickets, feedback, survey responses, leads, or any text — runs each row through an agent to classify, tag, score, and enrich it, then writes the structured results back to the table.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['analysis', 'automation', 'team'],
|
||||
@@ -624,6 +679,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Automated narrative report',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls key data from my tables every week, analyzes trends and anomalies, and writes a narrative report — not just charts and numbers, but written insights explaining what changed, why it matters, and what to do next. Save it as a document and send a summary to Slack.',
|
||||
integrationBlockTypes: ['slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'reporting', 'analysis'],
|
||||
@@ -633,6 +689,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Investor update writer',
|
||||
prompt:
|
||||
'Build a workflow that pulls key metrics from my tables — revenue, growth, burn rate, headcount, milestones — and drafts a concise investor update with highlights, lowlights, asks, and KPIs. Save it as a file I can review before sending. Schedule it to run on the first of each month.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'reporting', 'communication'],
|
||||
@@ -642,6 +699,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Email digest curator',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that searches the web for the latest articles, papers, and news on topics I care about, picks the top 5 most relevant pieces, writes a one-paragraph summary for each, and delivers a curated reading digest to my inbox or Slack.',
|
||||
integrationBlockTypes: ['slack'],
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'research', 'content'],
|
||||
@@ -651,6 +709,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Knowledge extractor',
|
||||
prompt:
|
||||
'Build a workflow that takes raw meeting notes, brainstorm dumps, or research transcripts, extracts the key insights, decisions, and facts, organizes them by topic, and saves them into my knowledge base so they are searchable and reusable in future conversations.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['files', 'knowledge-base', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'team', 'research'],
|
||||
@@ -660,6 +719,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Weekly team digest',
|
||||
prompt:
|
||||
"Build a scheduled workflow that runs every Friday, pulls the week's GitHub commits, closed Linear issues, and key Slack conversations, then emails a formatted weekly summary to the team.",
|
||||
integrationBlockTypes: ['github', 'linear', 'slack'],
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['engineering', 'team', 'reporting'],
|
||||
@@ -669,6 +729,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Daily standup summary',
|
||||
prompt:
|
||||
'Create a scheduled workflow that reads the #standup Slack channel each morning, summarizes what everyone is working on, identifies blockers, and posts a structured recap to a Google Doc.',
|
||||
integrationBlockTypes: ['slack', 'google_docs'],
|
||||
modules: ['scheduled', 'agent', 'files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'reporting', 'communication'],
|
||||
@@ -678,6 +739,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Email triage assistant',
|
||||
prompt:
|
||||
'Build a workflow that scans my Gmail inbox every hour, categorizes emails by urgency and type (action needed, FYI, follow-up), drafts replies for routine messages, and sends me a prioritized summary in Slack so I only open what matters. Schedule it to run hourly.',
|
||||
integrationBlockTypes: ['gmail', 'slack'],
|
||||
modules: ['agent', 'scheduled', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
@@ -687,6 +749,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Meeting notes to action items',
|
||||
prompt:
|
||||
'Create a workflow that takes meeting notes or a transcript, extracts action items with owners and due dates, creates tasks in Linear or Asana for each one, and posts a summary to the relevant Slack channel.',
|
||||
integrationBlockTypes: ['linear', 'asana', 'slack'],
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'automation'],
|
||||
@@ -696,6 +759,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Weekly metrics report',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls data from Stripe and my database every Monday, calculates key metrics like MRR, churn, new subscriptions, and failed payments, populates a Google Sheet, and Slacks the team a summary with week-over-week trends.',
|
||||
integrationBlockTypes: ['stripe', 'google_sheets', 'slack'],
|
||||
modules: ['scheduled', 'tables', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['founder', 'finance', 'reporting'],
|
||||
@@ -705,6 +769,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Product analytics digest',
|
||||
prompt:
|
||||
'Create a scheduled weekly workflow that pulls key product metrics from Amplitude — active users, feature adoption rates, retention cohorts, and top events — generates an executive summary with week-over-week trends, and posts it to Slack.',
|
||||
integrationBlockTypes: ['amplitude', 'slack'],
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['product', 'reporting', 'analysis'],
|
||||
@@ -714,6 +779,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Scheduling follow-up automator',
|
||||
prompt:
|
||||
'Build a workflow that monitors new Calendly bookings, researches each attendee and their company, prepares a pre-meeting brief with relevant context, and sends a personalized confirmation email with an agenda and any prep materials.',
|
||||
integrationBlockTypes: ['calendly'],
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['sales', 'research', 'automation'],
|
||||
@@ -723,6 +789,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'SMS appointment reminders',
|
||||
prompt:
|
||||
'Create a scheduled workflow that checks Google Calendar each morning for appointments in the next 24 hours, and sends an SMS reminder to each attendee via Twilio with the meeting time, location, and any prep notes.',
|
||||
integrationBlockTypes: ['google_calendar', 'twilio_sms'],
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['individual', 'communication', 'automation'],
|
||||
@@ -732,6 +799,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Microsoft Teams daily brief',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls updates from your project tools — GitHub commits, Jira ticket status changes, and calendar events — and posts a formatted daily brief to your Microsoft Teams channel each morning.',
|
||||
integrationBlockTypes: ['github', 'jira', 'microsoft_teams'],
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['team', 'reporting', 'enterprise'],
|
||||
@@ -743,6 +811,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Data cleanup agent',
|
||||
prompt:
|
||||
'Create a workflow that takes a messy table — inconsistent formatting, duplicates, missing fields, typos — and cleans it up by standardizing values, merging duplicates, filling gaps where possible, and flagging rows that need human review.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['automation', 'analysis'],
|
||||
@@ -752,6 +821,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Training material generator',
|
||||
prompt:
|
||||
'Create a knowledge base from my product documentation, then build a workflow that generates training materials from it — onboarding guides, FAQ documents, step-by-step tutorials, and quiz questions. Schedule it to regenerate weekly so materials stay current as docs change.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'agent', 'scheduled'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'content', 'team', 'automation'],
|
||||
@@ -761,6 +831,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'SOP generator',
|
||||
prompt:
|
||||
'Create an agent that takes a brief description of any business process — from employee onboarding to incident response to content publishing — and generates a detailed standard operating procedure document with numbered steps, responsible roles, decision points, and checklists.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['files', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['team', 'enterprise', 'content'],
|
||||
@@ -770,6 +841,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Invoice processor',
|
||||
prompt:
|
||||
'Build a workflow that processes invoice PDFs from Gmail, extracts vendor name, amount, due date, and line items, then logs everything to a tracking table and sends a Slack alert for invoices due within 7 days.',
|
||||
integrationBlockTypes: ['gmail', 'slack'],
|
||||
modules: ['files', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['finance', 'automation'],
|
||||
@@ -779,6 +851,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Contract analyzer',
|
||||
prompt:
|
||||
'Create a knowledge base from my standard contract terms, then build a workflow that reviews uploaded contracts against it — extracting key clauses like payment terms, liability caps, and termination conditions, flagging deviations, and outputting a summary to a table.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'tables', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['legal', 'analysis'],
|
||||
@@ -788,6 +861,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Competitive intel monitor',
|
||||
prompt:
|
||||
'Build a scheduled workflow that scrapes competitor websites, pricing pages, and changelog pages weekly using Firecrawl, compares against previous snapshots, summarizes any changes, logs them to a tracking table, and sends a Slack alert for major updates.',
|
||||
integrationBlockTypes: ['firecrawl', 'slack'],
|
||||
modules: ['scheduled', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['founder', 'product', 'monitoring', 'research'],
|
||||
@@ -797,6 +871,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Revenue operations dashboard',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that pulls payment data from Stripe, calculates MRR, net revenue, failed payments, and new subscriptions, logs everything to a table with historical tracking, and sends a daily Slack summary with trends and anomalies.',
|
||||
integrationBlockTypes: ['stripe', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['finance', 'founder', 'reporting', 'monitoring'],
|
||||
@@ -806,6 +881,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'E-commerce order monitor',
|
||||
prompt:
|
||||
'Build a workflow that monitors Shopify orders, flags high-value or unusual orders for review, tracks fulfillment status in a table, and sends daily inventory and sales summaries to Slack with restock alerts when items run low.',
|
||||
integrationBlockTypes: ['shopify', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['ecommerce', 'monitoring', 'reporting'],
|
||||
@@ -815,6 +891,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Compliance document checker',
|
||||
prompt:
|
||||
'Create a knowledge base from my compliance requirements and policies, then build an agent that reviews uploaded policy documents and SOC 2 evidence against it, identifies gaps or outdated sections, and generates a remediation checklist file with priority levels.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['legal', 'enterprise', 'analysis'],
|
||||
@@ -824,6 +901,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'New hire onboarding automation',
|
||||
prompt:
|
||||
"Build a workflow that when triggered with a new hire's info, creates their accounts, sends a personalized welcome message in Slack, schedules 1:1s with their team on Google Calendar, shares relevant onboarding docs from the knowledge base, and tracks completion in a table.",
|
||||
integrationBlockTypes: ['slack', 'google_calendar'],
|
||||
modules: ['knowledge-base', 'tables', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'automation', 'team'],
|
||||
@@ -833,6 +911,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Candidate screening assistant',
|
||||
prompt:
|
||||
'Create a knowledge base from my job descriptions and hiring criteria, then build a workflow that takes uploaded resumes, evaluates candidates against the requirements, scores them on experience, skills, and culture fit, and populates a comparison table with a summary and recommendation for each.',
|
||||
integrationBlockTypes: [],
|
||||
modules: ['knowledge-base', 'files', 'tables', 'agent'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'recruiting', 'analysis'],
|
||||
@@ -842,6 +921,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Recruiting pipeline automator',
|
||||
prompt:
|
||||
'Build a scheduled workflow that syncs open jobs and candidates from Greenhouse to a tracking table daily, flags candidates who have been in the same stage for more than 5 days, and sends a Slack summary to hiring managers with pipeline stats and bottlenecks.',
|
||||
integrationBlockTypes: ['greenhouse', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['hr', 'recruiting', 'monitoring', 'reporting'],
|
||||
@@ -851,6 +931,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Infrastructure health report',
|
||||
prompt:
|
||||
'Create a scheduled daily workflow that queries Datadog for key infrastructure metrics — error rates, latency percentiles, CPU and memory usage — logs them to a table for trend tracking, and sends a morning Slack report highlighting any anomalies or degradations.',
|
||||
integrationBlockTypes: ['datadog', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['devops', 'infrastructure', 'monitoring', 'reporting'],
|
||||
@@ -860,6 +941,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Airtable data sync',
|
||||
prompt:
|
||||
'Create a scheduled workflow that syncs records from my Airtable base into a Sim table every hour, keeping both in sync. Use an agent to detect changes, resolve conflicts, and flag any discrepancies for review in Slack.',
|
||||
integrationBlockTypes: ['airtable', 'slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['sync', 'automation'],
|
||||
@@ -869,6 +951,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Multi-source knowledge hub',
|
||||
prompt:
|
||||
'Create a knowledge base and connect it to Confluence, Notion, and Google Drive so all my company documentation is automatically synced, chunked, and embedded. Then deploy a Q&A agent that can answer questions across all sources with citations.',
|
||||
integrationBlockTypes: ['confluence', 'notion', 'google_drive'],
|
||||
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['enterprise', 'team', 'sync', 'automation'],
|
||||
@@ -878,6 +961,7 @@ export const TEMPLATES: TemplatePrompt[] = [
|
||||
title: 'Customer 360 view',
|
||||
prompt:
|
||||
'Create a comprehensive customer table that aggregates data from my CRM, support tickets, billing history, and product usage into a single unified view per customer. Schedule it to sync daily and send a Slack alert when any customer shows signs of trouble across multiple signals.',
|
||||
integrationBlockTypes: ['slack'],
|
||||
modules: ['tables', 'scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['founder', 'sales', 'support', 'enterprise', 'sync'],
|
||||
|
||||
@@ -16,7 +16,6 @@ import { persistImportedWorkflow } from '@/lib/workflows/operations/import-expor
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import {
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
@@ -167,8 +166,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
const handleResourceEvent = useCallback(() => {
|
||||
if (isResourceCollapsedRef.current) {
|
||||
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
|
||||
if (!isCollapsed) toggleCollapsed()
|
||||
setIsResourceCollapsed(false)
|
||||
startAnimatingIn()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -75,15 +75,25 @@ export function AddDocumentsModal({
|
||||
}
|
||||
}, [open, clearError])
|
||||
|
||||
/** Handles close with upload guard */
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
if (isUploading) return
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
},
|
||||
[isUploading, clearError, onOpenChange]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
if (isUploading) return
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
onOpenChange(false)
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
@@ -220,7 +230,7 @@ export function AddDocumentsModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>New Documents</ModalHeader>
|
||||
|
||||
|
||||
@@ -494,13 +494,12 @@ export function CredentialsManager() {
|
||||
}, [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceEnvData) {
|
||||
if (hasSavedRef.current) {
|
||||
hasSavedRef.current = false
|
||||
} else {
|
||||
setWorkspaceVars(workspaceEnvData?.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
|
||||
}
|
||||
if (!workspaceEnvData) return
|
||||
if (hasSavedRef.current) {
|
||||
hasSavedRef.current = false
|
||||
} else {
|
||||
setWorkspaceVars(workspaceEnvData.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {}
|
||||
}
|
||||
}, [workspaceEnvData])
|
||||
|
||||
|
||||
@@ -89,21 +89,25 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset all state when modal opens/closes
|
||||
* Reset all form and UI state to prepare for a fresh modal session
|
||||
*/
|
||||
const resetModalState = useCallback(() => {
|
||||
setSubmitStatus(null)
|
||||
setImages([])
|
||||
setIsDragging(false)
|
||||
setIsProcessing(false)
|
||||
reset({
|
||||
subject: '',
|
||||
message: '',
|
||||
type: DEFAULT_REQUEST_TYPE,
|
||||
})
|
||||
}, [reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setImages([])
|
||||
setIsDragging(false)
|
||||
setIsProcessing(false)
|
||||
reset({
|
||||
subject: '',
|
||||
message: '',
|
||||
type: DEFAULT_REQUEST_TYPE,
|
||||
})
|
||||
resetModalState()
|
||||
}
|
||||
}, [open, reset])
|
||||
}, [open, resetModalState])
|
||||
|
||||
/**
|
||||
* Fix z-index for popover/dropdown when inside modal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -39,29 +39,27 @@ function ColorGrid({
|
||||
hexInput,
|
||||
setHexInput,
|
||||
onColorChange,
|
||||
isOpen,
|
||||
buttonRefs,
|
||||
}: {
|
||||
hexInput: string
|
||||
setHexInput: (color: string) => void
|
||||
onColorChange?: (color: string) => void
|
||||
isOpen: boolean
|
||||
buttonRefs: RefObject<(HTMLButtonElement | null)[]>
|
||||
}) {
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && gridRef.current) {
|
||||
const selectedIndex = WORKFLOW_COLORS.findIndex(
|
||||
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
|
||||
)
|
||||
const initialIndex = selectedIndex >= 0 ? selectedIndex : 0
|
||||
setFocusedIndex(initialIndex)
|
||||
setTimeout(() => {
|
||||
buttonRefs.current[initialIndex]?.focus()
|
||||
}, 50)
|
||||
}
|
||||
}, [isOpen, hexInput])
|
||||
const selectedIndex = WORKFLOW_COLORS.findIndex(
|
||||
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
|
||||
)
|
||||
const idx = selectedIndex >= 0 ? selectedIndex : 0
|
||||
setFocusedIndex(idx)
|
||||
requestAnimationFrame(() => {
|
||||
buttonRefs.current[idx]?.focus()
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, index: number) => {
|
||||
@@ -176,10 +174,10 @@ function ColorPickerSubmenu({
|
||||
handleHexFocus: (e: React.FocusEvent<HTMLInputElement>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [isSubOpen, setIsSubOpen] = useState(false)
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
return (
|
||||
<DropdownMenuSub open={isSubOpen} onOpenChange={setIsSubOpen}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={disabled ? 'pointer-events-none opacity-50' : ''}>
|
||||
<Palette />
|
||||
Change color
|
||||
@@ -190,7 +188,7 @@ function ColorPickerSubmenu({
|
||||
hexInput={hexInput}
|
||||
setHexInput={setHexInput}
|
||||
onColorChange={onColorChange}
|
||||
isOpen={isSubOpen}
|
||||
buttonRefs={buttonRefs}
|
||||
/>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<div
|
||||
@@ -375,6 +373,7 @@ export function ContextMenu({
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -33,29 +33,31 @@ export function CreateWorkspaceModal({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('')
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || isCreating) return
|
||||
await onConfirm(trimmed)
|
||||
}, [name, isCreating, onConfirm])
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent
|
||||
size='sm'
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<ModalHeader>Create Workspace</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { Badge, Button, Skeleton, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -64,13 +64,18 @@ export const PermissionsTable = ({
|
||||
}: PermissionsTableProps) => {
|
||||
const { data: session } = useSession()
|
||||
const userPerms = useUserPermissionsContext()
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
const hasLoadedOnceRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionsLoading && !userPerms.isLoading && !isPendingInvitationsLoading) {
|
||||
setHasLoadedOnce(true)
|
||||
}
|
||||
}, [permissionsLoading, userPerms.isLoading, isPendingInvitationsLoading])
|
||||
if (
|
||||
!hasLoadedOnceRef.current &&
|
||||
!permissionsLoading &&
|
||||
!userPerms.isLoading &&
|
||||
!isPendingInvitationsLoading
|
||||
) {
|
||||
hasLoadedOnceRef.current = true
|
||||
}
|
||||
|
||||
const hasLoadedOnce = hasLoadedOnceRef.current
|
||||
|
||||
const existingUsers: UserPermissions[] = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -591,10 +591,15 @@ export const Sidebar = memo(function Sidebar() {
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => navigateToSettings(),
|
||||
onClick: () => {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
navigateToSettings()
|
||||
},
|
||||
},
|
||||
],
|
||||
[workspaceId, navigateToSettings]
|
||||
[workspaceId, navigateToSettings, isCollapsed, setSidebarWidth]
|
||||
)
|
||||
|
||||
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
|
||||
@@ -636,6 +641,16 @@ export const Sidebar = memo(function Sidebar() {
|
||||
setIsTaskDeleteModalOpen(true)
|
||||
}, [tasks])
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
(path: string) => {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
router.push(path)
|
||||
},
|
||||
[isCollapsed, setSidebarWidth, router]
|
||||
)
|
||||
|
||||
const handleConfirmDeleteTasks = useCallback(() => {
|
||||
const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current
|
||||
if (taskIdsToDelete.length === 0) return
|
||||
@@ -648,7 +663,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const onDeleteSuccess = () => {
|
||||
useFolderStore.getState().clearTaskSelection()
|
||||
if (isViewingDeletedTask) {
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
navigateToPage(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +673,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess })
|
||||
}
|
||||
setIsTaskDeleteModalOpen(false)
|
||||
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, router])
|
||||
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage])
|
||||
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
|
||||
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
|
||||
@@ -910,7 +925,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
try {
|
||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/templates`)
|
||||
navigateToPage(`/workspace/${pathWorkspaceId}/templates`)
|
||||
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates')
|
||||
@@ -926,7 +941,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
try {
|
||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/logs`)
|
||||
navigateToPage(`/workspace/${pathWorkspaceId}/logs`)
|
||||
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to logs')
|
||||
@@ -1113,7 +1128,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
@@ -1131,7 +1146,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
hover={tasksHover}
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-[6px]'
|
||||
>
|
||||
|
||||
231
apps/sim/blocks/blocks/infisical.ts
Normal file
231
apps/sim/blocks/blocks/infisical.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { InfisicalIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { InfisicalResponse } from '@/tools/infisical/types'
|
||||
|
||||
export const InfisicalBlock: BlockConfig<InfisicalResponse> = {
|
||||
type: 'infisical',
|
||||
name: 'Infisical',
|
||||
description: 'Manage secrets with Infisical',
|
||||
longDescription:
|
||||
'Integrate Infisical into your workflow. List, get, create, update, and delete secrets across project environments.',
|
||||
docsLink: 'https://docs.sim.ai/tools/infisical',
|
||||
category: 'tools',
|
||||
bgColor: '#F7FE62',
|
||||
icon: InfisicalIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Secrets', id: 'list_secrets' },
|
||||
{ label: 'Get Secret', id: 'get_secret' },
|
||||
{ label: 'Create Secret', id: 'create_secret' },
|
||||
{ label: 'Update Secret', id: 'update_secret' },
|
||||
{ label: 'Delete Secret', id: 'delete_secret' },
|
||||
],
|
||||
value: () => 'list_secrets',
|
||||
},
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter project ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'environment',
|
||||
title: 'Environment',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., dev, staging, prod',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'secretName',
|
||||
title: 'Secret Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter secret name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_secret', 'create_secret', 'update_secret', 'delete_secret'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['get_secret', 'create_secret', 'update_secret', 'delete_secret'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'secretValue',
|
||||
title: 'Secret Value',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter secret value',
|
||||
password: true,
|
||||
condition: { field: 'operation', value: 'create_secret' },
|
||||
required: { field: 'operation', value: 'create_secret' },
|
||||
},
|
||||
{
|
||||
id: 'updateSecretValue',
|
||||
title: 'Secret Value',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter new secret value',
|
||||
password: true,
|
||||
condition: { field: 'operation', value: 'update_secret' },
|
||||
},
|
||||
{
|
||||
id: 'secretComment',
|
||||
title: 'Comment',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional comment',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['create_secret', 'update_secret'] },
|
||||
},
|
||||
{
|
||||
id: 'newSecretName',
|
||||
title: 'New Secret Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Rename secret to...',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'update_secret' },
|
||||
},
|
||||
{
|
||||
id: 'baseUrl',
|
||||
title: 'Instance URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://us.infisical.com (default)',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'secretPath',
|
||||
title: 'Secret Path',
|
||||
type: 'short-input',
|
||||
placeholder: '/ (default)',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'recursive',
|
||||
title: 'Recursive',
|
||||
type: 'switch',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_secrets' },
|
||||
},
|
||||
{
|
||||
id: 'includeImports',
|
||||
title: 'Include Imports',
|
||||
type: 'switch',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_secrets' },
|
||||
},
|
||||
{
|
||||
id: 'tagSlugs',
|
||||
title: 'Filter by Tags',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated tag slugs',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_secrets' },
|
||||
},
|
||||
{
|
||||
id: 'tagIds',
|
||||
title: 'Tag IDs',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated tag IDs',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['create_secret', 'update_secret'] },
|
||||
},
|
||||
{
|
||||
id: 'secretVersion',
|
||||
title: 'Version',
|
||||
type: 'short-input',
|
||||
placeholder: 'Specific version number',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'get_secret' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Infisical API token',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'infisical_list_secrets',
|
||||
'infisical_get_secret',
|
||||
'infisical_create_secret',
|
||||
'infisical_update_secret',
|
||||
'infisical_delete_secret',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `infisical_${params.operation}`,
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {
|
||||
apiKey: params.apiKey,
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
}
|
||||
|
||||
if (params.baseUrl) result.baseUrl = params.baseUrl
|
||||
if (params.secretPath) result.secretPath = params.secretPath
|
||||
|
||||
switch (params.operation) {
|
||||
case 'list_secrets':
|
||||
if (params.recursive != null) result.recursive = params.recursive
|
||||
if (params.includeImports != null) result.includeImports = params.includeImports
|
||||
if (params.tagSlugs) result.tagSlugs = params.tagSlugs
|
||||
break
|
||||
case 'get_secret':
|
||||
result.secretName = params.secretName
|
||||
if (params.secretVersion) {
|
||||
const v = Number(params.secretVersion)
|
||||
if (!Number.isNaN(v)) result.version = v
|
||||
}
|
||||
break
|
||||
case 'create_secret':
|
||||
result.secretName = params.secretName
|
||||
result.secretValue = params.secretValue
|
||||
if (params.secretComment) result.secretComment = params.secretComment
|
||||
if (params.tagIds) result.tagIds = params.tagIds
|
||||
break
|
||||
case 'update_secret':
|
||||
result.secretName = params.secretName
|
||||
if (params.updateSecretValue) result.secretValue = params.updateSecretValue
|
||||
if (params.secretComment) result.secretComment = params.secretComment
|
||||
if (params.newSecretName) result.newSecretName = params.newSecretName
|
||||
if (params.tagIds) result.tagIds = params.tagIds
|
||||
break
|
||||
case 'delete_secret':
|
||||
result.secretName = params.secretName
|
||||
break
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
apiKey: { type: 'string', description: 'Infisical API token' },
|
||||
baseUrl: { type: 'string', description: 'Infisical instance URL' },
|
||||
projectId: { type: 'string', description: 'Project ID' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
secretName: { type: 'string', description: 'Secret name' },
|
||||
secretValue: { type: 'string', description: 'Secret value' },
|
||||
updateSecretValue: { type: 'string', description: 'New secret value for update' },
|
||||
secretComment: { type: 'string', description: 'Secret comment' },
|
||||
newSecretName: { type: 'string', description: 'New name for secret rename' },
|
||||
secretPath: { type: 'string', description: 'Secret path' },
|
||||
recursive: { type: 'boolean', description: 'Fetch secrets recursively' },
|
||||
includeImports: { type: 'boolean', description: 'Include imported secrets' },
|
||||
tagSlugs: { type: 'string', description: 'Comma-separated tag slugs to filter by' },
|
||||
tagIds: { type: 'string', description: 'Comma-separated tag IDs to attach' },
|
||||
secretVersion: { type: 'string', description: 'Specific secret version to retrieve' },
|
||||
},
|
||||
outputs: {
|
||||
secrets: { type: 'json', description: 'Array of secrets (list operation)' },
|
||||
count: { type: 'number', description: 'Number of secrets returned' },
|
||||
secret: { type: 'json', description: 'Secret object (get/create/update/delete operations)' },
|
||||
},
|
||||
}
|
||||
395
apps/sim/blocks/blocks/microsoft_ad.ts
Normal file
395
apps/sim/blocks/blocks/microsoft_ad.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { AzureIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { MicrosoftAdResponse } from '@/tools/microsoft_ad/types'
|
||||
|
||||
export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
type: 'microsoft_ad',
|
||||
name: 'Azure AD',
|
||||
description: 'Manage users and groups in Azure AD (Microsoft Entra ID)',
|
||||
longDescription:
|
||||
'Integrate Azure Active Directory into your workflows. List, create, update, and delete users and groups. Manage group memberships programmatically.',
|
||||
docsLink: 'https://docs.sim.ai/tools/microsoft_ad',
|
||||
category: 'tools',
|
||||
bgColor: '#0078D4',
|
||||
icon: AzureIcon,
|
||||
authMode: AuthMode.OAuth,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Users', id: 'list_users' },
|
||||
{ label: 'Get User', id: 'get_user' },
|
||||
{ label: 'Create User', id: 'create_user' },
|
||||
{ label: 'Update User', id: 'update_user' },
|
||||
{ label: 'Delete User', id: 'delete_user' },
|
||||
{ label: 'List Groups', id: 'list_groups' },
|
||||
{ label: 'Get Group', id: 'get_group' },
|
||||
{ label: 'Create Group', id: 'create_group' },
|
||||
{ label: 'Update Group', id: 'update_group' },
|
||||
{ label: 'Delete Group', id: 'delete_group' },
|
||||
{ label: 'List Group Members', id: 'list_group_members' },
|
||||
{ label: 'Add Group Member', id: 'add_group_member' },
|
||||
{ label: 'Remove Group Member', id: 'remove_group_member' },
|
||||
],
|
||||
value: () => 'list_users',
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Microsoft Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'microsoft-ad',
|
||||
requiredScopes: getScopesForService('microsoft-ad'),
|
||||
required: true,
|
||||
},
|
||||
// User ID field (for get, update, delete user)
|
||||
{
|
||||
id: 'userId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'User ID or user principal name (e.g., user@example.com)',
|
||||
condition: { field: 'operation', value: ['get_user', 'update_user', 'delete_user'] },
|
||||
required: { field: 'operation', value: ['get_user', 'update_user', 'delete_user'] },
|
||||
},
|
||||
// Create user fields
|
||||
{
|
||||
id: 'displayName',
|
||||
title: 'Display Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., John Doe',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
required: { field: 'operation', value: 'create_user' },
|
||||
},
|
||||
{
|
||||
id: 'mailNickname',
|
||||
title: 'Mail Nickname',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., johndoe',
|
||||
condition: { field: 'operation', value: 'create_user' },
|
||||
required: { field: 'operation', value: 'create_user' },
|
||||
},
|
||||
{
|
||||
id: 'userPrincipalName',
|
||||
title: 'User Principal Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., johndoe@example.com',
|
||||
condition: { field: 'operation', value: 'create_user' },
|
||||
required: { field: 'operation', value: 'create_user' },
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Password',
|
||||
type: 'short-input',
|
||||
placeholder: 'Initial password',
|
||||
condition: { field: 'operation', value: 'create_user' },
|
||||
required: { field: 'operation', value: 'create_user' },
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
id: 'accountEnabled',
|
||||
title: 'Account Enabled',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No Change', id: '' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'update_user' },
|
||||
},
|
||||
{
|
||||
id: 'accountEnabledCreate',
|
||||
title: 'Account Enabled',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
condition: { field: 'operation', value: 'create_user' },
|
||||
},
|
||||
// Update user optional fields
|
||||
{
|
||||
id: 'givenName',
|
||||
title: 'First Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., John',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'surname',
|
||||
title: 'Last Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., Doe',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'jobTitle',
|
||||
title: 'Job Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., Software Engineer',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'department',
|
||||
title: 'Department',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., Engineering',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'officeLocation',
|
||||
title: 'Office Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., Building A, Room 101',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'mobilePhone',
|
||||
title: 'Mobile Phone',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., +1-555-555-5555',
|
||||
condition: { field: 'operation', value: ['create_user', 'update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// List users/groups optional filters
|
||||
{
|
||||
id: 'top',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 100 (max 999)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_users', 'list_groups', 'list_group_members'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
title: 'Filter',
|
||||
type: 'short-input',
|
||||
placeholder: "e.g., department eq 'Sales'",
|
||||
condition: { field: 'operation', value: ['list_users', 'list_groups'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: 'Search',
|
||||
type: 'short-input',
|
||||
placeholder: 'Search by name or email',
|
||||
condition: { field: 'operation', value: ['list_users', 'list_groups'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Group ID field
|
||||
{
|
||||
id: 'groupId',
|
||||
title: 'Group ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Group ID (GUID)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'get_group',
|
||||
'update_group',
|
||||
'delete_group',
|
||||
'list_group_members',
|
||||
'add_group_member',
|
||||
'remove_group_member',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'get_group',
|
||||
'update_group',
|
||||
'delete_group',
|
||||
'list_group_members',
|
||||
'add_group_member',
|
||||
'remove_group_member',
|
||||
],
|
||||
},
|
||||
},
|
||||
// Create group fields
|
||||
{
|
||||
id: 'groupDisplayName',
|
||||
title: 'Display Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., Engineering Team',
|
||||
condition: { field: 'operation', value: ['create_group', 'update_group'] },
|
||||
required: { field: 'operation', value: 'create_group' },
|
||||
},
|
||||
{
|
||||
id: 'groupMailNickname',
|
||||
title: 'Mail Nickname',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., engineering-team',
|
||||
condition: { field: 'operation', value: ['create_group', 'update_group'] },
|
||||
required: { field: 'operation', value: 'create_group' },
|
||||
},
|
||||
{
|
||||
id: 'groupDescription',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Group description',
|
||||
condition: { field: 'operation', value: ['create_group', 'update_group'] },
|
||||
},
|
||||
{
|
||||
id: 'mailEnabled',
|
||||
title: 'Mail Enabled',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'create_group' },
|
||||
},
|
||||
{
|
||||
id: 'securityEnabled',
|
||||
title: 'Security Enabled',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
condition: { field: 'operation', value: 'create_group' },
|
||||
},
|
||||
{
|
||||
id: 'groupTypes',
|
||||
title: 'Group Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Security Group', id: '' },
|
||||
{ label: 'Microsoft 365 Group', id: 'Unified' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'create_group' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'visibility',
|
||||
title: 'Visibility',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No Change', id: '' },
|
||||
{ label: 'Private', id: 'Private' },
|
||||
{ label: 'Public', id: 'Public' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'update_group' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'visibilityCreate',
|
||||
title: 'Visibility',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Private', id: 'Private' },
|
||||
{ label: 'Public', id: 'Public' },
|
||||
],
|
||||
value: () => 'Private',
|
||||
condition: { field: 'operation', value: 'create_group' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Member ID (for add/remove member)
|
||||
{
|
||||
id: 'memberId',
|
||||
title: 'Member ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'User ID to add or remove',
|
||||
condition: { field: 'operation', value: ['add_group_member', 'remove_group_member'] },
|
||||
required: { field: 'operation', value: ['add_group_member', 'remove_group_member'] },
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'microsoft_ad_list_users',
|
||||
'microsoft_ad_get_user',
|
||||
'microsoft_ad_create_user',
|
||||
'microsoft_ad_update_user',
|
||||
'microsoft_ad_delete_user',
|
||||
'microsoft_ad_list_groups',
|
||||
'microsoft_ad_get_group',
|
||||
'microsoft_ad_create_group',
|
||||
'microsoft_ad_update_group',
|
||||
'microsoft_ad_delete_group',
|
||||
'microsoft_ad_list_group_members',
|
||||
'microsoft_ad_add_group_member',
|
||||
'microsoft_ad_remove_group_member',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `microsoft_ad_${params.operation}`,
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
if (params.top) result.top = Number(params.top)
|
||||
if (params.filter) result.filter = params.filter
|
||||
if (params.search) result.search = params.search
|
||||
if (params.operation === 'update_user') {
|
||||
if (params.accountEnabled) result.accountEnabled = params.accountEnabled === 'true'
|
||||
} else if (params.operation === 'create_user') {
|
||||
if (params.accountEnabledCreate)
|
||||
result.accountEnabled = params.accountEnabledCreate === 'true'
|
||||
}
|
||||
if (params.mailEnabled !== undefined) result.mailEnabled = params.mailEnabled === 'true'
|
||||
if (params.securityEnabled !== undefined)
|
||||
result.securityEnabled = params.securityEnabled === 'true'
|
||||
// Map group-specific fields to tool param names
|
||||
if (params.groupDisplayName) result.displayName = params.groupDisplayName
|
||||
if (params.groupMailNickname) result.mailNickname = params.groupMailNickname
|
||||
if (params.groupDescription) result.description = params.groupDescription
|
||||
if (params.groupTypes !== undefined) result.groupTypes = params.groupTypes
|
||||
if (params.operation === 'update_group') {
|
||||
if (params.visibility) result.visibility = params.visibility
|
||||
} else if (params.operation === 'create_group') {
|
||||
if (params.visibilityCreate) result.visibility = params.visibilityCreate
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
mailNickname: { type: 'string' },
|
||||
userPrincipalName: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
accountEnabled: { type: 'string' },
|
||||
accountEnabledCreate: { type: 'string' },
|
||||
givenName: { type: 'string' },
|
||||
surname: { type: 'string' },
|
||||
jobTitle: { type: 'string' },
|
||||
department: { type: 'string' },
|
||||
officeLocation: { type: 'string' },
|
||||
mobilePhone: { type: 'string' },
|
||||
top: { type: 'string' },
|
||||
filter: { type: 'string' },
|
||||
search: { type: 'string' },
|
||||
groupId: { type: 'string' },
|
||||
groupDisplayName: { type: 'string' },
|
||||
groupMailNickname: { type: 'string' },
|
||||
groupDescription: { type: 'string' },
|
||||
mailEnabled: { type: 'string' },
|
||||
securityEnabled: { type: 'string' },
|
||||
groupTypes: { type: 'string' },
|
||||
visibility: { type: 'string' },
|
||||
visibilityCreate: { type: 'string' },
|
||||
memberId: { type: 'string' },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType.',
|
||||
},
|
||||
},
|
||||
}
|
||||
381
apps/sim/blocks/blocks/okta.ts
Normal file
381
apps/sim/blocks/blocks/okta.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { OktaIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { OktaResponse } from '@/tools/okta/types'
|
||||
|
||||
export const OktaBlock: BlockConfig<OktaResponse> = {
|
||||
type: 'okta',
|
||||
name: 'Okta',
|
||||
description: 'Manage users and groups in Okta',
|
||||
longDescription:
|
||||
'Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.',
|
||||
docsLink: 'https://docs.sim.ai/tools/okta',
|
||||
category: 'tools',
|
||||
bgColor: '#191919',
|
||||
icon: OktaIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Users', id: 'okta_list_users' },
|
||||
{ label: 'Get User', id: 'okta_get_user' },
|
||||
{ label: 'Create User', id: 'okta_create_user' },
|
||||
{ label: 'Update User', id: 'okta_update_user' },
|
||||
{ label: 'Activate User', id: 'okta_activate_user' },
|
||||
{ label: 'Deactivate User', id: 'okta_deactivate_user' },
|
||||
{ label: 'Suspend User', id: 'okta_suspend_user' },
|
||||
{ label: 'Unsuspend User', id: 'okta_unsuspend_user' },
|
||||
{ label: 'Reset Password', id: 'okta_reset_password' },
|
||||
{ label: 'Delete User', id: 'okta_delete_user' },
|
||||
{ label: 'List Groups', id: 'okta_list_groups' },
|
||||
{ label: 'Get Group', id: 'okta_get_group' },
|
||||
{ label: 'Create Group', id: 'okta_create_group' },
|
||||
{ label: 'Update Group', id: 'okta_update_group' },
|
||||
{ label: 'Delete Group', id: 'okta_delete_group' },
|
||||
{ label: 'Add User to Group', id: 'okta_add_user_to_group' },
|
||||
{ label: 'Remove User from Group', id: 'okta_remove_user_from_group' },
|
||||
{ label: 'List Group Members', id: 'okta_list_group_members' },
|
||||
],
|
||||
value: () => 'okta_list_users',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Token',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Enter your Okta API token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Okta Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'dev-123456.okta.com',
|
||||
required: true,
|
||||
},
|
||||
// Search/Filter params (list operations)
|
||||
{
|
||||
id: 'search',
|
||||
title: 'Search',
|
||||
type: 'short-input',
|
||||
placeholder: 'profile.firstName eq "John"',
|
||||
condition: { field: 'operation', value: ['okta_list_users', 'okta_list_groups'] },
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
title: 'Filter',
|
||||
type: 'short-input',
|
||||
placeholder: 'status eq "ACTIVE"',
|
||||
condition: { field: 'operation', value: ['okta_list_users', 'okta_list_groups'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// User ID (shared across user operations that need it)
|
||||
{
|
||||
id: 'userId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'User ID or login (email)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'okta_get_user',
|
||||
'okta_update_user',
|
||||
'okta_activate_user',
|
||||
'okta_deactivate_user',
|
||||
'okta_suspend_user',
|
||||
'okta_unsuspend_user',
|
||||
'okta_reset_password',
|
||||
'okta_delete_user',
|
||||
'okta_add_user_to_group',
|
||||
'okta_remove_user_from_group',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'okta_get_user',
|
||||
'okta_update_user',
|
||||
'okta_activate_user',
|
||||
'okta_deactivate_user',
|
||||
'okta_suspend_user',
|
||||
'okta_unsuspend_user',
|
||||
'okta_reset_password',
|
||||
'okta_delete_user',
|
||||
'okta_add_user_to_group',
|
||||
'okta_remove_user_from_group',
|
||||
],
|
||||
},
|
||||
},
|
||||
// Group ID (shared across group operations that need it)
|
||||
{
|
||||
id: 'groupId',
|
||||
title: 'Group ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Okta group ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'okta_get_group',
|
||||
'okta_update_group',
|
||||
'okta_delete_group',
|
||||
'okta_add_user_to_group',
|
||||
'okta_remove_user_from_group',
|
||||
'okta_list_group_members',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'okta_get_group',
|
||||
'okta_update_group',
|
||||
'okta_delete_group',
|
||||
'okta_add_user_to_group',
|
||||
'okta_remove_user_from_group',
|
||||
'okta_list_group_members',
|
||||
],
|
||||
},
|
||||
},
|
||||
// Create/Update User profile params
|
||||
{
|
||||
id: 'firstName',
|
||||
title: 'First Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'John',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
required: { field: 'operation', value: 'okta_create_user' },
|
||||
},
|
||||
{
|
||||
id: 'lastName',
|
||||
title: 'Last Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Doe',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
required: { field: 'operation', value: 'okta_create_user' },
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
type: 'short-input',
|
||||
placeholder: 'john.doe@example.com',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
required: { field: 'operation', value: 'okta_create_user' },
|
||||
},
|
||||
{
|
||||
id: 'login',
|
||||
title: 'Login',
|
||||
type: 'short-input',
|
||||
placeholder: 'john.doe@example.com (defaults to email)',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Password',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Set user password',
|
||||
condition: { field: 'operation', value: 'okta_create_user' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'mobilePhone',
|
||||
title: 'Mobile Phone',
|
||||
type: 'short-input',
|
||||
placeholder: '+1234567890',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Job Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Software Engineer',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'department',
|
||||
title: 'Department',
|
||||
type: 'short-input',
|
||||
placeholder: 'Engineering',
|
||||
condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'activate',
|
||||
title: 'Activate Immediately',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'okta_create_user' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Group name (for create/update group)
|
||||
{
|
||||
id: 'groupName',
|
||||
title: 'Group Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Engineering Team',
|
||||
condition: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] },
|
||||
required: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] },
|
||||
},
|
||||
{
|
||||
id: 'groupDescription',
|
||||
title: 'Group Description',
|
||||
type: 'short-input',
|
||||
placeholder: 'Description for the group',
|
||||
condition: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] },
|
||||
},
|
||||
// Send email option (activate, reset password, delete)
|
||||
{
|
||||
id: 'sendEmail',
|
||||
title: 'Send Email',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'okta_activate_user',
|
||||
'okta_deactivate_user',
|
||||
'okta_reset_password',
|
||||
'okta_delete_user',
|
||||
],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Pagination
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: 'Max results to return',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['okta_list_users', 'okta_list_groups', 'okta_list_group_members'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'okta_list_users',
|
||||
'okta_get_user',
|
||||
'okta_create_user',
|
||||
'okta_update_user',
|
||||
'okta_activate_user',
|
||||
'okta_deactivate_user',
|
||||
'okta_suspend_user',
|
||||
'okta_unsuspend_user',
|
||||
'okta_reset_password',
|
||||
'okta_delete_user',
|
||||
'okta_list_groups',
|
||||
'okta_get_group',
|
||||
'okta_create_group',
|
||||
'okta_update_group',
|
||||
'okta_delete_group',
|
||||
'okta_add_user_to_group',
|
||||
'okta_remove_user_from_group',
|
||||
'okta_list_group_members',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => params.operation as string,
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {
|
||||
apiKey: params.apiKey,
|
||||
domain: params.domain,
|
||||
}
|
||||
|
||||
if (params.limit) result.limit = Number(params.limit)
|
||||
|
||||
// Map group-specific UI fields to tool param names
|
||||
if (params.groupName) result.name = params.groupName
|
||||
if (params.groupDescription !== undefined) result.description = params.groupDescription
|
||||
|
||||
// Pass through all other non-empty params
|
||||
// Allow empty strings so users can clear fields (e.g. update_user partial updates)
|
||||
const skipKeys = new Set([
|
||||
'operation',
|
||||
'apiKey',
|
||||
'domain',
|
||||
'limit',
|
||||
'groupName',
|
||||
'groupDescription',
|
||||
])
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (!skipKeys.has(key) && value !== undefined && value !== null) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
apiKey: { type: 'string', description: 'Okta API token' },
|
||||
domain: { type: 'string', description: 'Okta domain' },
|
||||
userId: { type: 'string', description: 'User ID or login' },
|
||||
groupId: { type: 'string', description: 'Group ID' },
|
||||
search: { type: 'string', description: 'Search expression' },
|
||||
filter: { type: 'string', description: 'Filter expression' },
|
||||
limit: { type: 'number', description: 'Max results to return' },
|
||||
firstName: { type: 'string', description: 'First name' },
|
||||
lastName: { type: 'string', description: 'Last name' },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
login: { type: 'string', description: 'Login (defaults to email)' },
|
||||
password: { type: 'string', description: 'User password' },
|
||||
mobilePhone: { type: 'string', description: 'Mobile phone number' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
department: { type: 'string', description: 'Department' },
|
||||
activate: { type: 'boolean', description: 'Activate user immediately on creation' },
|
||||
groupName: { type: 'string', description: 'Group name' },
|
||||
groupDescription: { type: 'string', description: 'Group description' },
|
||||
sendEmail: { type: 'boolean', description: 'Whether to send email notification' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
users: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Array of user objects (id, status, firstName, lastName, email, login, mobilePhone, title, department, created, lastLogin, lastUpdated)',
|
||||
},
|
||||
members: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Array of group member user objects (id, status, firstName, lastName, email, login, mobilePhone, title, department, created, lastLogin, lastUpdated)',
|
||||
},
|
||||
groups: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Array of group objects (id, name, description, type, created, lastUpdated, lastMembershipUpdated)',
|
||||
},
|
||||
id: { type: 'string', description: 'Resource ID' },
|
||||
status: { type: 'string', description: 'User status' },
|
||||
firstName: { type: 'string', description: 'First name' },
|
||||
lastName: { type: 'string', description: 'Last name' },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
login: { type: 'string', description: 'Login' },
|
||||
name: { type: 'string', description: 'Group name' },
|
||||
description: { type: 'string', description: 'Group description' },
|
||||
type: { type: 'string', description: 'Group type' },
|
||||
count: { type: 'number', description: 'Number of results' },
|
||||
added: { type: 'boolean', description: 'Whether user was added to group' },
|
||||
removed: { type: 'boolean', description: 'Whether user was removed from group' },
|
||||
deactivated: { type: 'boolean', description: 'Whether user was deactivated' },
|
||||
suspended: { type: 'boolean', description: 'Whether user was suspended' },
|
||||
unsuspended: { type: 'boolean', description: 'Whether user was unsuspended' },
|
||||
activated: { type: 'boolean', description: 'Whether user was activated' },
|
||||
deleted: { type: 'boolean', description: 'Whether resource was deleted' },
|
||||
activationUrl: { type: 'string', description: 'Activation URL (when sendEmail is false)' },
|
||||
activationToken: { type: 'string', description: 'Activation token (when sendEmail is false)' },
|
||||
resetPasswordUrl: {
|
||||
type: 'string',
|
||||
description: 'Password reset URL (when sendEmail is false)',
|
||||
},
|
||||
created: { type: 'string', description: 'Creation timestamp' },
|
||||
lastUpdated: { type: 'string', description: 'Last update timestamp' },
|
||||
success: { type: 'boolean', description: 'Operation success status' },
|
||||
},
|
||||
}
|
||||
@@ -84,6 +84,7 @@ import { HunterBlock } from '@/blocks/blocks/hunter'
|
||||
import { ImageGeneratorBlock } from '@/blocks/blocks/image_generator'
|
||||
import { ImapBlock } from '@/blocks/blocks/imap'
|
||||
import { IncidentioBlock } from '@/blocks/blocks/incidentio'
|
||||
import { InfisicalBlock } from '@/blocks/blocks/infisical'
|
||||
import { InputTriggerBlock } from '@/blocks/blocks/input_trigger'
|
||||
import { IntercomBlock, IntercomV2Block } from '@/blocks/blocks/intercom'
|
||||
import { JinaBlock } from '@/blocks/blocks/jina'
|
||||
@@ -104,6 +105,7 @@ import { ManualTriggerBlock } from '@/blocks/blocks/manual_trigger'
|
||||
import { McpBlock } from '@/blocks/blocks/mcp'
|
||||
import { Mem0Block } from '@/blocks/blocks/mem0'
|
||||
import { MemoryBlock } from '@/blocks/blocks/memory'
|
||||
import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad'
|
||||
import { MicrosoftDataverseBlock } from '@/blocks/blocks/microsoft_dataverse'
|
||||
import { MicrosoftExcelBlock, MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel'
|
||||
import { MicrosoftPlannerBlock } from '@/blocks/blocks/microsoft_planner'
|
||||
@@ -120,6 +122,7 @@ import { Neo4jBlock } from '@/blocks/blocks/neo4j'
|
||||
import { NoteBlock } from '@/blocks/blocks/note'
|
||||
import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion'
|
||||
import { ObsidianBlock } from '@/blocks/blocks/obsidian'
|
||||
import { OktaBlock } from '@/blocks/blocks/okta'
|
||||
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
|
||||
import { OnePasswordBlock } from '@/blocks/blocks/onepassword'
|
||||
import { OpenAIBlock } from '@/blocks/blocks/openai'
|
||||
@@ -298,6 +301,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
image_generator: ImageGeneratorBlock,
|
||||
imap: ImapBlock,
|
||||
incidentio: IncidentioBlock,
|
||||
infisical: InfisicalBlock,
|
||||
input_trigger: InputTriggerBlock,
|
||||
intercom: IntercomBlock,
|
||||
intercom_v2: IntercomV2Block,
|
||||
@@ -320,6 +324,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
mcp: McpBlock,
|
||||
mem0: Mem0Block,
|
||||
memory: MemoryBlock,
|
||||
microsoft_ad: MicrosoftAdBlock,
|
||||
microsoft_dataverse: MicrosoftDataverseBlock,
|
||||
microsoft_excel: MicrosoftExcelBlock,
|
||||
microsoft_excel_v2: MicrosoftExcelV2Block,
|
||||
@@ -336,6 +341,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
notion: NotionBlock,
|
||||
notion_v2: NotionV2Block,
|
||||
obsidian: ObsidianBlock,
|
||||
okta: OktaBlock,
|
||||
onepassword: OnePasswordBlock,
|
||||
onedrive: OneDriveBlock,
|
||||
openai: OpenAIBlock,
|
||||
|
||||
@@ -462,13 +462,25 @@ const Combobox = memo(
|
||||
[disabled, editable, inputRef]
|
||||
)
|
||||
|
||||
const effectiveHighlightedIndex =
|
||||
highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1
|
||||
|
||||
/**
|
||||
* Reset highlighted index when filtered options change and index is out of bounds
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && highlightedIndex >= filteredOptions.length) {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [filteredOptions, highlightedIndex])
|
||||
|
||||
/**
|
||||
* Scroll highlighted option into view
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && dropdownRef.current) {
|
||||
if (effectiveHighlightedIndex >= 0 && dropdownRef.current) {
|
||||
const highlightedElement = dropdownRef.current.querySelector(
|
||||
`[data-option-index="${highlightedIndex}"]`
|
||||
`[data-option-index="${effectiveHighlightedIndex}"]`
|
||||
)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
@@ -477,19 +489,7 @@ const Combobox = memo(
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex])
|
||||
|
||||
/**
|
||||
* Adjust highlighted index when filtered options change
|
||||
*/
|
||||
useEffect(() => {
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev >= 0 && prev < filteredOptions.length) {
|
||||
return prev
|
||||
}
|
||||
return -1
|
||||
})
|
||||
}, [filteredOptions])
|
||||
}, [effectiveHighlightedIndex])
|
||||
|
||||
const SelectedIcon = selectedOption?.icon
|
||||
|
||||
@@ -713,7 +713,7 @@ const Combobox = memo(
|
||||
const globalIndex = filteredOptions.findIndex(
|
||||
(o) => o.value === option.value
|
||||
)
|
||||
const isHighlighted = globalIndex === highlightedIndex
|
||||
const isHighlighted = globalIndex === effectiveHighlightedIndex
|
||||
const OptionIcon = option.icon
|
||||
|
||||
return (
|
||||
@@ -789,7 +789,7 @@ const Combobox = memo(
|
||||
const isSelected = multiSelect
|
||||
? multiSelectValues?.includes(option.value)
|
||||
: effectiveSelectedValue === option.value
|
||||
const isHighlighted = index === highlightedIndex
|
||||
const isHighlighted = index === effectiveHighlightedIndex
|
||||
const OptionIcon = option.icon
|
||||
|
||||
return (
|
||||
|
||||
@@ -559,12 +559,15 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [open, isRangeMode, initialStart, initialEnd])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRangeMode && selectedDate) {
|
||||
const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined
|
||||
const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey)
|
||||
if (singleValueKey !== prevSingleValueKey) {
|
||||
setPrevSingleValueKey(singleValueKey)
|
||||
if (selectedDate) {
|
||||
setViewMonth(selectedDate.getMonth())
|
||||
setViewYear(selectedDate.getFullYear())
|
||||
}
|
||||
}, [isRangeMode, selectedDate])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles selection of a specific day in single mode.
|
||||
|
||||
@@ -226,6 +226,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
size = 'md',
|
||||
colorScheme = 'default',
|
||||
open,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
|
||||
@@ -251,21 +252,33 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Resets all navigation state to initial values */
|
||||
const resetState = React.useCallback(() => {
|
||||
setCurrentFolder(null)
|
||||
setFolderTitle(null)
|
||||
setOnFolderSelect(null)
|
||||
setSearchQuery('')
|
||||
setLastHoveredItem(null)
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open === false) {
|
||||
setCurrentFolder(null)
|
||||
setFolderTitle(null)
|
||||
setOnFolderSelect(null)
|
||||
setSearchQuery('')
|
||||
setLastHoveredItem(null)
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
} else {
|
||||
// Reset hover state when opening to prevent stale submenu from previous menu
|
||||
setLastHoveredItem(null)
|
||||
if (!open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open])
|
||||
}, [open, resetState])
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
const openFolder = React.useCallback(
|
||||
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
|
||||
@@ -336,7 +349,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={contextValue}>
|
||||
<PopoverPrimitive.Root open={open} {...props}>
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props}>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</PopoverContext.Provider>
|
||||
|
||||
@@ -135,13 +135,15 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
const [hour, setHour] = React.useState(parsed.hour)
|
||||
const [minute, setMinute] = React.useState(parsed.minute)
|
||||
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm)
|
||||
const [prevValue, setPrevValue] = React.useState(value)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value)
|
||||
const newParsed = parseTime(value || '')
|
||||
setHour(newParsed.hour)
|
||||
setMinute(newParsed.minute)
|
||||
setAmpm(newParsed.ampm)
|
||||
}, [value])
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
@@ -4138,6 +4138,16 @@ export function IncidentioIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function InfisicalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 273 182' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m191.6 39.4c-20.3 0-37.15 13.21-52.9 30.61-12.99-16.4-29.8-30.61-51.06-30.61-27.74 0-50.44 23.86-50.44 51.33 0 26.68 21.43 51.8 48.98 51.8 20.55 0 37.07-13.86 51.32-31.81 12.69 16.97 29.1 31.41 53.2 31.41 27.13 0 49.85-22.96 49.85-51.4 0-27.12-20.44-51.33-48.95-51.33zm-104.3 77.94c-14.56 0-25.51-12.84-25.51-26.07 0-13.7 10.95-28.29 25.51-28.29 14.93 0 25.71 11.6 37.6 27.34-11.31 15.21-22.23 27.02-37.6 27.02zm104.4 0.25c-15 0-25.28-11.13-37.97-27.37 12.69-16.4 22.01-27.24 37.59-27.24 14.97 0 24.79 13.25 24.79 27.26 0 13-10.17 27.35-24.41 27.35z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function IntercomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -6096,6 +6106,19 @@ export function AgentSkillsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function OktaIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 63 63' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M34.6.4l-1.3 16c-.6-.1-1.2-.1-1.9-.1-.8 0-1.6.1-2.3.2l-.7-7.7c0-.2.2-.5.4-.5h1.3L29.5.5c0-.2.2-.5.4-.5h4.3c.3 0 .5.2.4.4zm-10.8.8c-.1-.2-.3-.4-.5-.3l-4 1.5c-.3.1-.4.4-.3.6l3.3 7.1-1.2.5c-.2.1-.3.3-.2.6l3.3 7c1.2-.7 2.5-1.2 3.9-1.5L23.8 1.2zM14 5.7l9.3 13.1c-1.2.8-2.2 1.7-3.1 2.7L14.5 16c-.2-.2-.2-.5 0-.6l1-.8L10 9c-.2-.2-.2-.5 0-.6l3.3-2.7c.2-.3.5-.2.7 0zM6.2 13.2c-.2-.1-.5-.1-.6.1l-2.1 3.7c-.1.2 0 .5.2.6l7.1 3.4-.7 1.1c-.1.2 0 .5.2.6l7.1 3.2c.5-1.3 1.2-2.5 2-3.6L6.2 13.2zM.9 23.3c0-.2.3-.4.5-.3l15.5 4c-.4 1.3-.6 2.7-.7 4.1l-7.8-.6c-.2 0-.4-.2-.4-.5l.2-1.3L.6 28c-.2 0-.4-.2-.4-.5l.7-4.2zM.4 33.8c-.3 0-.4.2-.4.5l.8 4.2c0 .2.3.4.5.3l7.6-2 .2 1.3c0 .2.3.4.5.3l7.5-2.1c-.4-1.3-.7-2.7-.8-4.1L.4 33.8zm2.5 11.1c-.1-.2 0-.5.2-.6l14.5-6.9c.5 1.3 1.3 2.5 2.2 3.6l-6.3 4.5c-.2.1-.5.1-.6-.1L12 44.3l-6.5 4.5c-.2.1-.5.1-.6-.1l-2-3.8zm17.5-3L9.1 53.3c-.2.2-.2.5 0 .6l3.3 2.7c.2.2.5.1.6-.1l4.6-6.4 1 .9c.2.2.5.1.6-.1l4.4-6.4c-1.2-.7-2.3-1.6-3.2-2.6zm-2.2 18.2c-.2-.1-.3-.3-.2-.6L24.6 45c1.2.6 2.6 1.1 3.9 1.4l-2 7.5c-.1.2-.3.4-.5.3l-1.2-.5-2.1 7.6c-.1.2-.3.4-.5.3l-4-1.5zm10.9-13.5l-1.3 16c0 .2.2.5.4.5H33c.2 0 .4-.2.4-.5l-.6-7.8h1.3c.2 0 .4-.2.4-.5l-.7-7.7c-.8.1-1.5.2-2.3.2-.6 0-1.3 0-1.9-.1zm16-43.2c.1-.2 0-.5-.2-.6l-4-1.5c-.2-.1-.5.1-.5.3l-2.1 7.6-1.2-.5c-.2-.1-.5.1-.5.3l-2 7.5c1.4.3 2.7.8 3.9 1.4l6.6-14.5zm8.8 6.3L42.6 21.1c-.9-1-2-1.9-3.2-2.6l4.4-6.4c.1-.2.4-.2.6-.1l1 .9 4.6-6.4c.1-.2.4-.2.6-.1l3.3 2.7c.2.2.2.5 0 .6zM59.9 18.7c.2-.1.3-.4.2-.6L58 14.4c-.1-.2-.4-.3-.6-.1l-6.5 4.5-.7-1.1c-.1-.2-.4-.3-.6-.1L43.3 22c.9 1.1 1.6 2.3 2.2 3.6l14.4-6.9zm2.3 5.8l.7 4.2c0 .2-.1.5-.4.5l-15.9 1.5c-.1-1.4-.4-2.8-.8-4.1l7.5-2.1c.2-.1.5.1.5.3l.2 1.3 7.6-2c.3-.1.5.1.6.4zM61.5 40c.2.1.5-.1.5-.3l.7-4.2c0-.2-.1-.5-.4-.5l-7.8-.7.2-1.3c0-.2-.1-.5-.4-.5l-7.8-.6c0 1.4-.3 2.8-.7 4.1L61.5 40zm-4.1 9.6c-.1.2-.4.3-.6.1l-13.2-9.1c.8-1.1 1.5-2.3 2-3.6l7.1 3.2c.2.1.3.4.2.6L52.2 42l7.1 3.4c.2.1.3.4.2.6l-2.1 3.6zm-17.7-5.4L49 57.3c.1.2.4.2.6.1l3.3-2.7c.2-.2.2-.4 0-.6l-5.5-5.6 1-.8c.2-.2.2-.4 0-.6l-5.5-5.5c1.1.8 0 1.7-1.2 2.4zm0 17.8c-.2.1-.5-.1-.5-.3l-4.2-15.4c1.4-.3 2.7-.8 3.9-1.5l3.3 7c.1.2 0 .5-.2.6l-1.2.5 3.3 7.1c.1.2 0 .5-.2.6L39.7 62z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 48 48' xmlns='http://www.w3.org/2000/svg' fill='none'>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Input, Label } from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { env, isFalsy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
@@ -29,24 +30,6 @@ const validateEmailField = (emailValue: string): string[] => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateCallbackUrl = (url: string): boolean => {
|
||||
try {
|
||||
if (url.startsWith('/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
if (url.startsWith(currentOrigin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error validating callback URL:', { error, url })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default function SSOForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -115,7 +98,7 @@ export default function SSOForm() {
|
||||
}
|
||||
|
||||
try {
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
const safeCallbackUrl = callbackUrl
|
||||
|
||||
await client.signIn.sso({
|
||||
email: emailValue,
|
||||
|
||||
@@ -24,6 +24,9 @@ export function filterOutputForLog(
|
||||
additionalHiddenKeys?: string[]
|
||||
}
|
||||
): NormalizedBlockOutput {
|
||||
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
|
||||
return output as NormalizedBlockOutput
|
||||
}
|
||||
const blockConfig = blockType ? getBlock(blockType) : undefined
|
||||
const filtered: NormalizedBlockOutput = {}
|
||||
const additionalHiddenKeys = options?.additionalHiddenKeys ?? []
|
||||
|
||||
@@ -69,7 +69,11 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import {
|
||||
isDisposableEmailFull,
|
||||
isDisposableMxBackend,
|
||||
quickValidateEmail,
|
||||
} from '@/lib/messaging/email/validation'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
@@ -479,6 +483,7 @@ export const auth = betterAuth({
|
||||
'google-tasks',
|
||||
'vertex-ai',
|
||||
|
||||
'microsoft-ad',
|
||||
'microsoft-dataverse',
|
||||
'microsoft-teams',
|
||||
'microsoft-excel',
|
||||
@@ -624,12 +629,23 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path.startsWith('/sign-up') && blockedSignupDomains) {
|
||||
if (ctx.path.startsWith('/sign-up')) {
|
||||
const requestEmail = ctx.body?.email?.toLowerCase()
|
||||
if (requestEmail) {
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
// Check manually blocked domains
|
||||
if (blockedSignupDomains) {
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
}
|
||||
}
|
||||
|
||||
// Check disposable email domains (full list + MX backend check)
|
||||
if (isDisposableEmailFull(requestEmail)) {
|
||||
throw new Error('Sign-ups from disposable email addresses are not allowed.')
|
||||
}
|
||||
if (await isDisposableMxBackend(requestEmail)) {
|
||||
throw new Error('Sign-ups from disposable email addresses are not allowed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1263,6 +1279,46 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
providerId: 'microsoft-ad',
|
||||
clientId: env.MICROSOFT_CLIENT_ID as string,
|
||||
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
||||
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
||||
scopes: getCanonicalScopesForProvider('microsoft-ad'),
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
authentication: 'basic',
|
||||
pkce: true,
|
||||
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
logger.error('Failed to fetch Microsoft user info', { status: response.status })
|
||||
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
|
||||
}
|
||||
const profile = await response.json()
|
||||
const now = new Date()
|
||||
return {
|
||||
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||
name: profile.displayName || 'Microsoft User',
|
||||
email: profile.mail || profile.userPrincipalName,
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in Microsoft getUserInfo', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
providerId: 'microsoft-teams',
|
||||
clientId: env.MICROSOFT_CLIENT_ID as string,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { generateMcpServerId } from '@/lib/mcp/utils'
|
||||
import { getAllOAuthServices } from '@/lib/oauth/utils'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import {
|
||||
deleteCustomTool,
|
||||
getCustomToolById,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from '@/lib/workflows/custom-tools/operations'
|
||||
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { isMcpTool } from '@/executor/constants'
|
||||
import { isMcpTool, isUuid } from '@/executor/constants'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool, resolveToolId } from '@/tools/utils'
|
||||
import {
|
||||
@@ -68,6 +69,8 @@ import type {
|
||||
ListWorkspaceMcpServersParams,
|
||||
MoveFolderParams,
|
||||
MoveWorkflowParams,
|
||||
OpenResourceParams,
|
||||
OpenResourceType,
|
||||
RenameFolderParams,
|
||||
RenameWorkflowParams,
|
||||
RunBlockParams,
|
||||
@@ -77,6 +80,7 @@ import type {
|
||||
SetGlobalWorkflowVariablesParams,
|
||||
UpdateWorkflowParams,
|
||||
UpdateWorkspaceMcpServerParams,
|
||||
ValidOpenResourceParams,
|
||||
} from './param-types'
|
||||
import { PLATFORM_ACTIONS_CONTENT } from './platform-actions'
|
||||
import { executeVfsGlob, executeVfsGrep, executeVfsList, executeVfsRead } from './vfs-tools'
|
||||
@@ -105,6 +109,36 @@ import {
|
||||
} from './workflow-tools'
|
||||
|
||||
const logger = createLogger('CopilotToolExecutor')
|
||||
const VALID_OPEN_RESOURCE_TYPES = new Set<OpenResourceType>([
|
||||
'workflow',
|
||||
'table',
|
||||
'knowledgebase',
|
||||
'file',
|
||||
])
|
||||
|
||||
function validateOpenResourceParams(
|
||||
params: OpenResourceParams
|
||||
): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } {
|
||||
if (!params.type) {
|
||||
return { success: false, error: 'type is required' }
|
||||
}
|
||||
|
||||
if (!VALID_OPEN_RESOURCE_TYPES.has(params.type)) {
|
||||
return { success: false, error: `Invalid resource type: ${params.type}` }
|
||||
}
|
||||
|
||||
if (!params.id) {
|
||||
return { success: false, error: `${params.type} resources require \`id\`` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
params: {
|
||||
type: params.type,
|
||||
id: params.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list'
|
||||
|
||||
@@ -996,16 +1030,43 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
|
||||
list: (p, c) => executeVfsList(p, c),
|
||||
|
||||
// Resource visibility
|
||||
open_resource: async (p) => {
|
||||
const resourceType = p.type as string | undefined
|
||||
const resourceId = p.id as string | undefined
|
||||
if (!resourceType || !resourceId) {
|
||||
return { success: false, error: 'type and id are required' }
|
||||
open_resource: async (p: OpenResourceParams, c: ExecutionContext) => {
|
||||
const validated = validateOpenResourceParams(p)
|
||||
if (!validated.success) {
|
||||
return { success: false, error: validated.error }
|
||||
}
|
||||
const validTypes = new Set(['workflow', 'table', 'knowledgebase', 'file'])
|
||||
if (!validTypes.has(resourceType)) {
|
||||
return { success: false, error: `Invalid resource type: ${resourceType}` }
|
||||
|
||||
const params = validated.params
|
||||
const resourceType = params.type
|
||||
let resourceId = params.id
|
||||
let title: string = resourceType
|
||||
|
||||
if (resourceType === 'file') {
|
||||
if (!c.workspaceId) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Opening a workspace file requires workspace context. Pass the file UUID from files/<name>/meta.json.',
|
||||
}
|
||||
}
|
||||
if (!isUuid(params.id)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'open_resource for files requires the canonical UUID from files/<name>/meta.json (the "id" field). Do not pass VFS paths, display names, or file_<name> strings.',
|
||||
}
|
||||
}
|
||||
const record = await getWorkspaceFile(c.workspaceId, params.id)
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No workspace file with id "${params.id}". Confirm the UUID from meta.json.`,
|
||||
}
|
||||
}
|
||||
resourceId = record.id
|
||||
title = record.name
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { message: `Opened ${resourceType} ${resourceId} for the user` },
|
||||
@@ -1013,7 +1074,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
|
||||
{
|
||||
type: resourceType as 'workflow' | 'table' | 'knowledgebase' | 'file',
|
||||
id: resourceId,
|
||||
title: resourceType,
|
||||
title,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { getTableById, queryRows } from '@/lib/table/service'
|
||||
import {
|
||||
downloadWorkspaceFile,
|
||||
findWorkspaceFileRecord,
|
||||
listWorkspaceFiles,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
@@ -178,9 +179,7 @@ export async function executeIntegrationToolDirect(
|
||||
logger.warn('Skipping non-text sandbox input file', { fileName, ext })
|
||||
continue
|
||||
}
|
||||
const record = allFiles.find(
|
||||
(f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC')
|
||||
)
|
||||
const record = findWorkspaceFileRecord(allFiles, filePath)
|
||||
if (!record) {
|
||||
logger.warn('Sandbox input file not found', { fileName })
|
||||
continue
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
|
||||
import { workflow, workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { getServePathPrefix } from '@/lib/uploads'
|
||||
import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
@@ -12,22 +13,6 @@ import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('MaterializeFile')
|
||||
|
||||
async function findUploadRecord(fileName: string, chatId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.originalName, fileName),
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
|
||||
const pathPrefix = getServePathPrefix()
|
||||
return {
|
||||
@@ -41,21 +26,23 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
|
||||
uploadedBy: row.userId,
|
||||
deletedAt: row.deletedAt,
|
||||
uploadedAt: row.uploadedAt,
|
||||
storageContext: 'mothership' as const,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
|
||||
if (!row) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workspaceFiles)
|
||||
.set({ context: 'workspace', chatId: null })
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.originalName, fileName),
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
|
||||
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })
|
||||
|
||||
if (!updated) {
|
||||
@@ -84,7 +71,7 @@ async function executeImport(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<ToolCallResult> {
|
||||
const row = await findUploadRecord(fileName, chatId)
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
|
||||
if (!row) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -202,3 +202,15 @@ export interface UpdateWorkspaceMcpServerParams {
|
||||
export interface DeleteWorkspaceMcpServerParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export type OpenResourceType = 'workflow' | 'table' | 'knowledgebase' | 'file'
|
||||
|
||||
export interface OpenResourceParams {
|
||||
type?: OpenResourceType
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface ValidOpenResourceParams {
|
||||
type: OpenResourceType
|
||||
id: string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import { getServePathPrefix } from '@/lib/uploads'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
@@ -21,9 +22,50 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa
|
||||
uploadedBy: row.userId,
|
||||
deletedAt: row.deletedAt,
|
||||
uploadedAt: row.uploadedAt,
|
||||
storageContext: 'mothership',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mothership upload row by `originalName`, preferring an exact DB match (limit 1) and
|
||||
* only scanning all chat uploads when that misses (e.g. macOS U+202F vs ASCII space in the name).
|
||||
*/
|
||||
export async function findMothershipUploadRowByChatAndName(
|
||||
chatId: string,
|
||||
fileName: string
|
||||
): Promise<typeof workspaceFiles.$inferSelect | null> {
|
||||
const exactRows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
eq(workspaceFiles.originalName, fileName),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (exactRows[0]) {
|
||||
return exactRows[0]
|
||||
}
|
||||
|
||||
const allRows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const segmentKey = normalizeVfsSegment(fileName)
|
||||
return allRows.find((r) => normalizeVfsSegment(r.originalName) === segmentKey) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* List all chat-scoped uploads for a given chat.
|
||||
*/
|
||||
@@ -51,30 +93,18 @@ export async function listChatUploads(chatId: string): Promise<WorkspaceFileReco
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a specific uploaded file by name within a chat session.
|
||||
* Read a specific uploaded file by display name within a chat session.
|
||||
* Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F)
|
||||
* matches when the model passes a visually equivalent path.
|
||||
*/
|
||||
export async function readChatUpload(
|
||||
filename: string,
|
||||
chatId: string
|
||||
): Promise<FileReadResult | null> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
eq(workspaceFiles.originalName, filename),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const record = toWorkspaceFileRecord(rows[0])
|
||||
return readFileRecord(record)
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, filename)
|
||||
if (!row) return null
|
||||
return readFileRecord(toWorkspaceFileRecord(row))
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read chat upload', {
|
||||
filename,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
updateTagDefinition,
|
||||
} from '@/lib/knowledge/tags/service'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils'
|
||||
|
||||
const logger = createLogger('KnowledgeBaseServerTool')
|
||||
@@ -235,13 +235,8 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
}
|
||||
}
|
||||
|
||||
const match = args.filePath.match(/^files\/(.+)$/)
|
||||
const fileName = match ? match[1] : args.filePath
|
||||
const kbWorkspaceId: string = targetKb.workspaceId
|
||||
const files = await listWorkspaceFiles(kbWorkspaceId)
|
||||
const fileRecord = files.find(
|
||||
(f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC')
|
||||
)
|
||||
const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, args.filePath)
|
||||
|
||||
if (!fileRecord) {
|
||||
return {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import type { ColumnDefinition, RowData, TableDefinition } from '@/lib/table/types'
|
||||
import {
|
||||
downloadWorkspaceFile,
|
||||
listWorkspaceFiles,
|
||||
resolveWorkspaceFileReference,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
const logger = createLogger('UserTableServerTool')
|
||||
@@ -40,15 +40,10 @@ async function resolveWorkspaceFile(
|
||||
filePath: string,
|
||||
workspaceId: string
|
||||
): Promise<{ buffer: Buffer; name: string; type: string }> {
|
||||
const match = filePath.match(/^files\/(.+)$/)
|
||||
const fileName = match ? match[1] : filePath
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
const record = files.find(
|
||||
(f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC')
|
||||
)
|
||||
const record = await resolveWorkspaceFileReference(workspaceId, filePath)
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`File not found: "${fileName}". Use glob("files/*/meta.json") to list available files.`
|
||||
`File not found: "${filePath}". Use glob("files/*/meta.json") to list available files.`
|
||||
)
|
||||
}
|
||||
const buffer = await downloadWorkspaceFile(record)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { isImageFileType } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('FileReader')
|
||||
|
||||
const MAX_TEXT_READ_BYTES = 512 * 1024 // 512 KB
|
||||
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
|
||||
const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
|
||||
const MAX_IMAGE_READ_BYTES = 20 * 1024 * 1024 // 20 MB
|
||||
|
||||
const TEXT_TYPES = new Set([
|
||||
'text/plain',
|
||||
@@ -53,7 +53,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
|
||||
if (isImageFileType(record.type)) {
|
||||
if (record.size > MAX_IMAGE_READ_BYTES) {
|
||||
return {
|
||||
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`,
|
||||
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 20MB)]`,
|
||||
totalLines: 1,
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/sim/lib/copilot/vfs/normalize-segment.ts
Normal file
14
apps/sim/lib/copilot/vfs/normalize-segment.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Normalize a string for use as a single VFS path segment (workflow name, file name, etc.).
|
||||
* Applies NFC normalization, trims, strips ASCII control characters, maps `/` to `-`, and
|
||||
* collapses Unicode whitespace (including U+202F as in macOS screenshot names) to a single
|
||||
* ASCII space.
|
||||
*/
|
||||
export function normalizeVfsSegment(name: string): string {
|
||||
return name
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
.replace(/\//g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
130
apps/sim/lib/copilot/vfs/operations.test.ts
Normal file
130
apps/sim/lib/copilot/vfs/operations.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { glob, grep } from '@/lib/copilot/vfs/operations'
|
||||
|
||||
function vfsFromEntries(entries: [string, string][]): Map<string, string> {
|
||||
return new Map(entries)
|
||||
}
|
||||
|
||||
describe('glob', () => {
|
||||
it('matches one path segment for single star (files listing pattern)', () => {
|
||||
const files = vfsFromEntries([
|
||||
['files/a/meta.json', '{}'],
|
||||
['files/a/b/meta.json', '{}'],
|
||||
['uploads/x.png', ''],
|
||||
])
|
||||
const hits = glob(files, 'files/*/meta.json')
|
||||
expect(hits).toContain('files/a/meta.json')
|
||||
expect(hits).not.toContain('files/a/b/meta.json')
|
||||
})
|
||||
|
||||
it('matches nested paths with double star', () => {
|
||||
const files = vfsFromEntries([
|
||||
['workflows/W/state.json', ''],
|
||||
['workflows/W/sub/state.json', ''],
|
||||
])
|
||||
const hits = glob(files, 'workflows/**/state.json')
|
||||
expect(hits.sort()).toEqual(['workflows/W/state.json', 'workflows/W/sub/state.json'].sort())
|
||||
})
|
||||
|
||||
it('includes virtual directory prefixes when pattern matches descendants', () => {
|
||||
const files = vfsFromEntries([['files/a/meta.json', '{}']])
|
||||
const hits = glob(files, 'files/**')
|
||||
expect(hits).toContain('files')
|
||||
expect(hits).toContain('files/a')
|
||||
expect(hits).toContain('files/a/meta.json')
|
||||
})
|
||||
|
||||
it('treats braces literally when nobrace is set (matches old builder)', () => {
|
||||
const files = vfsFromEntries([
|
||||
['weird{brace}/x', ''],
|
||||
['weirdA/x', ''],
|
||||
])
|
||||
const hits = glob(files, 'weird{brace}/*')
|
||||
expect(hits).toContain('weird{brace}/x')
|
||||
expect(hits).not.toContain('weirdA/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grep', () => {
|
||||
it('returns content matches per line in default mode', () => {
|
||||
const files = vfsFromEntries([['a.txt', 'hello\nworld\nhello']])
|
||||
const matches = grep(files, 'hello', undefined, { outputMode: 'content' })
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches[0]).toMatchObject({ path: 'a.txt', line: 1, content: 'hello' })
|
||||
expect(matches[1]).toMatchObject({ path: 'a.txt', line: 3, content: 'hello' })
|
||||
})
|
||||
|
||||
it('strips CR before end-of-line matching on CRLF content', () => {
|
||||
const files = vfsFromEntries([['x.txt', 'foo\r\n']])
|
||||
const matches = grep(files, 'foo$', undefined, { outputMode: 'content' })
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.content).toBe('foo')
|
||||
})
|
||||
|
||||
it('counts matching lines', () => {
|
||||
const files = vfsFromEntries([['a.txt', 'a\nb\na']])
|
||||
const counts = grep(files, 'a', undefined, { outputMode: 'count' })
|
||||
expect(counts).toEqual([{ path: 'a.txt', count: 2 }])
|
||||
})
|
||||
|
||||
it('files_with_matches scans whole file (can match across newlines with dot-all style pattern)', () => {
|
||||
const files = vfsFromEntries([['a.txt', 'foo\nbar']])
|
||||
const multiline = grep(files, 'foo[\\s\\S]*bar', undefined, {
|
||||
outputMode: 'files_with_matches',
|
||||
})
|
||||
expect(multiline).toContain('a.txt')
|
||||
|
||||
const lineOnly = grep(files, 'foo[\\s\\S]*bar', undefined, { outputMode: 'content' })
|
||||
expect(lineOnly).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats trailing slash on directory scope like grep (files/ matches files/foo)', () => {
|
||||
const files = vfsFromEntries([
|
||||
['files/TEST BOY.md/meta.json', '"name": "TEST BOY.md"'],
|
||||
['workflows/x', 'TEST BOY'],
|
||||
])
|
||||
const hits = grep(files, 'TEST BOY', 'files/', { outputMode: 'files_with_matches' })
|
||||
expect(hits).toContain('files/TEST BOY.md/meta.json')
|
||||
expect(hits).not.toContain('workflows/x')
|
||||
})
|
||||
|
||||
it('scopes to directory prefix without matching unrelated prefixes', () => {
|
||||
const files = vfsFromEntries([
|
||||
['workflows/a/x', 'needle'],
|
||||
['workflowsManual/x', 'needle'],
|
||||
])
|
||||
const hits = grep(files, 'needle', 'workflows', { outputMode: 'files_with_matches' })
|
||||
expect(hits).toContain('workflows/a/x')
|
||||
expect(hits).not.toContain('workflowsManual/x')
|
||||
})
|
||||
|
||||
it('treats scope with literal brackets as directory prefix, not a glob character class', () => {
|
||||
const files = vfsFromEntries([['weird[bracket]/x.txt', 'needle']])
|
||||
const hits = grep(files, 'needle', 'weird[bracket]', { outputMode: 'files_with_matches' })
|
||||
expect(hits).toContain('weird[bracket]/x.txt')
|
||||
})
|
||||
|
||||
it('scopes with glob pattern when path contains metacharacters', () => {
|
||||
const files = vfsFromEntries([
|
||||
['workflows/A/state.json', '{"x":1}'],
|
||||
['workflows/B/sub/state.json', '{"x":1}'],
|
||||
['workflows/C/other.json', '{"x":1}'],
|
||||
])
|
||||
const hits = grep(files, '1', 'workflows/*/state.json', { outputMode: 'files_with_matches' })
|
||||
expect(hits).toEqual(['workflows/A/state.json'])
|
||||
})
|
||||
|
||||
it('returns empty array for invalid regex pattern', () => {
|
||||
const files = vfsFromEntries([['a.txt', 'x']])
|
||||
expect(grep(files, '(unclosed', undefined, { outputMode: 'content' })).toEqual([])
|
||||
})
|
||||
|
||||
it('respects ignoreCase', () => {
|
||||
const files = vfsFromEntries([['a.txt', 'Hello']])
|
||||
const hits = grep(files, 'hello', undefined, { outputMode: 'content', ignoreCase: true })
|
||||
expect(hits).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import micromatch from 'micromatch'
|
||||
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
line: number
|
||||
@@ -30,8 +32,51 @@ export interface DirEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex search over VFS file contents.
|
||||
* Supports multiple output modes: content (default), files_with_matches, count.
|
||||
* Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*`
|
||||
* never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace`
|
||||
* and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for
|
||||
* well-tested `**` and edge cases instead of a custom `RegExp`.
|
||||
*/
|
||||
const VFS_GLOB_OPTIONS: micromatch.Options = {
|
||||
bash: false,
|
||||
dot: false,
|
||||
windows: false,
|
||||
nobrace: true,
|
||||
noext: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style
|
||||
* CRLF payloads still match patterns anchored at line end (`$`).
|
||||
*/
|
||||
function splitLinesForGrep(content: string): string[] {
|
||||
return content.split('\n').map((line) => line.replace(/\r$/, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains
|
||||
* `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters
|
||||
* (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not
|
||||
* parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under
|
||||
* `files/...`.
|
||||
*/
|
||||
function pathWithinGrepScope(filePath: string, scope: string): boolean {
|
||||
const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope)
|
||||
if (scopeUsesStarOrQuestionGlob) {
|
||||
return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS)
|
||||
}
|
||||
const base = scope.replace(/\/+$/, '')
|
||||
if (base === '') {
|
||||
return true
|
||||
}
|
||||
return filePath === base || filePath.startsWith(`${base}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex search over VFS file contents using ECMAScript `RegExp` syntax.
|
||||
* `content` and `count` are line-oriented (split on newline, CR stripped per line).
|
||||
* `files_with_matches` tests the entire file string once, so multiline patterns can match there
|
||||
* but not in line modes.
|
||||
*/
|
||||
export function grep(
|
||||
files: Map<string, string>,
|
||||
@@ -56,7 +101,7 @@ export function grep(
|
||||
if (outputMode === 'files_with_matches') {
|
||||
const matchingFiles: string[] = []
|
||||
for (const [filePath, content] of files) {
|
||||
if (path && !filePath.startsWith(path)) continue
|
||||
if (path && !pathWithinGrepScope(filePath, path)) continue
|
||||
regex.lastIndex = 0
|
||||
if (regex.test(content)) {
|
||||
matchingFiles.push(filePath)
|
||||
@@ -69,8 +114,8 @@ export function grep(
|
||||
if (outputMode === 'count') {
|
||||
const counts: GrepCountEntry[] = []
|
||||
for (const [filePath, content] of files) {
|
||||
if (path && !filePath.startsWith(path)) continue
|
||||
const lines = content.split('\n')
|
||||
if (path && !pathWithinGrepScope(filePath, path)) continue
|
||||
const lines = splitLinesForGrep(content)
|
||||
let count = 0
|
||||
for (const line of lines) {
|
||||
regex.lastIndex = 0
|
||||
@@ -87,9 +132,9 @@ export function grep(
|
||||
// Default: 'content' mode
|
||||
const matches: GrepMatch[] = []
|
||||
for (const [filePath, content] of files) {
|
||||
if (path && !filePath.startsWith(path)) continue
|
||||
if (path && !pathWithinGrepScope(filePath, path)) continue
|
||||
|
||||
const lines = content.split('\n')
|
||||
const lines = splitLinesForGrep(content)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
regex.lastIndex = 0
|
||||
if (regex.test(lines[i])) {
|
||||
@@ -119,53 +164,13 @@ export function grep(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp.
|
||||
* Supports *, **, and ? wildcards.
|
||||
*/
|
||||
function globToRegExp(pattern: string): RegExp {
|
||||
let regexStr = '^'
|
||||
let i = 0
|
||||
while (i < pattern.length) {
|
||||
const ch = pattern[i]
|
||||
if (ch === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** matches any number of path segments
|
||||
if (pattern[i + 2] === '/') {
|
||||
regexStr += '(?:.+/)?'
|
||||
i += 3
|
||||
} else {
|
||||
regexStr += '.*'
|
||||
i += 2
|
||||
}
|
||||
} else {
|
||||
// * matches anything except /
|
||||
regexStr += '[^/]*'
|
||||
i++
|
||||
}
|
||||
} else if (ch === '?') {
|
||||
regexStr += '[^/]'
|
||||
i++
|
||||
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
||||
regexStr += `\\${ch}`
|
||||
i++
|
||||
} else {
|
||||
regexStr += ch
|
||||
i++
|
||||
}
|
||||
}
|
||||
regexStr += '$'
|
||||
return new RegExp(regexStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob pattern matching against VFS file paths and virtual directories.
|
||||
* Returns matching paths (both files and directory prefixes), just like a real filesystem.
|
||||
* Glob pattern matching against VFS file paths and virtual directories using `micromatch`
|
||||
* with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion).
|
||||
* Returns matching file keys and virtual directory prefixes.
|
||||
*/
|
||||
export function glob(files: Map<string, string>, pattern: string): string[] {
|
||||
const regex = globToRegExp(pattern)
|
||||
const result = new Set<string>()
|
||||
|
||||
// Collect all virtual directory paths from file paths
|
||||
const directories = new Set<string>()
|
||||
for (const filePath of files.keys()) {
|
||||
const parts = filePath.split('/')
|
||||
@@ -174,16 +179,14 @@ export function glob(files: Map<string, string>, pattern: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Match file paths
|
||||
for (const filePath of files.keys()) {
|
||||
if (regex.test(filePath)) {
|
||||
if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) {
|
||||
result.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Match virtual directory paths
|
||||
for (const dir of directories) {
|
||||
if (regex.test(dir)) {
|
||||
if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) {
|
||||
result.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull, ne } from 'drizzle-orm'
|
||||
import { listApiKeys } from '@/lib/api-key/service'
|
||||
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations'
|
||||
import * as ops from '@/lib/copilot/vfs/operations'
|
||||
import type { DeploymentData } from '@/lib/copilot/vfs/serializers'
|
||||
@@ -56,7 +57,10 @@ import {
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { getKnowledgeBases } from '@/lib/knowledge/service'
|
||||
import { listTables } from '@/lib/table/service'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import {
|
||||
findWorkspaceFileRecord,
|
||||
listWorkspaceFiles,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
|
||||
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
@@ -397,9 +401,7 @@ export class WorkspaceVFS {
|
||||
|
||||
try {
|
||||
const files = await listWorkspaceFiles(this._workspaceId)
|
||||
const record = files.find(
|
||||
(f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC')
|
||||
)
|
||||
const record = findWorkspaceFileRecord(files, fileName)
|
||||
if (!record) return null
|
||||
return readFileRecord(record)
|
||||
} catch (err) {
|
||||
@@ -1176,14 +1178,8 @@ export type { FileReadResult } from '@/lib/copilot/vfs/file-reader'
|
||||
|
||||
/**
|
||||
* Sanitize a name for use as a VFS path segment.
|
||||
* Normalizes Unicode to NFC, collapses whitespace, strips control
|
||||
* characters, and replaces forward slashes (path separators).
|
||||
* Delegates to {@link normalizeVfsSegment} so workspace file paths match DB lookups.
|
||||
*/
|
||||
export function sanitizeName(name: string): string {
|
||||
return name
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
.replace(/\//g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
return normalizeVfsSegment(name)
|
||||
}
|
||||
|
||||
@@ -1169,3 +1169,56 @@ export function validatePaginationCursor(
|
||||
|
||||
return { isValid: true, sanitized: value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a callback URL to prevent open redirect attacks.
|
||||
* Accepts relative paths and absolute URLs matching the current origin.
|
||||
*
|
||||
* @param url - The callback URL to validate
|
||||
* @returns true if the URL is safe to redirect to
|
||||
*/
|
||||
export function validateCallbackUrl(url: string): boolean {
|
||||
try {
|
||||
if (url.startsWith('/')) return true
|
||||
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
const currentOrigin = window.location.origin
|
||||
if (url.startsWith(currentOrigin)) return true
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error validating callback URL:', { error, url })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const OKTA_DOMAIN_PATTERN =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-]*\.(okta|okta-gov|okta-emea|oktapreview|trexcloud)\.com$/
|
||||
|
||||
/**
|
||||
* Validates and sanitizes an Okta domain to prevent SSRF.
|
||||
* Ensures the domain matches a known Okta domain suffix.
|
||||
*
|
||||
* @param rawDomain - The raw domain string (may include protocol, trailing slash, or whitespace)
|
||||
* @returns The cleaned, validated domain string
|
||||
* @throws Error if the domain does not match a known Okta domain suffix
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const domain = validateOktaDomain(params.domain)
|
||||
* // Returns: "dev-123456.okta.com"
|
||||
* ```
|
||||
*/
|
||||
export function validateOktaDomain(rawDomain: string): string {
|
||||
const domain = rawDomain
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '')
|
||||
if (!OKTA_DOMAIN_PATTERN.test(domain)) {
|
||||
throw new Error(
|
||||
`Invalid Okta domain: "${domain}". Must be a valid Okta domain (e.g., dev-123456.okta.com)`
|
||||
)
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { quickValidateEmail, validateEmail } from '@/lib/messaging/email/validation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isDisposableEmailFull,
|
||||
isDisposableMxBackend,
|
||||
quickValidateEmail,
|
||||
validateEmail,
|
||||
} from '@/lib/messaging/email/validation'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
const { mockResolveMx } = vi.hoisted(() => ({
|
||||
mockResolveMx: vi.fn(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'mail.example.com', priority: 10 }])
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('dns', () => ({
|
||||
resolveMx: (
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'mail.example.com', priority: 10 }])
|
||||
},
|
||||
resolveMx: mockResolveMx,
|
||||
}))
|
||||
|
||||
describe('Email Validation', () => {
|
||||
beforeEach(() => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'mail.example.com', priority: 10 }])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('validateEmail', () => {
|
||||
it.concurrent('should validate a correct email', async () => {
|
||||
const result = await validateEmail('user@example.com')
|
||||
@@ -215,4 +237,118 @@ describe('Email Validation', () => {
|
||||
expect(result.checks.domain).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDisposableEmailFull', () => {
|
||||
it('should reject domains from the inline blocklist', () => {
|
||||
expect(isDisposableEmailFull('user@sharebot.net')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@oakon.com')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@catchmail.io')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@salt.email')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@mail.gw')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@mailinator.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject domains from the npm package list that are not in the inline list', () => {
|
||||
expect(isDisposableEmailFull('user@0-mail.com')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@0-180.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow legitimate email domains', () => {
|
||||
expect(isDisposableEmailFull('user@gmail.com')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@company.com')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@outlook.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle invalid input', () => {
|
||||
expect(isDisposableEmailFull('')).toBe(false)
|
||||
expect(isDisposableEmailFull('nodomain')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@')).toBe(false)
|
||||
})
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isDisposableEmailFull('user@MAILINATOR.COM')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@ShareBot.Net')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDisposableMxBackend', () => {
|
||||
it('should detect mail.gw MX backend', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@some-random-domain.xyz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect catchmail.io MX backend', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'smtp.catchmail.io', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@custom-domain.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle trailing dot in MX exchange', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw.', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@trailing-dot.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow legitimate MX backends', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'aspmx.l.google.com', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@legitimate.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false on DNS errors', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(_domain: string, callback: (err: Error | null, addresses: null) => void) => {
|
||||
callback(new Error('ENOTFOUND'), null)
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@nonexistent.invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid input', async () => {
|
||||
expect(await isDisposableMxBackend('')).toBe(false)
|
||||
expect(await isDisposableMxBackend('nodomain')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEmail with disposable MX backend', () => {
|
||||
it('should reject emails with disposable MX backend even if domain is not in blocklist', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw', priority: 10 }])
|
||||
}
|
||||
)
|
||||
const result = await validateEmail('user@unknown-disposable.xyz')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,29 +14,65 @@ export interface EmailValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Common disposable email domains (subset - can be expanded)
|
||||
const DISPOSABLE_DOMAINS = new Set([
|
||||
/** Common disposable domains for fast client-side checks (no heavy import needed) */
|
||||
const DISPOSABLE_DOMAINS_INLINE = new Set([
|
||||
'10minutemail.com',
|
||||
'tempmail.org',
|
||||
'guerrillamail.com',
|
||||
'mailinator.com',
|
||||
'yopmail.com',
|
||||
'temp-mail.org',
|
||||
'throwaway.email',
|
||||
'getnada.com',
|
||||
'10minutemail.net',
|
||||
'temporary-mail.net',
|
||||
'fakemailgenerator.com',
|
||||
'sharklasers.com',
|
||||
'guerrillamailblock.com',
|
||||
'pokemail.net',
|
||||
'spam4.me',
|
||||
'tempail.com',
|
||||
'tempr.email',
|
||||
'catchmail.io',
|
||||
'dispostable.com',
|
||||
'emailondeck.com',
|
||||
'fakemailgenerator.com',
|
||||
'getnada.com',
|
||||
'guerrillamail.com',
|
||||
'guerrillamailblock.com',
|
||||
'mail.gw',
|
||||
'mailinator.com',
|
||||
'oakon.com',
|
||||
'pokemail.net',
|
||||
'salt.email',
|
||||
'sharebot.net',
|
||||
'sharklasers.com',
|
||||
'spam4.me',
|
||||
'temp-mail.org',
|
||||
'tempail.com',
|
||||
'tempmail.org',
|
||||
'tempr.email',
|
||||
'temporary-mail.net',
|
||||
'throwaway.email',
|
||||
'yopmail.com',
|
||||
])
|
||||
|
||||
/** Full disposable domain list from npm package (~5.3K domains), lazy-loaded server-side only */
|
||||
let disposableDomainsFull: Set<string> | null = null
|
||||
|
||||
function getDisposableDomainsFull(): Set<string> {
|
||||
if (!disposableDomainsFull) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const domains = require('disposable-email-domains') as string[]
|
||||
disposableDomainsFull = new Set(domains)
|
||||
} catch {
|
||||
logger.warn('Failed to load disposable-email-domains package')
|
||||
disposableDomainsFull = new Set()
|
||||
}
|
||||
}
|
||||
return disposableDomainsFull
|
||||
}
|
||||
|
||||
/** MX hostnames used by known disposable email backends */
|
||||
const DISPOSABLE_MX_BACKENDS = new Set(['in.mail.gw', 'smtp.catchmail.io', 'mx.yopmail.com'])
|
||||
|
||||
/** Per-domain MX result cache — avoids redundant DNS queries for concurrent or repeated sign-ups */
|
||||
const mxCache = new Map<string, { result: boolean; expires: number }>()
|
||||
const MX_CACHE_MAX = 1_000
|
||||
|
||||
function setMxCache(domain: string, entry: { result: boolean; expires: number }) {
|
||||
if (mxCache.size >= MX_CACHE_MAX && !mxCache.has(domain)) {
|
||||
mxCache.delete(mxCache.keys().next().value!)
|
||||
}
|
||||
mxCache.set(domain, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email syntax using RFC 5322 compliant regex
|
||||
*/
|
||||
@@ -47,12 +83,14 @@ function validateEmailSyntax(email: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if domain has valid MX records (server-side only)
|
||||
* Checks if domain has valid MX records and is not backed by a disposable email service (server-side only)
|
||||
*/
|
||||
async function checkMXRecord(domain: string): Promise<boolean> {
|
||||
async function checkMXRecord(
|
||||
domain: string
|
||||
): Promise<{ exists: boolean; isDisposableBackend: boolean }> {
|
||||
// Skip MX check on client-side (browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
return true // Assume valid on client-side
|
||||
return { exists: true, isDisposableBackend: false }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -61,19 +99,61 @@ async function checkMXRecord(domain: string): Promise<boolean> {
|
||||
const resolveMx = promisify(dns.resolveMx)
|
||||
|
||||
const mxRecords = await resolveMx(domain)
|
||||
return mxRecords && mxRecords.length > 0
|
||||
if (!mxRecords || mxRecords.length === 0) {
|
||||
return { exists: false, isDisposableBackend: false }
|
||||
}
|
||||
|
||||
const isDisposableBackend = mxRecords.some((record: { exchange: string }) =>
|
||||
DISPOSABLE_MX_BACKENDS.has(record.exchange.toLowerCase().replace(/\.$/, ''))
|
||||
)
|
||||
|
||||
return { exists: true, isDisposableBackend }
|
||||
} catch (error) {
|
||||
logger.debug('MX record check failed', { domain, error })
|
||||
return false
|
||||
return { exists: false, isDisposableBackend: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if email is from a known disposable email provider
|
||||
* Checks against the full disposable email domain list (~5.3K domains server-side, inline list client-side)
|
||||
*/
|
||||
function isDisposableEmail(email: string): boolean {
|
||||
export function isDisposableEmailFull(email: string): boolean {
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
return domain ? DISPOSABLE_DOMAINS.has(domain) : false
|
||||
if (!domain) return false
|
||||
return DISPOSABLE_DOMAINS_INLINE.has(domain) || getDisposableDomainsFull().has(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email's MX records point to a known disposable email backend (server-side only)
|
||||
*/
|
||||
export async function isDisposableMxBackend(email: string): Promise<boolean> {
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return false
|
||||
|
||||
const now = Date.now()
|
||||
const cached = mxCache.get(domain)
|
||||
if (cached) {
|
||||
if (cached.expires > now) return cached.result
|
||||
mxCache.delete(domain)
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const mxCheckPromise = checkMXRecord(domain)
|
||||
const timeoutPromise = new Promise<{ exists: boolean; isDisposableBackend: boolean }>(
|
||||
(_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('MX check timeout')), 5000)
|
||||
}
|
||||
)
|
||||
const result = await Promise.race([mxCheckPromise, timeoutPromise])
|
||||
setMxCache(domain, { result: result.isDisposableBackend, expires: now + 5 * 60 * 1000 })
|
||||
return result.isDisposableBackend
|
||||
} catch {
|
||||
setMxCache(domain, { result: false, expires: now + 60 * 1000 })
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,8 +203,8 @@ export async function validateEmail(email: string): Promise<EmailValidationResul
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for disposable email first (more specific)
|
||||
checks.disposable = !isDisposableEmail(email)
|
||||
// 2. Check for disposable email against full list (server-side)
|
||||
checks.disposable = !isDisposableEmailFull(email)
|
||||
if (!checks.disposable) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -155,17 +235,33 @@ export async function validateEmail(email: string): Promise<EmailValidationResul
|
||||
}
|
||||
}
|
||||
|
||||
// 5. MX record check (with timeout)
|
||||
// 5. MX record check (with timeout) — also detects disposable email backends
|
||||
let mxTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const mxCheckPromise = checkMXRecord(domain)
|
||||
const timeoutPromise = new Promise<boolean>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('MX check timeout')), 5000)
|
||||
const timeoutPromise = new Promise<{ exists: boolean; isDisposableBackend: boolean }>(
|
||||
(_, reject) => {
|
||||
mxTimeoutId = setTimeout(() => reject(new Error('MX check timeout')), 5000)
|
||||
}
|
||||
)
|
||||
|
||||
checks.mxRecord = await Promise.race([mxCheckPromise, timeoutPromise])
|
||||
const mxResult = await Promise.race([mxCheckPromise, timeoutPromise])
|
||||
checks.mxRecord = mxResult.exists
|
||||
|
||||
if (mxResult.isDisposableBackend) {
|
||||
checks.disposable = false
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Disposable email addresses are not allowed',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('MX record check failed or timed out', { domain, error })
|
||||
checks.mxRecord = false
|
||||
} finally {
|
||||
clearTimeout(mxTimeoutId)
|
||||
}
|
||||
|
||||
// Determine overall validity and confidence
|
||||
@@ -225,7 +321,7 @@ export function quickValidateEmail(email: string): EmailValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
checks.disposable = !isDisposableEmail(email)
|
||||
checks.disposable = !isDisposableEmailFull(email)
|
||||
if (!checks.disposable) {
|
||||
return {
|
||||
isValid: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
|
||||
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
|
||||
|
||||
export const MICROSOFT_PROVIDERS = new Set([
|
||||
'microsoft-ad',
|
||||
'microsoft-dataverse',
|
||||
'microsoft-excel',
|
||||
'microsoft-planner',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AirtableIcon,
|
||||
AsanaIcon,
|
||||
AttioIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
CalComIcon,
|
||||
ConfluenceIcon,
|
||||
@@ -243,6 +244,24 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
name: 'Microsoft',
|
||||
icon: MicrosoftIcon,
|
||||
services: {
|
||||
'microsoft-ad': {
|
||||
name: 'Azure AD',
|
||||
description: 'Connect to Azure AD (Microsoft Entra ID) and manage users and groups.',
|
||||
providerId: 'microsoft-ad',
|
||||
icon: AzureIcon,
|
||||
baseProviderIcon: MicrosoftIcon,
|
||||
scopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'User.Read.All',
|
||||
'User.ReadWrite.All',
|
||||
'Group.ReadWrite.All',
|
||||
'GroupMember.ReadWrite.All',
|
||||
'Directory.Read.All',
|
||||
'offline_access',
|
||||
],
|
||||
},
|
||||
'microsoft-dataverse': {
|
||||
name: 'Microsoft Dataverse',
|
||||
description: 'Connect to Microsoft Dataverse and manage records.',
|
||||
|
||||
@@ -24,6 +24,7 @@ export type OAuthProvider =
|
||||
| 'box'
|
||||
| 'dropbox'
|
||||
| 'microsoft'
|
||||
| 'microsoft-ad'
|
||||
| 'microsoft-dataverse'
|
||||
| 'microsoft-excel'
|
||||
| 'microsoft-planner'
|
||||
@@ -73,6 +74,7 @@ export type OAuthService =
|
||||
| 'jira'
|
||||
| 'box'
|
||||
| 'dropbox'
|
||||
| 'microsoft-ad'
|
||||
| 'microsoft-dataverse'
|
||||
| 'microsoft-excel'
|
||||
| 'microsoft-teams'
|
||||
|
||||
@@ -213,7 +213,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels',
|
||||
'ChannelMember.Read.All': 'Read team channel members',
|
||||
'Group.Read.All': 'Read Microsoft groups',
|
||||
'Group.ReadWrite.All': 'Write to Microsoft groups',
|
||||
'Group.ReadWrite.All': 'Read and write all groups',
|
||||
'Team.ReadBasic.All': 'Read Microsoft teams',
|
||||
'TeamMember.Read.All': 'Read team members',
|
||||
'Mail.ReadWrite': 'Write to Microsoft emails',
|
||||
@@ -227,6 +227,10 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
|
||||
'Sites.Manage.All': 'Manage Sharepoint sites',
|
||||
'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf',
|
||||
'User.Read.All': 'Read all user profiles',
|
||||
'User.ReadWrite.All': 'Read and write all user profiles',
|
||||
'GroupMember.ReadWrite.All': 'Read and write all group memberships',
|
||||
'Directory.Read.All': 'Read directory data',
|
||||
|
||||
// Discord scopes
|
||||
identify: 'Read Discord user',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
decrementStorageUsage,
|
||||
incrementStorageUsage,
|
||||
} from '@/lib/billing/storage'
|
||||
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
||||
import {
|
||||
downloadFile,
|
||||
hasCloudStorage,
|
||||
@@ -44,6 +45,8 @@ export interface WorkspaceFileRecord {
|
||||
uploadedBy: string
|
||||
deletedAt?: Date | null
|
||||
uploadedAt: Date
|
||||
/** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */
|
||||
storageContext?: 'workspace' | 'mothership'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +331,62 @@ export async function listWorkspaceFiles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a workspace file reference to its display name.
|
||||
* Supports raw names and VFS-style paths like `files/name`, `files/name/content`,
|
||||
* and `files/name/meta.json`.
|
||||
*
|
||||
* Used by storage resolution (`findWorkspaceFileRecord`), not by `open_resource`, which
|
||||
* requires the canonical database UUID only.
|
||||
*/
|
||||
export function normalizeWorkspaceFileReference(fileReference: string): string {
|
||||
const trimmed = fileReference.trim().replace(/^\/+/, '')
|
||||
|
||||
if (trimmed.startsWith('files/')) {
|
||||
const withoutPrefix = trimmed.slice('files/'.length)
|
||||
if (withoutPrefix.endsWith('/meta.json')) {
|
||||
return withoutPrefix.slice(0, -'/meta.json'.length)
|
||||
}
|
||||
if (withoutPrefix.endsWith('/content')) {
|
||||
return withoutPrefix.slice(0, -'/content'.length)
|
||||
}
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a workspace file record in an existing list from either its id or a VFS/name reference.
|
||||
* For copilot `open_resource` and the resource panel, use {@link getWorkspaceFile} with a UUID only.
|
||||
*/
|
||||
export function findWorkspaceFileRecord(
|
||||
files: WorkspaceFileRecord[],
|
||||
fileReference: string
|
||||
): WorkspaceFileRecord | null {
|
||||
const exactIdMatch = files.find((file) => file.id === fileReference)
|
||||
if (exactIdMatch) {
|
||||
return exactIdMatch
|
||||
}
|
||||
|
||||
const normalizedReference = normalizeWorkspaceFileReference(fileReference)
|
||||
const segmentKey = normalizeVfsSegment(normalizedReference)
|
||||
return (
|
||||
files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace file record from either its id or a VFS/name reference.
|
||||
*/
|
||||
export async function resolveWorkspaceFileReference(
|
||||
workspaceId: string,
|
||||
fileReference: string
|
||||
): Promise<WorkspaceFileRecord | null> {
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
return findWorkspaceFileRecord(files, fileReference)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific workspace file
|
||||
*/
|
||||
@@ -390,7 +449,7 @@ export async function downloadWorkspaceFile(fileRecord: WorkspaceFileRecord): Pr
|
||||
try {
|
||||
const buffer = await downloadFile({
|
||||
key: fileRecord.key,
|
||||
context: 'workspace',
|
||||
context: fileRecord.storageContext ?? 'workspace',
|
||||
})
|
||||
logger.info(
|
||||
`Successfully downloaded workspace file: ${fileRecord.name} (${buffer.length} bytes)`
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"csv-parse": "6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"decimal.js": "10.6.0",
|
||||
"disposable-email-domains": "1.0.62",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"encoding": "0.1.13",
|
||||
"entities": "6.0.1",
|
||||
@@ -126,6 +127,7 @@
|
||||
"lucide-react": "^0.479.0",
|
||||
"mammoth": "^1.9.0",
|
||||
"marked": "17.0.4",
|
||||
"micromatch": "4.0.8",
|
||||
"mongodb": "6.19.0",
|
||||
"mysql2": "3.14.3",
|
||||
"nanoid": "^3.3.7",
|
||||
@@ -179,6 +181,7 @@
|
||||
"devDependencies": {
|
||||
"@sim/testing": "workspace:*",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@trigger.dev/build": "4.1.2",
|
||||
"@types/fluent-ffmpeg": "2.1.28",
|
||||
@@ -186,6 +189,7 @@
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.2.1",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/papaparse": "5.3.16",
|
||||
@@ -195,7 +199,6 @@
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"autoprefixer": "10.4.21",
|
||||
"concurrently": "^9.1.0",
|
||||
"critters": "0.0.25",
|
||||
|
||||
@@ -1830,6 +1830,186 @@ describe('Rate Limiting and Retry Logic', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripInternalFields Safety', () => {
|
||||
let cleanupEnvVars: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
cleanupEnvVars()
|
||||
})
|
||||
|
||||
it('should preserve string output from tools without character-indexing', async () => {
|
||||
const stringOutput = '{"type":"button","phone":"917899658001"}'
|
||||
|
||||
const mockTool = {
|
||||
id: 'test_string_output',
|
||||
name: 'Test String Output',
|
||||
description: 'A tool that returns a string as output',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/string-output',
|
||||
method: 'POST' as const,
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: stringOutput,
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_string_output = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_string_output', {}, true)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toBe(stringOutput)
|
||||
expect(typeof result.output).toBe('string')
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should preserve array output from tools', async () => {
|
||||
const arrayOutput = [{ id: 1 }, { id: 2 }]
|
||||
|
||||
const mockTool = {
|
||||
id: 'test_array_output',
|
||||
name: 'Test Array Output',
|
||||
description: 'A tool that returns an array as output',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/array-output',
|
||||
method: 'POST' as const,
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: arrayOutput,
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_array_output = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_array_output', {}, true)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(Array.isArray(result.output)).toBe(true)
|
||||
expect(result.output).toEqual(arrayOutput)
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should still strip __-prefixed fields from object output', async () => {
|
||||
const mockTool = {
|
||||
id: 'test_strip_internal',
|
||||
name: 'Test Strip Internal',
|
||||
description: 'A tool with __internal fields in output',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/strip-internal',
|
||||
method: 'POST' as const,
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'ok', __costDollars: 0.05, _id: 'keep-this' },
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_strip_internal = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_strip_internal', {}, true)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('ok')
|
||||
expect(result.output.__costDollars).toBeUndefined()
|
||||
expect(result.output._id).toBe('keep-this')
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should preserve __-prefixed fields in custom tool output', async () => {
|
||||
const mockTool = {
|
||||
id: 'custom_test-preserve-dunder',
|
||||
name: 'Custom Preserve Dunder',
|
||||
description: 'A custom tool whose output has __ fields',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/function/execute',
|
||||
method: 'POST' as const,
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' },
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any)['custom_test-preserve-dunder'] = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
})),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('custom_test-preserve-dunder', {}, true)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('ok')
|
||||
expect(result.output.__metadata).toEqual({ source: 'user' })
|
||||
expect(result.output.__tag).toBe('important')
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cost Field Handling', () => {
|
||||
let cleanupEnvVars: () => void
|
||||
|
||||
|
||||
@@ -363,6 +363,9 @@ async function reportCustomDimensionUsage(
|
||||
* fields like `_id`.
|
||||
*/
|
||||
function stripInternalFields(output: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
|
||||
return output
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
if (!key.startsWith('__')) {
|
||||
@@ -825,7 +828,9 @@ export async function executeTool(
|
||||
)
|
||||
}
|
||||
|
||||
const strippedOutput = stripInternalFields(finalResult.output || {})
|
||||
const strippedOutput = isCustomTool(normalizedToolId)
|
||||
? finalResult.output
|
||||
: stripInternalFields(finalResult.output ?? {})
|
||||
|
||||
return {
|
||||
...finalResult,
|
||||
@@ -880,7 +885,9 @@ export async function executeTool(
|
||||
)
|
||||
}
|
||||
|
||||
const strippedOutput = stripInternalFields(finalResult.output || {})
|
||||
const strippedOutput = isCustomTool(normalizedToolId)
|
||||
? finalResult.output
|
||||
: stripInternalFields(finalResult.output ?? {})
|
||||
|
||||
return {
|
||||
...finalResult,
|
||||
|
||||
195
apps/sim/tools/infisical/create_secret.ts
Normal file
195
apps/sim/tools/infisical/create_secret.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type {
|
||||
InfisicalCreateSecretParams,
|
||||
InfisicalCreateSecretResponse,
|
||||
} from '@/tools/infisical/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const createSecretTool: ToolConfig<
|
||||
InfisicalCreateSecretParams,
|
||||
InfisicalCreateSecretResponse
|
||||
> = {
|
||||
id: 'infisical_create_secret',
|
||||
name: 'Infisical Create Secret',
|
||||
description: 'Create a new secret in a project environment.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Infisical API token',
|
||||
},
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.',
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the project',
|
||||
},
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The environment slug (e.g., "dev", "staging", "prod")',
|
||||
},
|
||||
secretName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the secret to create',
|
||||
},
|
||||
secretValue: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The value of the secret',
|
||||
},
|
||||
secretPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path for the secret (default: "/")',
|
||||
},
|
||||
secretComment: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'A comment for the secret',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Secret type: "shared" or "personal" (default: "shared")',
|
||||
},
|
||||
tagIds: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated tag IDs to attach to the secret',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: (params) =>
|
||||
`${params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}`,
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
secretValue: params.secretValue,
|
||||
}
|
||||
if (params.secretPath) body.secretPath = params.secretPath
|
||||
if (params.secretComment) body.secretComment = params.secretComment
|
||||
if (params.type) body.type = params.type
|
||||
if (params.tagIds) {
|
||||
body.tagIds = params.tagIds.split(',').map((id) => id.trim())
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { secret: {} as InfisicalCreateSecretResponse['output']['secret'] },
|
||||
error: data.message ?? `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
const s = data.secret ?? data
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
secret: {
|
||||
id: s.id ?? null,
|
||||
workspace: s.workspace ?? null,
|
||||
secretKey: s.secretKey ?? null,
|
||||
secretValue: s.secretValue ?? null,
|
||||
secretComment: s.secretComment ?? null,
|
||||
secretPath: s.secretPath ?? null,
|
||||
version: s.version ?? null,
|
||||
type: s.type ?? null,
|
||||
environment: s.environment ?? null,
|
||||
tags:
|
||||
(s.tags as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
id: (t.id as string) ?? null,
|
||||
slug: (t.slug as string) ?? null,
|
||||
color: (t.color as string) ?? null,
|
||||
name: (t.name as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
secretMetadata:
|
||||
(s.secretMetadata as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(m: Record<string, unknown>) => ({
|
||||
key: (m.key as string) ?? null,
|
||||
value: (m.value as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
createdAt: s.createdAt ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
secret: {
|
||||
type: 'object',
|
||||
description: 'The created secret',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Secret ID' },
|
||||
workspace: { type: 'string', description: 'Workspace/project ID', optional: true },
|
||||
secretKey: { type: 'string', description: 'Secret name/key' },
|
||||
secretValue: { type: 'string', description: 'Secret value', optional: true },
|
||||
secretComment: { type: 'string', description: 'Secret comment', optional: true },
|
||||
secretPath: { type: 'string', description: 'Secret path', optional: true },
|
||||
version: { type: 'number', description: 'Secret version' },
|
||||
type: { type: 'string', description: 'Secret type (shared or personal)' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags attached to the secret',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag ID' },
|
||||
slug: { type: 'string', description: 'Tag slug' },
|
||||
color: { type: 'string', description: 'Tag color', optional: true },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
secretMetadata: {
|
||||
type: 'array',
|
||||
description: 'Custom metadata key-value pairs',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Metadata key' },
|
||||
value: { type: 'string', description: 'Metadata value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
169
apps/sim/tools/infisical/delete_secret.ts
Normal file
169
apps/sim/tools/infisical/delete_secret.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
InfisicalDeleteSecretParams,
|
||||
InfisicalDeleteSecretResponse,
|
||||
} from '@/tools/infisical/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteSecretTool: ToolConfig<
|
||||
InfisicalDeleteSecretParams,
|
||||
InfisicalDeleteSecretResponse
|
||||
> = {
|
||||
id: 'infisical_delete_secret',
|
||||
name: 'Infisical Delete Secret',
|
||||
description: 'Delete a secret from a project environment.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Infisical API token',
|
||||
},
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.',
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the project',
|
||||
},
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The environment slug (e.g., "dev", "staging", "prod")',
|
||||
},
|
||||
secretName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the secret to delete',
|
||||
},
|
||||
secretPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path of the secret (default: "/")',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Secret type: "shared" or "personal" (default: "shared")',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
method: 'DELETE',
|
||||
url: (params) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('projectId', params.projectId)
|
||||
searchParams.set('environment', params.environment)
|
||||
if (params.secretPath) searchParams.set('secretPath', params.secretPath)
|
||||
if (params.type) searchParams.set('type', params.type)
|
||||
const base = params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'
|
||||
return `${base}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}?${searchParams.toString()}`
|
||||
},
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { secret: {} as InfisicalDeleteSecretResponse['output']['secret'] },
|
||||
error: data.message ?? `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
const s = data.secret ?? data
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
secret: {
|
||||
id: s.id ?? null,
|
||||
workspace: s.workspace ?? null,
|
||||
secretKey: s.secretKey ?? null,
|
||||
secretValue: s.secretValue ?? null,
|
||||
secretComment: s.secretComment ?? null,
|
||||
secretPath: s.secretPath ?? null,
|
||||
version: s.version ?? null,
|
||||
type: s.type ?? null,
|
||||
environment: s.environment ?? null,
|
||||
tags:
|
||||
(s.tags as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
id: (t.id as string) ?? null,
|
||||
slug: (t.slug as string) ?? null,
|
||||
color: (t.color as string) ?? null,
|
||||
name: (t.name as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
secretMetadata:
|
||||
(s.secretMetadata as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(m: Record<string, unknown>) => ({
|
||||
key: (m.key as string) ?? null,
|
||||
value: (m.value as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
createdAt: s.createdAt ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
secret: {
|
||||
type: 'object',
|
||||
description: 'The deleted secret',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Secret ID' },
|
||||
workspace: { type: 'string', description: 'Workspace/project ID', optional: true },
|
||||
secretKey: { type: 'string', description: 'Secret name/key' },
|
||||
secretValue: { type: 'string', description: 'Secret value', optional: true },
|
||||
secretComment: { type: 'string', description: 'Secret comment', optional: true },
|
||||
secretPath: { type: 'string', description: 'Secret path', optional: true },
|
||||
version: { type: 'number', description: 'Secret version' },
|
||||
type: { type: 'string', description: 'Secret type (shared or personal)' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags attached to the secret',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag ID' },
|
||||
slug: { type: 'string', description: 'Tag slug' },
|
||||
color: { type: 'string', description: 'Tag color', optional: true },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
secretMetadata: {
|
||||
type: 'array',
|
||||
description: 'Custom metadata key-value pairs',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Metadata key' },
|
||||
value: { type: 'string', description: 'Metadata value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
186
apps/sim/tools/infisical/get_secret.ts
Normal file
186
apps/sim/tools/infisical/get_secret.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { InfisicalGetSecretParams, InfisicalGetSecretResponse } from '@/tools/infisical/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSecretTool: ToolConfig<InfisicalGetSecretParams, InfisicalGetSecretResponse> = {
|
||||
id: 'infisical_get_secret',
|
||||
name: 'Infisical Get Secret',
|
||||
description: 'Retrieve a single secret by name from a project environment.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Infisical API token',
|
||||
},
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.',
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the project',
|
||||
},
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The environment slug (e.g., "dev", "staging", "prod")',
|
||||
},
|
||||
secretName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the secret to retrieve',
|
||||
},
|
||||
secretPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path of the secret (default: "/")',
|
||||
},
|
||||
version: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Specific version of the secret to retrieve',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Secret type: "shared" or "personal" (default: "shared")',
|
||||
},
|
||||
viewSecretValue: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include the secret value in the response (default: true)',
|
||||
},
|
||||
expandSecretReferences: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to expand secret references (default: true)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: (params) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('projectId', params.projectId)
|
||||
searchParams.set('environment', params.environment)
|
||||
if (params.secretPath) searchParams.set('secretPath', params.secretPath)
|
||||
if (params.version != null) searchParams.set('version', String(params.version))
|
||||
if (params.type) searchParams.set('type', params.type)
|
||||
if (params.viewSecretValue != null)
|
||||
searchParams.set('viewSecretValue', String(params.viewSecretValue))
|
||||
if (params.expandSecretReferences != null)
|
||||
searchParams.set('expandSecretReferences', String(params.expandSecretReferences))
|
||||
const base = params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'
|
||||
return `${base}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}?${searchParams.toString()}`
|
||||
},
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { secret: {} as InfisicalGetSecretResponse['output']['secret'] },
|
||||
error: data.message ?? `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
const s = data.secret ?? data
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
secret: {
|
||||
id: s.id ?? null,
|
||||
workspace: s.workspace ?? null,
|
||||
secretKey: s.secretKey ?? null,
|
||||
secretValue: s.secretValue ?? null,
|
||||
secretComment: s.secretComment ?? null,
|
||||
secretPath: s.secretPath ?? null,
|
||||
version: s.version ?? null,
|
||||
type: s.type ?? null,
|
||||
environment: s.environment ?? null,
|
||||
tags:
|
||||
(s.tags as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
id: (t.id as string) ?? null,
|
||||
slug: (t.slug as string) ?? null,
|
||||
color: (t.color as string) ?? null,
|
||||
name: (t.name as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
secretMetadata:
|
||||
(s.secretMetadata as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(m: Record<string, unknown>) => ({
|
||||
key: (m.key as string) ?? null,
|
||||
value: (m.value as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
createdAt: s.createdAt ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
secret: {
|
||||
type: 'object',
|
||||
description: 'The retrieved secret',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Secret ID' },
|
||||
workspace: { type: 'string', description: 'Workspace/project ID', optional: true },
|
||||
secretKey: { type: 'string', description: 'Secret name/key' },
|
||||
secretValue: { type: 'string', description: 'Secret value', optional: true },
|
||||
secretComment: { type: 'string', description: 'Secret comment', optional: true },
|
||||
secretPath: { type: 'string', description: 'Secret path', optional: true },
|
||||
version: { type: 'number', description: 'Secret version' },
|
||||
type: { type: 'string', description: 'Secret type (shared or personal)' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags attached to the secret',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag ID' },
|
||||
slug: { type: 'string', description: 'Tag slug' },
|
||||
color: { type: 'string', description: 'Tag color', optional: true },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
secretMetadata: {
|
||||
type: 'array',
|
||||
description: 'Custom metadata key-value pairs',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Metadata key' },
|
||||
value: { type: 'string', description: 'Metadata value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
13
apps/sim/tools/infisical/index.ts
Normal file
13
apps/sim/tools/infisical/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createSecretTool } from '@/tools/infisical/create_secret'
|
||||
import { deleteSecretTool } from '@/tools/infisical/delete_secret'
|
||||
import { getSecretTool } from '@/tools/infisical/get_secret'
|
||||
import { listSecretsTool } from '@/tools/infisical/list_secrets'
|
||||
import { updateSecretTool } from '@/tools/infisical/update_secret'
|
||||
|
||||
export const infisicalListSecretsTool = listSecretsTool
|
||||
export const infisicalGetSecretTool = getSecretTool
|
||||
export const infisicalCreateSecretTool = createSecretTool
|
||||
export const infisicalUpdateSecretTool = updateSecretTool
|
||||
export const infisicalDeleteSecretTool = deleteSecretTool
|
||||
|
||||
export * from './types'
|
||||
194
apps/sim/tools/infisical/list_secrets.ts
Normal file
194
apps/sim/tools/infisical/list_secrets.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
InfisicalListSecretsParams,
|
||||
InfisicalListSecretsResponse,
|
||||
} from '@/tools/infisical/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listSecretsTool: ToolConfig<InfisicalListSecretsParams, InfisicalListSecretsResponse> =
|
||||
{
|
||||
id: 'infisical_list_secrets',
|
||||
name: 'Infisical List Secrets',
|
||||
description:
|
||||
'List all secrets in a project environment. Returns secret keys, values, comments, tags, and metadata.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Infisical API token',
|
||||
},
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.',
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the project to list secrets from',
|
||||
},
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The environment slug (e.g., "dev", "staging", "prod")',
|
||||
},
|
||||
secretPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path of the secrets (default: "/")',
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to fetch secrets recursively from subdirectories',
|
||||
},
|
||||
expandSecretReferences: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to expand secret references (default: true)',
|
||||
},
|
||||
viewSecretValue: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include secret values in the response (default: true)',
|
||||
},
|
||||
includeImports: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include imported secrets (default: true)',
|
||||
},
|
||||
tagSlugs: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated tag slugs to filter secrets by',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: (params) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('projectId', params.projectId)
|
||||
searchParams.set('environment', params.environment)
|
||||
if (params.secretPath) searchParams.set('secretPath', params.secretPath)
|
||||
if (params.recursive != null) searchParams.set('recursive', String(params.recursive))
|
||||
if (params.expandSecretReferences != null)
|
||||
searchParams.set('expandSecretReferences', String(params.expandSecretReferences))
|
||||
if (params.viewSecretValue != null)
|
||||
searchParams.set('viewSecretValue', String(params.viewSecretValue))
|
||||
if (params.includeImports != null)
|
||||
searchParams.set('includeImports', String(params.includeImports))
|
||||
if (params.tagSlugs) searchParams.set('tagSlugs', params.tagSlugs)
|
||||
const base = params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'
|
||||
return `${base}/api/v4/secrets?${searchParams.toString()}`
|
||||
},
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { secrets: [], count: 0 },
|
||||
error: data.message ?? `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
const secrets = (data.secrets ?? []).map((s: Record<string, unknown>) => ({
|
||||
id: s.id ?? null,
|
||||
workspace: s.workspace ?? null,
|
||||
secretKey: s.secretKey ?? null,
|
||||
secretValue: s.secretValue ?? null,
|
||||
secretComment: s.secretComment ?? null,
|
||||
secretPath: s.secretPath ?? null,
|
||||
version: s.version ?? null,
|
||||
type: s.type ?? null,
|
||||
environment: s.environment ?? null,
|
||||
tags:
|
||||
(s.tags as Array<Record<string, unknown>> | undefined)?.map((t) => ({
|
||||
id: (t.id as string) ?? null,
|
||||
slug: (t.slug as string) ?? null,
|
||||
color: (t.color as string) ?? null,
|
||||
name: (t.name as string) ?? null,
|
||||
})) ?? [],
|
||||
secretMetadata:
|
||||
(s.secretMetadata as Array<Record<string, unknown>> | undefined)?.map((m) => ({
|
||||
key: (m.key as string) ?? null,
|
||||
value: (m.value as string) ?? null,
|
||||
})) ?? [],
|
||||
createdAt: s.createdAt ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
}))
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
secrets,
|
||||
count: secrets.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
secrets: {
|
||||
type: 'array',
|
||||
description: 'Array of secrets',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Secret ID' },
|
||||
workspace: { type: 'string', description: 'Workspace/project ID', optional: true },
|
||||
secretKey: { type: 'string', description: 'Secret name/key' },
|
||||
secretValue: { type: 'string', description: 'Secret value', optional: true },
|
||||
secretComment: { type: 'string', description: 'Secret comment', optional: true },
|
||||
secretPath: { type: 'string', description: 'Secret path', optional: true },
|
||||
version: { type: 'number', description: 'Secret version' },
|
||||
type: { type: 'string', description: 'Secret type (shared or personal)' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags attached to the secret',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag ID' },
|
||||
slug: { type: 'string', description: 'Tag slug' },
|
||||
color: { type: 'string', description: 'Tag color', optional: true },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
secretMetadata: {
|
||||
type: 'array',
|
||||
description: 'Custom metadata key-value pairs',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Metadata key' },
|
||||
value: { type: 'string', description: 'Metadata value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number', description: 'Total number of secrets returned' },
|
||||
},
|
||||
}
|
||||
130
apps/sim/tools/infisical/types.ts
Normal file
130
apps/sim/tools/infisical/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface InfisicalListSecretsParams {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
projectId: string
|
||||
environment: string
|
||||
secretPath?: string
|
||||
recursive?: boolean
|
||||
expandSecretReferences?: boolean
|
||||
viewSecretValue?: boolean
|
||||
includeImports?: boolean
|
||||
tagSlugs?: string
|
||||
}
|
||||
|
||||
export interface InfisicalGetSecretParams {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
projectId: string
|
||||
environment: string
|
||||
secretName: string
|
||||
secretPath?: string
|
||||
version?: number
|
||||
type?: string
|
||||
viewSecretValue?: boolean
|
||||
expandSecretReferences?: boolean
|
||||
}
|
||||
|
||||
export interface InfisicalCreateSecretParams {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
projectId: string
|
||||
environment: string
|
||||
secretName: string
|
||||
secretValue: string
|
||||
secretPath?: string
|
||||
secretComment?: string
|
||||
type?: string
|
||||
tagIds?: string
|
||||
}
|
||||
|
||||
export interface InfisicalUpdateSecretParams {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
projectId: string
|
||||
environment: string
|
||||
secretName: string
|
||||
secretValue?: string
|
||||
secretPath?: string
|
||||
secretComment?: string
|
||||
newSecretName?: string
|
||||
type?: string
|
||||
tagIds?: string
|
||||
}
|
||||
|
||||
export interface InfisicalDeleteSecretParams {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
projectId: string
|
||||
environment: string
|
||||
secretName: string
|
||||
secretPath?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface InfisicalTag {
|
||||
id: string
|
||||
slug: string
|
||||
color: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface InfisicalSecretMetadata {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface InfisicalSecret {
|
||||
id: string
|
||||
workspace: string | null
|
||||
secretKey: string
|
||||
secretValue: string | null
|
||||
secretComment: string | null
|
||||
secretPath: string | null
|
||||
version: number
|
||||
type: string
|
||||
environment: string
|
||||
tags: InfisicalTag[]
|
||||
secretMetadata: InfisicalSecretMetadata[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InfisicalListSecretsResponse extends ToolResponse {
|
||||
output: {
|
||||
secrets: InfisicalSecret[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface InfisicalGetSecretResponse extends ToolResponse {
|
||||
output: {
|
||||
secret: InfisicalSecret
|
||||
}
|
||||
}
|
||||
|
||||
export interface InfisicalCreateSecretResponse extends ToolResponse {
|
||||
output: {
|
||||
secret: InfisicalSecret
|
||||
}
|
||||
}
|
||||
|
||||
export interface InfisicalUpdateSecretResponse extends ToolResponse {
|
||||
output: {
|
||||
secret: InfisicalSecret
|
||||
}
|
||||
}
|
||||
|
||||
export interface InfisicalDeleteSecretResponse extends ToolResponse {
|
||||
output: {
|
||||
secret: InfisicalSecret
|
||||
}
|
||||
}
|
||||
|
||||
export type InfisicalResponse =
|
||||
| InfisicalListSecretsResponse
|
||||
| InfisicalGetSecretResponse
|
||||
| InfisicalCreateSecretResponse
|
||||
| InfisicalUpdateSecretResponse
|
||||
| InfisicalDeleteSecretResponse
|
||||
202
apps/sim/tools/infisical/update_secret.ts
Normal file
202
apps/sim/tools/infisical/update_secret.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
InfisicalUpdateSecretParams,
|
||||
InfisicalUpdateSecretResponse,
|
||||
} from '@/tools/infisical/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateSecretTool: ToolConfig<
|
||||
InfisicalUpdateSecretParams,
|
||||
InfisicalUpdateSecretResponse
|
||||
> = {
|
||||
id: 'infisical_update_secret',
|
||||
name: 'Infisical Update Secret',
|
||||
description: 'Update an existing secret in a project environment.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Infisical API token',
|
||||
},
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.',
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the project',
|
||||
},
|
||||
environment: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The environment slug (e.g., "dev", "staging", "prod")',
|
||||
},
|
||||
secretName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the secret to update',
|
||||
},
|
||||
secretValue: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The new value for the secret',
|
||||
},
|
||||
secretPath: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path of the secret (default: "/")',
|
||||
},
|
||||
secretComment: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'A comment for the secret',
|
||||
},
|
||||
newSecretName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'New name for the secret (to rename it)',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Secret type: "shared" or "personal" (default: "shared")',
|
||||
},
|
||||
tagIds: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated tag IDs to set on the secret',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
method: 'PATCH',
|
||||
url: (params) =>
|
||||
`${params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}`,
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
}
|
||||
if (params.secretValue) body.secretValue = params.secretValue
|
||||
if (params.secretPath) body.secretPath = params.secretPath
|
||||
if (params.secretComment) body.secretComment = params.secretComment
|
||||
if (params.newSecretName) body.newSecretName = params.newSecretName
|
||||
if (params.type) body.type = params.type
|
||||
if (params.tagIds) {
|
||||
body.tagIds = params.tagIds.split(',').map((id) => id.trim())
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { secret: {} as InfisicalUpdateSecretResponse['output']['secret'] },
|
||||
error: data.message ?? `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
const s = data.secret ?? data
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
secret: {
|
||||
id: s.id ?? null,
|
||||
workspace: s.workspace ?? null,
|
||||
secretKey: s.secretKey ?? null,
|
||||
secretValue: s.secretValue ?? null,
|
||||
secretComment: s.secretComment ?? null,
|
||||
secretPath: s.secretPath ?? null,
|
||||
version: s.version ?? null,
|
||||
type: s.type ?? null,
|
||||
environment: s.environment ?? null,
|
||||
tags:
|
||||
(s.tags as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
id: (t.id as string) ?? null,
|
||||
slug: (t.slug as string) ?? null,
|
||||
color: (t.color as string) ?? null,
|
||||
name: (t.name as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
secretMetadata:
|
||||
(s.secretMetadata as Array<Record<string, unknown>> | undefined)?.map(
|
||||
(m: Record<string, unknown>) => ({
|
||||
key: (m.key as string) ?? null,
|
||||
value: (m.value as string) ?? null,
|
||||
})
|
||||
) ?? [],
|
||||
createdAt: s.createdAt ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
secret: {
|
||||
type: 'object',
|
||||
description: 'The updated secret',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Secret ID' },
|
||||
workspace: { type: 'string', description: 'Workspace/project ID', optional: true },
|
||||
secretKey: { type: 'string', description: 'Secret name/key' },
|
||||
secretValue: { type: 'string', description: 'Secret value', optional: true },
|
||||
secretComment: { type: 'string', description: 'Secret comment', optional: true },
|
||||
secretPath: { type: 'string', description: 'Secret path', optional: true },
|
||||
version: { type: 'number', description: 'Secret version' },
|
||||
type: { type: 'string', description: 'Secret type (shared or personal)' },
|
||||
environment: { type: 'string', description: 'Environment slug' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'Tags attached to the secret',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Tag ID' },
|
||||
slug: { type: 'string', description: 'Tag slug' },
|
||||
color: { type: 'string', description: 'Tag color', optional: true },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
secretMetadata: {
|
||||
type: 'array',
|
||||
description: 'Custom metadata key-value pairs',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Metadata key' },
|
||||
value: { type: 'string', description: 'Metadata value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
updatedAt: { type: 'string', description: 'Last update timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
74
apps/sim/tools/microsoft_ad/add_group_member.ts
Normal file
74
apps/sim/tools/microsoft_ad/add_group_member.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
MicrosoftAdAddGroupMemberParams,
|
||||
MicrosoftAdAddGroupMemberResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const addGroupMemberTool: ToolConfig<
|
||||
MicrosoftAdAddGroupMemberParams,
|
||||
MicrosoftAdAddGroupMemberResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_add_group_member',
|
||||
name: 'Add Azure AD Group Member',
|
||||
description: 'Add a member to a group in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
memberId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID of the member to add',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}/members/$ref`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const memberId = params.memberId?.trim()
|
||||
if (!memberId) throw new Error('Member ID is required')
|
||||
return {
|
||||
'@odata.id': `https://graph.microsoft.com/v1.0/directoryObjects/${memberId}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdAddGroupMemberParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
added: true,
|
||||
groupId: params?.groupId ?? '',
|
||||
memberId: params?.memberId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
added: { type: 'boolean', description: 'Whether the member was added successfully' },
|
||||
groupId: { type: 'string', description: 'Group ID' },
|
||||
memberId: { type: 'string', description: 'Member ID that was added' },
|
||||
},
|
||||
}
|
||||
119
apps/sim/tools/microsoft_ad/create_group.ts
Normal file
119
apps/sim/tools/microsoft_ad/create_group.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
MicrosoftAdCreateGroupParams,
|
||||
MicrosoftAdCreateGroupResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const createGroupTool: ToolConfig<
|
||||
MicrosoftAdCreateGroupParams,
|
||||
MicrosoftAdCreateGroupResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_create_group',
|
||||
name: 'Create Azure AD Group',
|
||||
description: 'Create a new group in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
displayName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Display name for the group',
|
||||
},
|
||||
mailNickname: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Mail alias for the group (ASCII only, max 64 characters)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group description',
|
||||
},
|
||||
mailEnabled: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether mail is enabled (true for Microsoft 365 groups)',
|
||||
},
|
||||
securityEnabled: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether security is enabled (true for security groups)',
|
||||
},
|
||||
groupTypes: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group type: "Unified" for Microsoft 365 group, leave empty for security group',
|
||||
},
|
||||
visibility: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group visibility: "Private" or "Public"',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'https://graph.microsoft.com/v1.0/groups',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
displayName: params.displayName,
|
||||
mailNickname: params.mailNickname,
|
||||
mailEnabled: params.mailEnabled,
|
||||
securityEnabled: params.securityEnabled,
|
||||
}
|
||||
if (params.description) body.description = params.description
|
||||
if (params.groupTypes) body.groupTypes = [params.groupTypes]
|
||||
else body.groupTypes = []
|
||||
if (params.visibility) body.visibility = params.visibility
|
||||
return body
|
||||
},
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const group = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
group: {
|
||||
id: group.id ?? null,
|
||||
displayName: group.displayName ?? null,
|
||||
description: group.description ?? null,
|
||||
mail: group.mail ?? null,
|
||||
mailEnabled: group.mailEnabled ?? null,
|
||||
mailNickname: group.mailNickname ?? null,
|
||||
securityEnabled: group.securityEnabled ?? null,
|
||||
groupTypes: group.groupTypes ?? [],
|
||||
visibility: group.visibility ?? null,
|
||||
createdDateTime: group.createdDateTime ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
group: {
|
||||
type: 'object',
|
||||
description: 'Created group details',
|
||||
properties: GROUP_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
150
apps/sim/tools/microsoft_ad/create_user.ts
Normal file
150
apps/sim/tools/microsoft_ad/create_user.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
MicrosoftAdCreateUserParams,
|
||||
MicrosoftAdCreateUserResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const createUserTool: ToolConfig<
|
||||
MicrosoftAdCreateUserParams,
|
||||
MicrosoftAdCreateUserResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_create_user',
|
||||
name: 'Create Azure AD User',
|
||||
description: 'Create a new user in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
displayName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Display name for the user',
|
||||
},
|
||||
mailNickname: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Mail alias for the user',
|
||||
},
|
||||
userPrincipalName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User principal name (e.g., "user@example.com")',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Initial password for the user',
|
||||
},
|
||||
accountEnabled: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the account is enabled',
|
||||
},
|
||||
givenName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'First name',
|
||||
},
|
||||
surname: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Last name',
|
||||
},
|
||||
jobTitle: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Job title',
|
||||
},
|
||||
department: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Department',
|
||||
},
|
||||
officeLocation: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Office location',
|
||||
},
|
||||
mobilePhone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Mobile phone number',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'https://graph.microsoft.com/v1.0/users',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
accountEnabled: params.accountEnabled,
|
||||
displayName: params.displayName,
|
||||
mailNickname: params.mailNickname,
|
||||
userPrincipalName: params.userPrincipalName,
|
||||
passwordProfile: {
|
||||
password: params.password,
|
||||
forceChangePasswordNextSignIn: true,
|
||||
},
|
||||
}
|
||||
if (params.givenName) body.givenName = params.givenName
|
||||
if (params.surname) body.surname = params.surname
|
||||
if (params.jobTitle) body.jobTitle = params.jobTitle
|
||||
if (params.department) body.department = params.department
|
||||
if (params.officeLocation) body.officeLocation = params.officeLocation
|
||||
if (params.mobilePhone) body.mobilePhone = params.mobilePhone
|
||||
return body
|
||||
},
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const user = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
user: {
|
||||
id: user.id ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
givenName: user.givenName ?? null,
|
||||
surname: user.surname ?? null,
|
||||
userPrincipalName: user.userPrincipalName ?? null,
|
||||
mail: user.mail ?? null,
|
||||
jobTitle: user.jobTitle ?? null,
|
||||
department: user.department ?? null,
|
||||
officeLocation: user.officeLocation ?? null,
|
||||
mobilePhone: user.mobilePhone ?? null,
|
||||
accountEnabled: user.accountEnabled ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
user: {
|
||||
type: 'object',
|
||||
description: 'Created user details',
|
||||
properties: USER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
59
apps/sim/tools/microsoft_ad/delete_group.ts
Normal file
59
apps/sim/tools/microsoft_ad/delete_group.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
MicrosoftAdDeleteGroupParams,
|
||||
MicrosoftAdDeleteGroupResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteGroupTool: ToolConfig<
|
||||
MicrosoftAdDeleteGroupParams,
|
||||
MicrosoftAdDeleteGroupResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_delete_group',
|
||||
name: 'Delete Azure AD Group',
|
||||
description:
|
||||
'Delete a group from Azure AD (Microsoft Entra ID). Microsoft 365 and security groups can be restored within 30 days.',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdDeleteGroupParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
deleted: true,
|
||||
groupId: params?.groupId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
deleted: { type: 'boolean', description: 'Whether the deletion was successful' },
|
||||
groupId: { type: 'string', description: 'ID of the deleted group' },
|
||||
},
|
||||
}
|
||||
59
apps/sim/tools/microsoft_ad/delete_user.ts
Normal file
59
apps/sim/tools/microsoft_ad/delete_user.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
MicrosoftAdDeleteUserParams,
|
||||
MicrosoftAdDeleteUserResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteUserTool: ToolConfig<
|
||||
MicrosoftAdDeleteUserParams,
|
||||
MicrosoftAdDeleteUserResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_delete_user',
|
||||
name: 'Delete Azure AD User',
|
||||
description:
|
||||
'Delete a user from Azure AD (Microsoft Entra ID). The user is moved to a temporary container and can be restored within 30 days.',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID or user principal name',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const userId = params.userId?.trim()
|
||||
if (!userId) throw new Error('User ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdDeleteUserParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
deleted: true,
|
||||
userId: params?.userId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
deleted: { type: 'boolean', description: 'Whether the deletion was successful' },
|
||||
userId: { type: 'string', description: 'ID of the deleted user' },
|
||||
},
|
||||
}
|
||||
70
apps/sim/tools/microsoft_ad/get_group.ts
Normal file
70
apps/sim/tools/microsoft_ad/get_group.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
MicrosoftAdGetGroupParams,
|
||||
MicrosoftAdGetGroupResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getGroupTool: ToolConfig<MicrosoftAdGetGroupParams, MicrosoftAdGetGroupResponse> = {
|
||||
id: 'microsoft_ad_get_group',
|
||||
name: 'Get Azure AD Group',
|
||||
description: 'Get a group by ID from Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}?$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const group = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
group: {
|
||||
id: group.id ?? null,
|
||||
displayName: group.displayName ?? null,
|
||||
description: group.description ?? null,
|
||||
mail: group.mail ?? null,
|
||||
mailEnabled: group.mailEnabled ?? null,
|
||||
mailNickname: group.mailNickname ?? null,
|
||||
securityEnabled: group.securityEnabled ?? null,
|
||||
groupTypes: group.groupTypes ?? [],
|
||||
visibility: group.visibility ?? null,
|
||||
createdDateTime: group.createdDateTime ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
group: {
|
||||
type: 'object',
|
||||
description: 'Group details',
|
||||
properties: GROUP_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
71
apps/sim/tools/microsoft_ad/get_user.ts
Normal file
71
apps/sim/tools/microsoft_ad/get_user.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
MicrosoftAdGetUserParams,
|
||||
MicrosoftAdGetUserResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getUserTool: ToolConfig<MicrosoftAdGetUserParams, MicrosoftAdGetUserResponse> = {
|
||||
id: 'microsoft_ad_get_user',
|
||||
name: 'Get Azure AD User',
|
||||
description: 'Get a user by ID or user principal name from Azure AD',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID or user principal name (e.g., "user@example.com")',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const userId = params.userId?.trim()
|
||||
if (!userId) throw new Error('User ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}?$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const user = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
user: {
|
||||
id: user.id ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
givenName: user.givenName ?? null,
|
||||
surname: user.surname ?? null,
|
||||
userPrincipalName: user.userPrincipalName ?? null,
|
||||
mail: user.mail ?? null,
|
||||
jobTitle: user.jobTitle ?? null,
|
||||
department: user.department ?? null,
|
||||
officeLocation: user.officeLocation ?? null,
|
||||
mobilePhone: user.mobilePhone ?? null,
|
||||
accountEnabled: user.accountEnabled ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
user: {
|
||||
type: 'object',
|
||||
description: 'User details',
|
||||
properties: USER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
27
apps/sim/tools/microsoft_ad/index.ts
Normal file
27
apps/sim/tools/microsoft_ad/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { addGroupMemberTool } from '@/tools/microsoft_ad/add_group_member'
|
||||
import { createGroupTool } from '@/tools/microsoft_ad/create_group'
|
||||
import { createUserTool } from '@/tools/microsoft_ad/create_user'
|
||||
import { deleteGroupTool } from '@/tools/microsoft_ad/delete_group'
|
||||
import { deleteUserTool } from '@/tools/microsoft_ad/delete_user'
|
||||
import { getGroupTool } from '@/tools/microsoft_ad/get_group'
|
||||
import { getUserTool } from '@/tools/microsoft_ad/get_user'
|
||||
import { listGroupMembersTool } from '@/tools/microsoft_ad/list_group_members'
|
||||
import { listGroupsTool } from '@/tools/microsoft_ad/list_groups'
|
||||
import { listUsersTool } from '@/tools/microsoft_ad/list_users'
|
||||
import { removeGroupMemberTool } from '@/tools/microsoft_ad/remove_group_member'
|
||||
import { updateGroupTool } from '@/tools/microsoft_ad/update_group'
|
||||
import { updateUserTool } from '@/tools/microsoft_ad/update_user'
|
||||
|
||||
export const microsoftAdListUsersTool = listUsersTool
|
||||
export const microsoftAdGetUserTool = getUserTool
|
||||
export const microsoftAdCreateUserTool = createUserTool
|
||||
export const microsoftAdUpdateUserTool = updateUserTool
|
||||
export const microsoftAdDeleteUserTool = deleteUserTool
|
||||
export const microsoftAdListGroupsTool = listGroupsTool
|
||||
export const microsoftAdGetGroupTool = getGroupTool
|
||||
export const microsoftAdCreateGroupTool = createGroupTool
|
||||
export const microsoftAdUpdateGroupTool = updateGroupTool
|
||||
export const microsoftAdDeleteGroupTool = deleteGroupTool
|
||||
export const microsoftAdListGroupMembersTool = listGroupMembersTool
|
||||
export const microsoftAdAddGroupMemberTool = addGroupMemberTool
|
||||
export const microsoftAdRemoveGroupMemberTool = removeGroupMemberTool
|
||||
78
apps/sim/tools/microsoft_ad/list_group_members.ts
Normal file
78
apps/sim/tools/microsoft_ad/list_group_members.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
MicrosoftAdListGroupMembersParams,
|
||||
MicrosoftAdListGroupMembersResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { MEMBER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listGroupMembersTool: ToolConfig<
|
||||
MicrosoftAdListGroupMembersParams,
|
||||
MicrosoftAdListGroupMembersResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_list_group_members',
|
||||
name: 'List Azure AD Group Members',
|
||||
description: 'List members of a group in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
top: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of members to return (default 100, max 999)',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
const queryParts = ['$select=id,displayName,mail']
|
||||
if (params.top) queryParts.push(`$top=${params.top}`)
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}/members?${queryParts.join('&')}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
const members = (data.value ?? []).map((member: Record<string, unknown>) => ({
|
||||
id: member.id ?? null,
|
||||
displayName: member.displayName ?? null,
|
||||
mail: member.mail ?? null,
|
||||
odataType: (member['@odata.type'] as string) ?? null,
|
||||
}))
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
members,
|
||||
memberCount: members.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
members: {
|
||||
type: 'array',
|
||||
description: 'List of group members',
|
||||
properties: MEMBER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
memberCount: { type: 'number', description: 'Number of members returned' },
|
||||
},
|
||||
}
|
||||
100
apps/sim/tools/microsoft_ad/list_groups.ts
Normal file
100
apps/sim/tools/microsoft_ad/list_groups.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
MicrosoftAdListGroupsParams,
|
||||
MicrosoftAdListGroupsResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listGroupsTool: ToolConfig<
|
||||
MicrosoftAdListGroupsParams,
|
||||
MicrosoftAdListGroupsResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_list_groups',
|
||||
name: 'List Azure AD Groups',
|
||||
description: 'List groups in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
top: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of groups to return (default 100, max 999)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'OData filter expression (e.g., "securityEnabled eq true")',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search string to filter groups by displayName or description',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const queryParts: string[] = []
|
||||
queryParts.push(
|
||||
'$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime'
|
||||
)
|
||||
if (params.top) queryParts.push(`$top=${params.top}`)
|
||||
if (params.search && params.filter) {
|
||||
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
|
||||
}
|
||||
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
|
||||
if (params.search) {
|
||||
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
|
||||
queryParts.push('$count=true')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/groups?${queryParts.join('&')}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
ConsistencyLevel: 'eventual',
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
const groups = (data.value ?? []).map((group: Record<string, unknown>) => ({
|
||||
id: group.id ?? null,
|
||||
displayName: group.displayName ?? null,
|
||||
description: group.description ?? null,
|
||||
mail: group.mail ?? null,
|
||||
mailEnabled: group.mailEnabled ?? null,
|
||||
mailNickname: group.mailNickname ?? null,
|
||||
securityEnabled: group.securityEnabled ?? null,
|
||||
groupTypes: group.groupTypes ?? [],
|
||||
visibility: group.visibility ?? null,
|
||||
createdDateTime: group.createdDateTime ?? null,
|
||||
}))
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
groups,
|
||||
groupCount: groups.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
groups: {
|
||||
type: 'array',
|
||||
description: 'List of groups',
|
||||
properties: GROUP_OUTPUT_PROPERTIES,
|
||||
},
|
||||
groupCount: { type: 'number', description: 'Number of groups returned' },
|
||||
},
|
||||
}
|
||||
98
apps/sim/tools/microsoft_ad/list_users.ts
Normal file
98
apps/sim/tools/microsoft_ad/list_users.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
MicrosoftAdListUsersParams,
|
||||
MicrosoftAdListUsersResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdListUsersResponse> = {
|
||||
id: 'microsoft_ad_list_users',
|
||||
name: 'List Azure AD Users',
|
||||
description: 'List users in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
top: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of users to return (default 100, max 999)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'OData filter expression (e.g., "department eq \'Sales\'")',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search string to filter users by displayName or mail',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const queryParts: string[] = []
|
||||
queryParts.push(
|
||||
'$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled'
|
||||
)
|
||||
if (params.top) queryParts.push(`$top=${params.top}`)
|
||||
if (params.search && params.filter) {
|
||||
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
|
||||
}
|
||||
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
|
||||
if (params.search) {
|
||||
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
|
||||
queryParts.push('$count=true')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/users?${queryParts.join('&')}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
ConsistencyLevel: 'eventual',
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
const users = (data.value ?? []).map((user: Record<string, unknown>) => ({
|
||||
id: user.id ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
givenName: user.givenName ?? null,
|
||||
surname: user.surname ?? null,
|
||||
userPrincipalName: user.userPrincipalName ?? null,
|
||||
mail: user.mail ?? null,
|
||||
jobTitle: user.jobTitle ?? null,
|
||||
department: user.department ?? null,
|
||||
officeLocation: user.officeLocation ?? null,
|
||||
mobilePhone: user.mobilePhone ?? null,
|
||||
accountEnabled: user.accountEnabled ?? null,
|
||||
}))
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
users,
|
||||
userCount: users.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
users: {
|
||||
type: 'array',
|
||||
description: 'List of users',
|
||||
properties: USER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
userCount: { type: 'number', description: 'Number of users returned' },
|
||||
},
|
||||
}
|
||||
68
apps/sim/tools/microsoft_ad/remove_group_member.ts
Normal file
68
apps/sim/tools/microsoft_ad/remove_group_member.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
MicrosoftAdRemoveGroupMemberParams,
|
||||
MicrosoftAdRemoveGroupMemberResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const removeGroupMemberTool: ToolConfig<
|
||||
MicrosoftAdRemoveGroupMemberParams,
|
||||
MicrosoftAdRemoveGroupMemberResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_remove_group_member',
|
||||
name: 'Remove Azure AD Group Member',
|
||||
description: 'Remove a member from a group in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
memberId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID of the member to remove',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
const memberId = params.memberId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
if (!memberId) throw new Error('Member ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(memberId)}/$ref`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdRemoveGroupMemberParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
removed: true,
|
||||
groupId: params?.groupId ?? '',
|
||||
memberId: params?.memberId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
removed: { type: 'boolean', description: 'Whether the member was removed successfully' },
|
||||
groupId: { type: 'string', description: 'Group ID' },
|
||||
memberId: { type: 'string', description: 'Member ID that was removed' },
|
||||
},
|
||||
}
|
||||
230
apps/sim/tools/microsoft_ad/types.ts
Normal file
230
apps/sim/tools/microsoft_ad/types.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { OutputProperty, ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface MicrosoftAdBaseParams {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListUsersParams extends MicrosoftAdBaseParams {
|
||||
top?: number
|
||||
filter?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetUserParams extends MicrosoftAdBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdCreateUserParams extends MicrosoftAdBaseParams {
|
||||
displayName: string
|
||||
mailNickname: string
|
||||
userPrincipalName: string
|
||||
password: string
|
||||
accountEnabled: boolean
|
||||
givenName?: string
|
||||
surname?: string
|
||||
jobTitle?: string
|
||||
department?: string
|
||||
officeLocation?: string
|
||||
mobilePhone?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdUpdateUserParams extends MicrosoftAdBaseParams {
|
||||
userId: string
|
||||
displayName?: string
|
||||
givenName?: string
|
||||
surname?: string
|
||||
jobTitle?: string
|
||||
department?: string
|
||||
officeLocation?: string
|
||||
mobilePhone?: string
|
||||
accountEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftAdDeleteUserParams extends MicrosoftAdBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListGroupsParams extends MicrosoftAdBaseParams {
|
||||
top?: number
|
||||
filter?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetGroupParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdCreateGroupParams extends MicrosoftAdBaseParams {
|
||||
displayName: string
|
||||
mailNickname: string
|
||||
description?: string
|
||||
mailEnabled: boolean
|
||||
securityEnabled: boolean
|
||||
groupTypes?: string
|
||||
visibility?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdUpdateGroupParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
mailNickname?: string
|
||||
visibility?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdDeleteGroupParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListGroupMembersParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
top?: number
|
||||
}
|
||||
|
||||
export interface MicrosoftAdAddGroupMemberParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdRemoveGroupMemberParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export const USER_OUTPUT_PROPERTIES = {
|
||||
id: { type: 'string', description: 'User ID' },
|
||||
displayName: { type: 'string', description: 'Display name' },
|
||||
givenName: { type: 'string', description: 'First name' },
|
||||
surname: { type: 'string', description: 'Last name' },
|
||||
userPrincipalName: { type: 'string', description: 'User principal name (email)' },
|
||||
mail: { type: 'string', description: 'Email address' },
|
||||
jobTitle: { type: 'string', description: 'Job title' },
|
||||
department: { type: 'string', description: 'Department' },
|
||||
officeLocation: { type: 'string', description: 'Office location' },
|
||||
mobilePhone: { type: 'string', description: 'Mobile phone number' },
|
||||
accountEnabled: { type: 'boolean', description: 'Whether the account is enabled' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const GROUP_OUTPUT_PROPERTIES = {
|
||||
id: { type: 'string', description: 'Group ID' },
|
||||
displayName: { type: 'string', description: 'Display name' },
|
||||
description: { type: 'string', description: 'Group description' },
|
||||
mail: { type: 'string', description: 'Email address' },
|
||||
mailEnabled: { type: 'boolean', description: 'Whether mail is enabled' },
|
||||
mailNickname: { type: 'string', description: 'Mail nickname' },
|
||||
securityEnabled: { type: 'boolean', description: 'Whether security is enabled' },
|
||||
groupTypes: { type: 'array', description: 'Group types' },
|
||||
visibility: { type: 'string', description: 'Group visibility' },
|
||||
createdDateTime: { type: 'string', description: 'Creation date' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export const MEMBER_OUTPUT_PROPERTIES = {
|
||||
id: { type: 'string', description: 'Member ID' },
|
||||
displayName: { type: 'string', description: 'Display name' },
|
||||
mail: { type: 'string', description: 'Email address' },
|
||||
odataType: { type: 'string', description: 'Directory object type' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
export interface MicrosoftAdListUsersResponse extends ToolResponse {
|
||||
output: {
|
||||
users: Array<Record<string, unknown>>
|
||||
userCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetUserResponse extends ToolResponse {
|
||||
output: {
|
||||
user: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdCreateUserResponse extends ToolResponse {
|
||||
output: {
|
||||
user: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdUpdateUserResponse extends ToolResponse {
|
||||
output: {
|
||||
updated: boolean
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdDeleteUserResponse extends ToolResponse {
|
||||
output: {
|
||||
deleted: boolean
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListGroupsResponse extends ToolResponse {
|
||||
output: {
|
||||
groups: Array<Record<string, unknown>>
|
||||
groupCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetGroupResponse extends ToolResponse {
|
||||
output: {
|
||||
group: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdCreateGroupResponse extends ToolResponse {
|
||||
output: {
|
||||
group: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdUpdateGroupResponse extends ToolResponse {
|
||||
output: {
|
||||
updated: boolean
|
||||
groupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdDeleteGroupResponse extends ToolResponse {
|
||||
output: {
|
||||
deleted: boolean
|
||||
groupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListGroupMembersResponse extends ToolResponse {
|
||||
output: {
|
||||
members: Array<Record<string, unknown>>
|
||||
memberCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdAddGroupMemberResponse extends ToolResponse {
|
||||
output: {
|
||||
added: boolean
|
||||
groupId: string
|
||||
memberId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftAdRemoveGroupMemberResponse extends ToolResponse {
|
||||
output: {
|
||||
removed: boolean
|
||||
groupId: string
|
||||
memberId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MicrosoftAdResponse =
|
||||
| MicrosoftAdListUsersResponse
|
||||
| MicrosoftAdGetUserResponse
|
||||
| MicrosoftAdCreateUserResponse
|
||||
| MicrosoftAdUpdateUserResponse
|
||||
| MicrosoftAdDeleteUserResponse
|
||||
| MicrosoftAdListGroupsResponse
|
||||
| MicrosoftAdGetGroupResponse
|
||||
| MicrosoftAdCreateGroupResponse
|
||||
| MicrosoftAdUpdateGroupResponse
|
||||
| MicrosoftAdDeleteGroupResponse
|
||||
| MicrosoftAdListGroupMembersResponse
|
||||
| MicrosoftAdAddGroupMemberResponse
|
||||
| MicrosoftAdRemoveGroupMemberResponse
|
||||
91
apps/sim/tools/microsoft_ad/update_group.ts
Normal file
91
apps/sim/tools/microsoft_ad/update_group.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
MicrosoftAdUpdateGroupParams,
|
||||
MicrosoftAdUpdateGroupResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateGroupTool: ToolConfig<
|
||||
MicrosoftAdUpdateGroupParams,
|
||||
MicrosoftAdUpdateGroupResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_update_group',
|
||||
name: 'Update Azure AD Group',
|
||||
description: 'Update group properties in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
},
|
||||
displayName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Display name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group description',
|
||||
},
|
||||
mailNickname: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Mail alias',
|
||||
},
|
||||
visibility: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group visibility: "Private" or "Public"',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/groups/${encodeURIComponent(groupId)}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.displayName) body.displayName = params.displayName
|
||||
if (params.description) body.description = params.description
|
||||
if (params.mailNickname) body.mailNickname = params.mailNickname
|
||||
if (params.visibility) body.visibility = params.visibility
|
||||
return body
|
||||
},
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdUpdateGroupParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updated: true,
|
||||
groupId: params?.groupId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
updated: { type: 'boolean', description: 'Whether the update was successful' },
|
||||
groupId: { type: 'string', description: 'ID of the updated group' },
|
||||
},
|
||||
}
|
||||
119
apps/sim/tools/microsoft_ad/update_user.ts
Normal file
119
apps/sim/tools/microsoft_ad/update_user.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
MicrosoftAdUpdateUserParams,
|
||||
MicrosoftAdUpdateUserResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateUserTool: ToolConfig<
|
||||
MicrosoftAdUpdateUserParams,
|
||||
MicrosoftAdUpdateUserResponse
|
||||
> = {
|
||||
id: 'microsoft_ad_update_user',
|
||||
name: 'Update Azure AD User',
|
||||
description: 'Update user properties in Azure AD (Microsoft Entra ID)',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-ad',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Microsoft Graph API access token',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID or user principal name',
|
||||
},
|
||||
displayName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Display name',
|
||||
},
|
||||
givenName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'First name',
|
||||
},
|
||||
surname: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Last name',
|
||||
},
|
||||
jobTitle: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Job title',
|
||||
},
|
||||
department: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Department',
|
||||
},
|
||||
officeLocation: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Office location',
|
||||
},
|
||||
mobilePhone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Mobile phone number',
|
||||
},
|
||||
accountEnabled: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the account is enabled',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const userId = params.userId?.trim()
|
||||
if (!userId) throw new Error('User ID is required')
|
||||
return `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.displayName) body.displayName = params.displayName
|
||||
if (params.givenName) body.givenName = params.givenName
|
||||
if (params.surname) body.surname = params.surname
|
||||
if (params.jobTitle) body.jobTitle = params.jobTitle
|
||||
if (params.department) body.department = params.department
|
||||
if (params.officeLocation) body.officeLocation = params.officeLocation
|
||||
if (params.mobilePhone) body.mobilePhone = params.mobilePhone
|
||||
if (params.accountEnabled !== undefined) body.accountEnabled = params.accountEnabled
|
||||
return body
|
||||
},
|
||||
},
|
||||
transformResponse: async (_response: Response, params?: MicrosoftAdUpdateUserParams) => {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updated: true,
|
||||
userId: params?.userId ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
updated: { type: 'boolean', description: 'Whether the update was successful' },
|
||||
userId: { type: 'string', description: 'ID of the updated user' },
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user