diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index b26718f92f8..1336fd5502d 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -71,12 +71,14 @@ export const {service}Connector: ConnectorConfig = { ], listDocuments: async (accessToken, sourceConfig, cursor) => { - // Paginate via cursor, extract text, compute SHA-256 hash + // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) + // Or full documents with content (if list API returns content inline) // Return { documents: ExternalDocument[], nextCursor?, hasMore } }, getDocument: async (accessToken, sourceConfig, externalId) => { - // Return ExternalDocument or null + // Fetch full content for a single document + // Return ExternalDocument with contentDeferred: false, or null }, validateConfig: async (accessToken, sourceConfig) => { @@ -281,26 +283,110 @@ Every document returned from `listDocuments`/`getDocument` must include: { externalId: string // Source-specific unique ID title: string // Document title - content: string // Extracted plain text + content: string // Extracted plain text (or '' if contentDeferred) + contentDeferred?: boolean // true = content will be fetched via getDocument mimeType: 'text/plain' // Always text/plain (content is extracted) - contentHash: string // SHA-256 of content (change detection) + contentHash: string // Metadata-based hash for change detection sourceUrl?: string // Link back to original (stored on document record) metadata?: Record // Source-specific data (fed to mapTags) } ``` -## Content Hashing (Required) +## Content Deferral (Required for file/content-download connectors) -The sync engine uses content hashes for change detection: +**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents. + +This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved. + +### When to use `contentDeferred: true` + +- The service's list API does NOT return document content (only metadata) +- Content requires a separate download/export API call per document +- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub + +### When NOT to use `contentDeferred` + +- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes) +- No per-document API call is needed to get content + +### Content Hash Strategy + +Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content. + +Good metadata hash sources: +- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited +- Git blob SHA — unique per content version +- API-provided content hash (e.g., Dropbox `content_hash`) +- Version number (e.g., Confluence page version) + +Format: `{service}:{id}:{changeIndicator}` ```typescript -async function computeContentHash(content: string): Promise { - const data = new TextEncoder().encode(content) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('') +// Google Drive: modifiedTime changes on edit +contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}` + +// GitHub: blob SHA is a content-addressable hash +contentHash: `gitsha:${item.sha}` + +// Dropbox: API provides content_hash +contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}` + +// Confluence: version number increments on edit +contentHash: `confluence:${page.id}:${page.version.number}` +``` + +**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this. + +### Implementation Pattern + +```typescript +// 1. Create a stub function (sync, no API calls) +function fileToStub(file: ServiceFile): ExternalDocument { + return { + externalId: file.id, + title: file.name || 'Untitled', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `https://service.com/file/${file.id}`, + contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`, + metadata: { /* fields needed by mapTags */ }, + } +} + +// 2. listDocuments returns stubs (fast, metadata only) +listDocuments: async (accessToken, sourceConfig, cursor) => { + const response = await fetchWithRetry(listUrl, { ... }) + const files = (await response.json()).files + const documents = files.map(fileToStub) + return { documents, nextCursor, hasMore } +} + +// 3. getDocument fetches content and returns full doc with SAME contentHash +getDocument: async (accessToken, sourceConfig, externalId) => { + const metadata = await fetchWithRetry(metadataUrl, { ... }) + const file = await metadata.json() + if (file.trashed) return null + + try { + const content = await fetchContent(accessToken, file) + if (!content.trim()) return null + const stub = fileToStub(file) + return { ...stub, content, contentDeferred: false } + } catch (error) { + logger.warn(`Failed to fetch content for: ${file.name}`, { error }) + return null + } } ``` +### Reference Implementations + +- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash +- **GitHub**: `connectors/github/github.ts` — git blob SHA hash +- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash +- **Confluence**: `connectors/confluence/confluence.ts` — version number hash + ## tagDefinitions — Declared Tag Definitions Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes. @@ -409,7 +495,10 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { ## Reference Implementations -- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching +- **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination +- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument` +- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing +- **OAuth + inline content**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching - **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth ## Checklist @@ -425,7 +514,9 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { - `selectorKey` exists in `hooks/selectors/registry.ts` - `dependsOn` references selector field IDs (not `canonicalParamId`) - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` -- [ ] `listDocuments` handles pagination and computes content hashes +- [ ] `listDocuments` handles pagination with metadata-based content hashes +- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) +- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument` - [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative) - [ ] `metadata` includes source-specific data for tag mapping - [ ] `tagDefinitions` declared for each semantic key returned by `mapTags` diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index 4bafaa07dcb..ceae7d4542c 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -141,12 +141,24 @@ For each API endpoint the connector calls: ## Step 6: Validate Data Transformation +### Content Deferral (CRITICAL) +Connectors that require per-document API calls to fetch content (file download, export, blocks fetch) MUST use `contentDeferred: true`. This is the standard pattern for reliability — without it, content downloads during listing can exhaust the sync task's time budget before any documents are saved. + +- [ ] If the connector downloads content per-doc during `listDocuments`, it MUST use `contentDeferred: true` instead +- [ ] `listDocuments` returns lightweight stubs with `content: ''` and `contentDeferred: true` +- [ ] `getDocument` fetches actual content and returns the full document with `contentDeferred: false` +- [ ] A shared stub function (e.g., `fileToStub`) is used by both `listDocuments` and `getDocument` to guarantee `contentHash` consistency +- [ ] `contentHash` is metadata-based (e.g., `service:{id}:{modifiedTime}`), NOT content-based — it must be derivable from list metadata alone +- [ ] The `contentHash` is identical whether produced by `listDocuments` or `getDocument` + +Connectors where the list API already returns content inline (e.g., Slack messages, Reddit posts) do NOT need `contentDeferred`. + ### ExternalDocument Construction - [ ] `externalId` is a stable, unique identifier from the source API - [ ] `title` is extracted from the correct field and has a sensible fallback (e.g., `'Untitled'`) - [ ] `content` is plain text — HTML content is stripped using `htmlToPlainText` from `@/connectors/utils` - [ ] `mimeType` is `'text/plain'` -- [ ] `contentHash` is computed using `computeContentHash` from `@/connectors/utils` +- [ ] `contentHash` uses a metadata-based format (e.g., `service:{id}:{modifiedTime}`) for connectors with `contentDeferred: true`, or `computeContentHash` from `@/connectors/utils` for inline-content connectors - [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative) - [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions` @@ -200,6 +212,8 @@ For each API endpoint the connector calls: - [ ] Fetches a single document by `externalId` - [ ] Returns `null` for 404 / not found (does not throw) - [ ] Returns the same `ExternalDocument` shape as `listDocuments` +- [ ] If `listDocuments` uses `contentDeferred: true`, `getDocument` MUST fetch actual content and return `contentDeferred: false` +- [ ] If `listDocuments` uses `contentDeferred: true`, `getDocument` MUST use the same stub function to ensure `contentHash` is identical - [ ] Handles all content types that `listDocuments` can produce (e.g., if `listDocuments` returns both pages and blogposts, `getDocument` must handle both — not hardcode one endpoint) - [ ] Forwards `syncContext` if it needs cached state (user names, field maps, etc.) - [ ] Error handling is graceful (catches, logs, returns null or throws with context) @@ -253,6 +267,8 @@ Group findings by severity: - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping +- Per-document content download in `listDocuments` without `contentDeferred: true` — causes sync timeouts for large document sets +- `contentHash` mismatch between `listDocuments` stub and `getDocument` return — causes unnecessary re-processing every sync **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -300,6 +316,7 @@ After fixing, confirm: - [ ] Validated scopes are sufficient for all API endpoints the connector calls - [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`) - [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps +- [ ] Validated content deferral: `contentDeferred: true` used when per-doc content fetch required, metadata-based `contentHash` consistent between stub and `getDocument` - [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing - [ ] Validated tag definitions match mapTags output, correct fieldTypes - [ ] Validated config fields: canonical pairs, selector keys, required flags diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 63ec07c288d..ef1aa5fc34e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.10-alpine +FROM oven/bun:1.3.11-alpine # Install necessary packages for development RUN apk add --no-cache \ diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index 08576b5c396..3a3d89c0713 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 90dc2d370f7..de8c59c9da4 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Cache Bun dependencies uses: actions/cache@v4 @@ -122,7 +122,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 6f246e829e7..8a3f543c172 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 3448cbb5504..0a9bea31400 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 4bd88074b5c..e826d4395fa 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c3ac097abff..b8fab8a77c4 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.11 - name: Setup Node uses: actions/setup-node@v4 diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 06e06b83ead..91eeb72f972 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -8,8 +8,6 @@ export default function RootLayout({ children }: { children: ReactNode }) { export const viewport: Viewport = { width: 'device-width', initialScale: 1, - maximumScale: 1, - userScalable: false, themeColor: [ { media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#0c0c0c' }, diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 0cf5773ca48..b79a166e901 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4931,6 +4931,18 @@ export function KalshiIcon(props: SVGProps) { ) } +export function KetchIcon(props: SVGProps) { + return ( + + + + + ) +} + export function PolymarketIcon(props: SVGProps) { return ( @@ -5091,6 +5103,17 @@ export function GrainIcon(props: SVGProps) { ) } +export function GranolaIcon(props: SVGProps) { + return ( + + + + ) +} + export function CirclebackIcon(props: SVGProps) { const id = useId() const patternId = `circleback_pattern_${id}` diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 900229eaad7..244607c63ca 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -74,6 +74,7 @@ import { GoogleVaultIcon, GrafanaIcon, GrainIcon, + GranolaIcon, GreenhouseIcon, GreptileIcon, HexIcon, @@ -88,6 +89,7 @@ import { JiraIcon, JiraServiceManagementIcon, KalshiIcon, + KetchIcon, LangsmithIcon, LemlistIcon, LinearIcon, @@ -247,6 +249,7 @@ export const blockTypeToIconMap: Record = { google_vault: GoogleVaultIcon, grafana: GrafanaIcon, grain: GrainIcon, + granola: GranolaIcon, greenhouse: GreenhouseIcon, greptile: GreptileIcon, hex: HexIcon, @@ -262,6 +265,7 @@ export const blockTypeToIconMap: Record = { jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, kalshi_v2: KalshiIcon, + ketch: KetchIcon, knowledge: PackageSearchIcon, langsmith: LangsmithIcon, lemlist: LemlistIcon, diff --git a/apps/docs/content/docs/en/tools/granola.mdx b/apps/docs/content/docs/en/tools/granola.mdx new file mode 100644 index 00000000000..88eb50622a3 --- /dev/null +++ b/apps/docs/content/docs/en/tools/granola.mdx @@ -0,0 +1,92 @@ +--- +title: Granola +description: Access meeting notes and transcripts from Granola +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Granola into your workflow to retrieve meeting notes, summaries, attendees, and transcripts. + + + +## Tools + +### `granola_list_notes` + +Lists meeting notes from Granola with optional date filters and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Granola API key | +| `createdBefore` | string | No | Return notes created before this date \(ISO 8601\) | +| `createdAfter` | string | No | Return notes created after this date \(ISO 8601\) | +| `updatedAfter` | string | No | Return notes updated after this date \(ISO 8601\) | +| `cursor` | string | No | Pagination cursor from a previous response | +| `pageSize` | number | No | Number of notes per page \(1-30, default 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | json | List of meeting notes | +| ↳ `id` | string | Note ID | +| ↳ `title` | string | Note title | +| ↳ `ownerName` | string | Note owner name | +| ↳ `ownerEmail` | string | Note owner email | +| ↳ `createdAt` | string | Creation timestamp | +| ↳ `updatedAt` | string | Last update timestamp | +| `hasMore` | boolean | Whether more notes are available | +| `cursor` | string | Pagination cursor for the next page | + +### `granola_get_note` + +Retrieves a specific meeting note from Granola by ID, including summary, attendees, calendar event details, and optionally the transcript. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Granola API key | +| `noteId` | string | Yes | The note ID \(e.g., not_1d3tmYTlCICgjy\) | +| `includeTranscript` | string | No | Whether to include the meeting transcript | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Note ID | +| `title` | string | Note title | +| `ownerName` | string | Note owner name | +| `ownerEmail` | string | Note owner email | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `summaryText` | string | Plain text summary of the meeting | +| `summaryMarkdown` | string | Markdown-formatted summary of the meeting | +| `attendees` | json | Meeting attendees | +| ↳ `name` | string | Attendee name | +| ↳ `email` | string | Attendee email | +| `folders` | json | Folders the note belongs to | +| ↳ `id` | string | Folder ID | +| ↳ `name` | string | Folder name | +| `calendarEventTitle` | string | Calendar event title | +| `calendarOrganiser` | string | Calendar event organiser email | +| `calendarEventId` | string | Calendar event ID | +| `scheduledStartTime` | string | Scheduled start time | +| `scheduledEndTime` | string | Scheduled end time | +| `invitees` | json | Calendar event invitee emails | +| `transcript` | json | Meeting transcript entries \(only if requested\) | +| ↳ `speaker` | string | Speaker source \(microphone or speaker\) | +| ↳ `text` | string | Transcript text | +| ↳ `startTime` | string | Segment start time | +| ↳ `endTime` | string | Segment end time | + + diff --git a/apps/docs/content/docs/en/tools/ketch.mdx b/apps/docs/content/docs/en/tools/ketch.mdx new file mode 100644 index 00000000000..145fec7ddbb --- /dev/null +++ b/apps/docs/content/docs/en/tools/ketch.mdx @@ -0,0 +1,149 @@ +--- +title: Ketch +description: Manage privacy consent, subscriptions, and data subject rights +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Ketch](https://www.ketch.com/) is an AI-powered privacy, consent, and data governance platform that helps organizations automate compliance with global privacy regulations. It provides tools for managing consent preferences, handling data subject rights requests, and controlling subscription communications. + +With Ketch, you can: + +- **Retrieve consent status**: Query the current consent preferences for any data subject across configured purposes and legal bases +- **Update consent preferences**: Set or modify consent for specific purposes (e.g., analytics, marketing) with the appropriate legal basis (opt-in, opt-out, disclosure) +- **Manage subscriptions**: Get and update subscription topic preferences and global controls across contact methods like email and SMS +- **Invoke data subject rights**: Submit privacy rights requests including data access, deletion, correction, and processing restriction under regulations like GDPR and CCPA + +To use Ketch, drop the Ketch block into your workflow and provide your organization code, property code, and environment code. The Ketch Web API is a public API — no API key or OAuth credentials are required. Identity is determined by the organization and property codes along with the data subject's identity (e.g., email address). + +These capabilities let you automate privacy compliance workflows, respond to user consent changes in real time, and manage data subject rights requests as part of your broader automation pipelines. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Ketch into the workflow. Retrieve and update consent preferences, manage subscription topics and controls, and submit data subject rights requests for access, deletion, correction, or processing restriction. + + + +## Tools + +### `ketch_get_consent` + +Retrieve consent status for a data subject. Returns the current consent preferences for each configured purpose. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organizationCode` | string | Yes | Ketch organization code | +| `propertyCode` | string | Yes | Digital property code defined in Ketch | +| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) | +| `jurisdictionCode` | string | No | Jurisdiction code \(e.g., "gdpr", "ccpa"\) | +| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) | +| `purposes` | json | No | Optional purposes to filter the consent query | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `purposes` | object | Map of purpose codes to consent status and legal basis | +| ↳ `allowed` | string | Consent status for the purpose: "granted" or "denied" | +| ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) | +| `vendors` | object | Map of vendor consent statuses | + +### `ketch_set_consent` + +Update consent preferences for a data subject. Sets the consent status for specified purposes with the appropriate legal basis. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organizationCode` | string | Yes | Ketch organization code | +| `propertyCode` | string | Yes | Digital property code defined in Ketch | +| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) | +| `jurisdictionCode` | string | No | Jurisdiction code \(e.g., "gdpr", "ccpa"\) | +| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) | +| `purposes` | json | Yes | Map of purpose codes to consent settings \(e.g., \{"analytics": \{"allowed": "granted", "legalBasisCode": "consent_optin"\}\}\) | +| `collectedAt` | number | No | UNIX timestamp when consent was collected \(defaults to current time\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `purposes` | object | Updated consent status map of purpose codes to consent settings | +| ↳ `allowed` | string | Consent status for the purpose: "granted" or "denied" | +| ↳ `legalBasisCode` | string | Legal basis code \(e.g., "consent_optin", "consent_optout", "disclosure", "other"\) | + +### `ketch_get_subscriptions` + +Retrieve subscription preferences for a data subject. Returns the current subscription topic and control statuses. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organizationCode` | string | Yes | Ketch organization code | +| `propertyCode` | string | Yes | Digital property code defined in Ketch | +| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) | +| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topics` | object | Map of topic codes to contact method settings \(e.g., \{"newsletter": \{"email": \{"status": "granted"\}\}\}\) | +| `controls` | object | Map of control codes to settings \(e.g., \{"global_unsubscribe": \{"status": "denied"\}\}\) | + +### `ketch_set_subscriptions` + +Update subscription preferences for a data subject. Sets topic and control statuses for email, SMS, and other contact methods. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organizationCode` | string | Yes | Ketch organization code | +| `propertyCode` | string | Yes | Digital property code defined in Ketch | +| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) | +| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) | +| `topics` | json | No | Map of topic codes to contact method settings \(e.g., \{"newsletter": \{"email": \{"status": "granted"\}, "sms": \{"status": "denied"\}\}\}\) | +| `controls` | json | No | Map of control codes to settings \(e.g., \{"global_unsubscribe": \{"status": "denied"\}\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the subscription preferences were updated | + +### `ketch_invoke_right` + +Submit a data subject rights request (e.g., access, delete, correct, restrict processing). Initiates a privacy rights workflow in Ketch. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `organizationCode` | string | Yes | Ketch organization code | +| `propertyCode` | string | Yes | Digital property code defined in Ketch | +| `environmentCode` | string | Yes | Environment code defined in Ketch \(e.g., "production"\) | +| `jurisdictionCode` | string | Yes | Jurisdiction code \(e.g., "gdpr", "ccpa"\) | +| `rightCode` | string | Yes | Privacy right code to invoke \(e.g., "access", "delete", "correct", "restrict_processing"\) | +| `identities` | json | Yes | Identity map \(e.g., \{"email": "user@example.com"\}\) | +| `userData` | json | No | Optional data subject information \(e.g., \{"email": "user@example.com", "firstName": "John", "lastName": "Doe"\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the rights request was submitted | +| `message` | string | Response message from Ketch | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 9ff8f3ff78a..a52a54cf0f8 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -68,6 +68,7 @@ "google_vault", "grafana", "grain", + "granola", "greenhouse", "greptile", "hex", @@ -83,6 +84,7 @@ "jira", "jira_service_management", "kalshi", + "ketch", "knowledge", "langsmith", "lemlist", diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx index fe4c524ecab..3aee420922f 100644 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -14,8 +14,8 @@ export default function AuthLayoutClient({ children }: { children: React.ReactNo return ( -
-
+
+
diff --git a/apps/sim/app/(auth)/components/auth-background.tsx b/apps/sim/app/(auth)/components/auth-background.tsx index 1291c151de8..dc284dbd66e 100644 --- a/apps/sim/app/(auth)/components/auth-background.tsx +++ b/apps/sim/app/(auth)/components/auth-background.tsx @@ -9,7 +9,7 @@ type AuthBackgroundProps = { export default function AuthBackground({ className, children }: AuthBackgroundProps) { return (
-
+
{children}
diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts new file mode 100644 index 00000000000..02d1d5e47ed --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-button-classes.ts @@ -0,0 +1,3 @@ +/** Shared className for primary auth form submit buttons across all auth pages. */ +export const AUTH_SUBMIT_BTN = + 'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const diff --git a/apps/sim/app/(auth)/components/branded-button.tsx b/apps/sim/app/(auth)/components/branded-button.tsx deleted file mode 100644 index 245cd7c9c6e..00000000000 --- a/apps/sim/app/(auth)/components/branded-button.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' - -import { forwardRef, useState } from 'react' -import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react' -import { cn } from '@/lib/core/utils/cn' -import { useBrandConfig } from '@/ee/whitelabeling' - -export interface BrandedButtonProps extends React.ButtonHTMLAttributes { - loading?: boolean - loadingText?: string - showArrow?: boolean - fullWidth?: boolean -} - -/** - * Branded button for auth and status pages. - * Default: white button matching the landing page "Get started" style. - * Whitelabel: uses the brand's primary color as background with white text. - */ -export const BrandedButton = forwardRef( - ( - { - children, - loading = false, - loadingText, - showArrow = true, - fullWidth = true, - className, - disabled, - onMouseEnter, - onMouseLeave, - ...props - }, - ref - ) => { - const brand = useBrandConfig() - const hasCustomColor = brand.isWhitelabeled && Boolean(brand.theme?.primaryColor) - const [isHovered, setIsHovered] = useState(false) - - const handleMouseEnter = (e: React.MouseEvent) => { - setIsHovered(true) - onMouseEnter?.(e) - } - - const handleMouseLeave = (e: React.MouseEvent) => { - setIsHovered(false) - onMouseLeave?.(e) - } - - return ( - - ) - } -) - -BrandedButton.displayName = 'BrandedButton' diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index c373942becb..2800c90ef5d 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -4,23 +4,18 @@ import { useRouter } from 'next/navigation' import { Button } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' import { cn } from '@/lib/core/utils/cn' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' interface SSOLoginButtonProps { callbackURL?: string className?: string - // Visual variant for button styling and placement contexts - // - 'primary' matches the main auth action button style - // - 'outline' matches social provider buttons variant?: 'primary' | 'outline' - // Optional class used when variant is primary to match brand/gradient - primaryClassName?: string } export function SSOLoginButton({ callbackURL, className, variant = 'outline', - primaryClassName, }: SSOLoginButtonProps) { const router = useRouter() @@ -33,11 +28,6 @@ export function SSOLoginButton({ router.push(ssoUrl) } - const primaryBtnClasses = cn( - primaryClassName || 'branded-button-gradient', - 'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200' - ) - const outlineBtnClasses = cn('w-full rounded-[10px]') return ( @@ -45,7 +35,7 @@ export function SSOLoginButton({ type='button' onClick={handleSSOClick} variant={variant === 'outline' ? 'outline' : undefined} - className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)} + className={cn(variant === 'outline' ? outlineBtnClasses : AUTH_SUBMIT_BTN, className)} > Sign in with SSO diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx index 9d62e776e44..417c5a582da 100644 --- a/apps/sim/app/(auth)/components/status-page-layout.tsx +++ b/apps/sim/app/(auth)/components/status-page-layout.tsx @@ -18,18 +18,18 @@ export function StatusPageLayout({ }: StatusPageLayoutProps) { return ( -
-
+
+
-

+

{title}

-

+

{description}

diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 5b9dc64fba2..6dad3bd6ac1 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -11,12 +11,12 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
Need help?{' '} Contact support diff --git a/apps/sim/app/(auth)/login/loading.tsx b/apps/sim/app/(auth)/login/loading.tsx index e42fb32e2e2..c21272e110d 100644 --- a/apps/sim/app/(auth)/login/loading.tsx +++ b/apps/sim/app/(auth)/login/loading.tsx @@ -4,21 +4,21 @@ export default function LoginLoading() { return (
-
+
-
+
- - -
+ + +
- +
) } diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 3013a4e284e..8a43548acb4 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff, Loader2 } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { @@ -20,10 +20,9 @@ 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' -import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' -import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' const logger = createLogger('LoginForm') @@ -87,8 +86,6 @@ export default function LoginPage({ const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) const [formError, setFormError] = useState(null) - const buttonClass = useBrandedButtonClass() - const callbackUrlParam = searchParams?.get('callbackUrl') const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false const invalidCallbackRef = useRef(false) @@ -174,7 +171,7 @@ export default function LoginPage({ callbackURL: safeCallbackUrl, }, { - onError: (ctx) => { + onError: (ctx: any) => { logger.error('Login error:', ctx.error) if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) { @@ -342,10 +339,10 @@ export default function LoginPage({ return ( <>
-

+

Sign in

-

+

Enter your details

@@ -353,11 +350,7 @@ export default function LoginPage({ {/* SSO Login Button (primary top-only when it is the only method) */} {showTopSSO && (
- +
)} @@ -399,7 +392,7 @@ export default function LoginPage({ @@ -426,7 +419,7 @@ export default function LoginPage({
)} - - Sign in - + )} @@ -469,10 +464,12 @@ export default function LoginPage({ {showDivider && (
-
+
- Or continue with + + Or continue with +
)} @@ -486,11 +483,7 @@ export default function LoginPage({ callbackURL={callbackUrl} > {ssoEnabled && !hasOnlySSO && ( - + )}
@@ -502,20 +495,20 @@ export default function LoginPage({ Don't have an account? Sign up
)} -
+
By signing in, you agree to our{' '} Terms of Service {' '} @@ -524,7 +517,7 @@ export default function LoginPage({ href='/privacy' target='_blank' rel='noopener noreferrer' - className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline' + className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline' > Privacy Policy @@ -569,14 +562,16 @@ export default function LoginPage({

{resetStatus.message}

)} - - Send Reset Link - +
diff --git a/apps/sim/app/(auth)/oauth/consent/loading.tsx b/apps/sim/app/(auth)/oauth/consent/loading.tsx index b6c3e192ced..40e029b4d25 100644 --- a/apps/sim/app/(auth)/oauth/consent/loading.tsx +++ b/apps/sim/app/(auth)/oauth/consent/loading.tsx @@ -3,16 +3,16 @@ import { Skeleton } from '@/components/emcn' export default function OAuthConsentLoading() { return (
-
+
- - - - -
+ + + + +
diff --git a/apps/sim/app/(auth)/oauth/consent/page.tsx b/apps/sim/app/(auth)/oauth/consent/page.tsx index 0138a7dd6d9..82127d22f6b 100644 --- a/apps/sim/app/(auth)/oauth/consent/page.tsx +++ b/apps/sim/app/(auth)/oauth/consent/page.tsx @@ -1,12 +1,12 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { ArrowLeftRight } from 'lucide-react' +import { ArrowLeftRight, Loader2 } from 'lucide-react' import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/emcn' import { signOut, useSession } from '@/lib/auth/auth-client' -import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' const SCOPE_DESCRIPTIONS: Record = { openid: 'Verify your identity', @@ -127,10 +127,10 @@ export default function OAuthConsentPage() { return (
-

+

Authorize Application

-

+

Loading application details...

@@ -142,15 +142,17 @@ export default function OAuthConsentPage() { return (
-

+

Authorization Error

-

+

{error}

- router.push('/')}>Return to Home +
) @@ -170,11 +172,11 @@ export default function OAuthConsentPage() { className='rounded-[10px]' /> ) : ( -
+
{(clientName ?? '?').charAt(0).toUpperCase()}
)} - + Sim
-

+

Authorize Application

-

- {clientName} is requesting access to - your account +

+ {clientName} is requesting + access to your account

{session?.user && ( -
+
{session.user.image ? ( ) : ( -
+
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
)}
{session.user.name && ( -

{session.user.name}

+

{session.user.name}

)} -

{session.user.email}

+

+ {session.user.email} +

@@ -228,11 +232,14 @@ export default function OAuthConsentPage() { {scopes.length > 0 && (
-
-

This will allow the application to:

+
+

This will allow the application to:

    {scopes.map((s) => ( -
  • +
  • {SCOPE_DESCRIPTIONS[s] ?? s}
  • @@ -252,15 +259,20 @@ export default function OAuthConsentPage() { > Deny - handleConsent(true)} + disabled={submitting} + className={AUTH_SUBMIT_BTN} > - Allow - + {submitting ? ( + + + Authorizing... + + ) : ( + 'Allow' + )} +
) diff --git a/apps/sim/app/(auth)/reset-password/loading.tsx b/apps/sim/app/(auth)/reset-password/loading.tsx index cd6a25a9b4f..02a11005e1b 100644 --- a/apps/sim/app/(auth)/reset-password/loading.tsx +++ b/apps/sim/app/(auth)/reset-password/loading.tsx @@ -4,13 +4,13 @@ export default function ResetPasswordLoading() { return (
- -
+ +
- - + +
) } diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx index a48eedc5f8a..8985af8dc8c 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx @@ -69,10 +69,10 @@ function ResetPasswordContent() { return ( <>
-

+

Reset your password

-

+

Enter a new password for your account

@@ -87,10 +87,10 @@ function ResetPasswordContent() { />
-
+
Back to login diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 8f5ef10aafc..0ff43b0561a 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -1,10 +1,10 @@ 'use client' import { useState } from 'react' -import { Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff, Loader2 } from 'lucide-react' import { Input, Label } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' -import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' interface RequestResetFormProps { email: string @@ -46,7 +46,7 @@ export function RequestResetForm({ disabled={isSubmitting} required /> -

+

We'll send a password reset link to this email address.

@@ -54,21 +54,26 @@ export function RequestResetForm({ {/* Status message display */} {statusType && statusMessage && (

{statusMessage}

)}
- - Send Reset Link - + ) } @@ -162,7 +167,7 @@ export function SetNewPasswordForm({ ) } diff --git a/apps/sim/app/(auth)/signup/loading.tsx b/apps/sim/app/(auth)/signup/loading.tsx index 4960851a805..21e15d9f713 100644 --- a/apps/sim/app/(auth)/signup/loading.tsx +++ b/apps/sim/app/(auth)/signup/loading.tsx @@ -4,25 +4,25 @@ export default function SignupLoading() { return (
-
+
-
+
-
+
- - -
+ + +
- +
) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 1054259a41e..c3db07b511e 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -3,7 +3,7 @@ import { Suspense, useMemo, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff, Loader2 } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { Input, Label } from '@/components/emcn' @@ -11,10 +11,9 @@ import { client, useSession } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' -import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' -import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' const logger = createLogger('SignupForm') @@ -96,8 +95,6 @@ function SignupFormContent({ const captchaResolveRef = useRef<((token: string) => void) | null>(null) const captchaRejectRef = useRef<((reason: Error) => void) | null>(null) const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), []) - const buttonClass = useBrandedButtonClass() - const redirectUrl = useMemo( () => searchParams.get('redirect') || searchParams.get('callbackUrl') || '', [searchParams] @@ -363,10 +360,10 @@ function SignupFormContent({ return ( <>
-

+

Create an account

-

+

Create an account or log in

@@ -380,115 +377,150 @@ function SignupFormContent({ return hasOnlySSO })() && (
- +
)} {/* Email/Password Form - show unless explicitly disabled */} {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
+
- 0 && - 'border-red-500 focus:border-red-500' - )} - /> - {showNameValidationError && nameErrors.length > 0 && ( -
- {nameErrors.map((error, index) => ( -

{error}

- ))} +
+ 0 && + 'border-red-500 focus:border-red-500' + )} + /> +
0 + ? 'grid-rows-[1fr]' + : 'grid-rows-[0fr]' + )} + aria-live={showNameValidationError && nameErrors.length > 0 ? 'polite' : 'off'} + > +
+
+ {nameErrors.map((error, index) => ( +

{error}

+ ))} +
+
- )} -
-
-
-
- 0)) && - 'border-red-500 focus:border-red-500' - )} - /> - {showEmailValidationError && emailErrors.length > 0 && ( -
- {emailErrors.map((error, index) => ( -

{error}

- ))} -
- )} - {emailError && !showEmailValidationError && ( -
-

{emailError}

-
- )}
- +
0 && + (emailError || (showEmailValidationError && emailErrors.length > 0)) && 'border-red-500 focus:border-red-500' )} /> - +
+
+ {showEmailValidationError && emailErrors.length > 0 ? ( + emailErrors.map((error, index) =>

{error}

) + ) : emailError && !showEmailValidationError ? ( +

{emailError}

+ ) : null} +
+
+
+
+
+
+
+
- {showValidationError && passwordErrors.length > 0 && ( -
- {passwordErrors.map((error, index) => ( -

{error}

- ))} +
+
+ 0 && + 'border-red-500 focus:border-red-500' + )} + /> + +
+
0 + ? 'grid-rows-[1fr]' + : 'grid-rows-[0fr]' + )} + aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'} + > +
+
+ {passwordErrors.map((error, index) => ( +

{error}

+ ))} +
+
- )} +
@@ -509,14 +541,16 @@ function SignupFormContent({
)} - - Create account - + )} @@ -532,10 +566,12 @@ function SignupFormContent({ })() && (
-
+
- Or continue with + + Or continue with +
)} @@ -560,33 +596,29 @@ function SignupFormContent({ isProduction={isProduction} > {isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && ( - + )}
)} -
+
Already have an account? Sign in
-
+
By creating an account, you agree to our{' '} Terms of Service {' '} @@ -595,7 +627,7 @@ function SignupFormContent({ href='/privacy' target='_blank' rel='noopener noreferrer' - className='text-[#999] underline-offset-4 transition hover:text-[#ECECEC] hover:underline' + className='text-[var(--landing-text-muted)] underline-offset-4 transition hover:text-[var(--landing-text)] hover:underline' > Privacy Policy diff --git a/apps/sim/app/(auth)/sso/loading.tsx b/apps/sim/app/(auth)/sso/loading.tsx index 11acf1bec75..76209c2f50e 100644 --- a/apps/sim/app/(auth)/sso/loading.tsx +++ b/apps/sim/app/(auth)/sso/loading.tsx @@ -4,13 +4,13 @@ export default function SSOLoading() { return (
- -
+ +
- - + +
) } diff --git a/apps/sim/app/(auth)/verify/loading.tsx b/apps/sim/app/(auth)/verify/loading.tsx index b41b5784f50..7460e3295a9 100644 --- a/apps/sim/app/(auth)/verify/loading.tsx +++ b/apps/sim/app/(auth)/verify/loading.tsx @@ -4,9 +4,9 @@ export default function VerifyLoading() { return (
- - - + + +
) } diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 392811ff2ed..9f5b7e47590 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,10 +1,11 @@ 'use client' import { Suspense, useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' -import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { useVerification } from '@/app/(auth)/verify/use-verification' interface VerifyContentProps { @@ -59,10 +60,10 @@ function VerificationForm({ return ( <>
-

+

{isVerified ? 'Email Verified!' : 'Verify Your Email'}

-

+

{isVerified ? 'Your email has been verified. Redirecting to dashboard...' : !isEmailVerificationEnabled @@ -78,7 +79,7 @@ function VerificationForm({ {!isVerified && isEmailVerificationEnabled && (

-

+

Enter the 6-digit code to verify your account. {hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}

@@ -110,27 +111,33 @@ function VerificationForm({ )}
- - Verify Email - + {isLoading ? ( + + + Verifying... + + ) : ( + 'Verify Email' + )} + {hasEmailService && (
-

+

Didn't receive a code?{' '} {countdown > 0 ? ( - Resend in {countdown}s + Resend in{' '} + {countdown}s ) : (

)} -
+
@@ -166,8 +173,8 @@ function VerificationFormFallback() { return (
-
-
+
+
) diff --git a/apps/sim/app/(landing)/actions/github.ts b/apps/sim/app/(home)/actions/github.ts similarity index 100% rename from apps/sim/app/(landing)/actions/github.ts rename to apps/sim/app/(home)/actions/github.ts diff --git a/apps/sim/app/(home)/components/collaboration/collaboration.tsx b/apps/sim/app/(home)/components/collaboration/collaboration.tsx index e9e760a8525..302bcc05904 100644 --- a/apps/sim/app/(home)/components/collaboration/collaboration.tsx +++ b/apps/sim/app/(home)/components/collaboration/collaboration.tsx @@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) { }} > {Array.from({ length: cols * rows }, (_, i) => ( -
+
))}
) @@ -89,7 +89,7 @@ function VikhyathCursor() {
-
+
Vikhyath
@@ -113,7 +113,7 @@ function AlexaCursor() {
-
+
Alexa
@@ -143,7 +143,7 @@ function YouCursor({ x, y, visible }: YouCursorProps) { -
+
You
@@ -212,7 +212,7 @@ export default function Collaboration() { ref={sectionRef} id='collaboration' aria-labelledby='collaboration-heading' - className='bg-[#1C1C1C]' + className='bg-[var(--landing-bg)]' style={{ cursor: isHovering ? 'none' : 'auto' }} onMouseMove={handleMouseMove} onMouseEnter={handleMouseEnter} @@ -222,7 +222,7 @@ export default function Collaboration() { - - - ) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/landing-canvas/landing-flow.tsx b/apps/sim/app/(landing)/components/hero/components/landing-canvas/landing-flow.tsx deleted file mode 100644 index c63cba043ae..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/landing-canvas/landing-flow.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'use client' - -import React from 'react' -import ReactFlow, { applyNodeChanges, type NodeChange, useReactFlow } from 'reactflow' -import 'reactflow/dist/style.css' -import { LandingLoopNode } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-loop-node' -import { LandingNode } from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-node' -import { - CARD_WIDTH, - type LandingCanvasProps, -} from '@/app/(landing)/components/hero/components/landing-canvas/landing-canvas' -import { LandingEdge } from '@/app/(landing)/components/hero/components/landing-canvas/landing-edge/landing-edge' - -/** - * Props for the LandingFlow component - */ -export interface LandingFlowProps extends LandingCanvasProps { - /** Reference to the wrapper element */ - wrapperRef: React.RefObject -} - -/** - * React Flow wrapper component for the landing canvas - * Handles viewport control, auto-panning, and node/edge rendering - * @param props - Component properties including nodes, edges, and viewport control - * @returns A configured React Flow instance - */ -export function LandingFlow({ - nodes, - edges, - groupBox, - worldWidth, - wrapperRef, - viewportApiRef, -}: LandingFlowProps) { - const { setViewport, getViewport } = useReactFlow() - const [rfReady, setRfReady] = React.useState(false) - const [localNodes, setLocalNodes] = React.useState(nodes) - - // Update local nodes when props change - React.useEffect(() => { - setLocalNodes(nodes) - }, [nodes]) - - // Handle node changes (dragging) - const onNodesChange = React.useCallback((changes: NodeChange[]) => { - setLocalNodes((nds) => applyNodeChanges(changes, nds)) - }, []) - - // Node and edge types map - const nodeTypes = React.useMemo( - () => ({ - landing: LandingNode, - landingLoop: LandingLoopNode, - group: LandingLoopNode, // Use our custom loop node for group type - }), - [] - ) - const edgeTypes = React.useMemo(() => ({ landingEdge: LandingEdge }), []) - - // Compose nodes with optional group overlay - const flowNodes = localNodes - - // Auto-pan to the right only if content overflows the wrapper - React.useEffect(() => { - const el = wrapperRef.current as HTMLDivElement | null - if (!el || !rfReady || localNodes.length === 0) return - - const containerWidth = el.clientWidth - // Derive overflow from actual node positions for accuracy - const PAD = 16 - const maxRight = localNodes.reduce((m, n) => Math.max(m, (n.position?.x ?? 0) + CARD_WIDTH), 0) - const contentWidth = Math.max(worldWidth, maxRight + PAD) - const overflow = Math.max(0, contentWidth - containerWidth) - - // Delay pan so initial nodes are visible briefly - const timer = window.setTimeout(() => { - if (overflow > 12) { - setViewport({ x: -overflow, y: 0, zoom: 1 }, { duration: 900 }) - } - }, 1400) - - return () => window.clearTimeout(timer) - }, [worldWidth, wrapperRef, setViewport, rfReady, localNodes]) - - return ( - { - setRfReady(true) - // Expose limited viewport API for outer timeline to pan smoothly - viewportApiRef.current = { - panTo: (x: number, y: number, options?: { duration?: number }) => { - setViewport({ x, y, zoom: 1 }, { duration: options?.duration ?? 0 }) - }, - getViewport: () => getViewport(), - } - }} - className='h-full w-full' - style={{ - // Override React Flow's default cursor styles - cursor: 'default', - }} - > - - {null} - - ) -} diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx deleted file mode 100644 index a3674b21465..00000000000 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ /dev/null @@ -1,467 +0,0 @@ -'use client' - -import React from 'react' -import { ArrowUp, CodeIcon } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { type Edge, type Node, Position } from 'reactflow' -import { - AgentIcon, - AirtableIcon, - DiscordIcon, - GmailIcon, - GoogleDriveIcon, - GoogleSheetsIcon, - JiraIcon, - LinearIcon, - NotionIcon, - OutlookIcon, - PackageSearchIcon, - PineconeIcon, - ScheduleIcon, - SlackIcon, - StripeIcon, - SupabaseIcon, -} from '@/components/icons' -import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' -import { - CARD_WIDTH, - IconButton, - LandingCanvas, - type LandingGroupData, - type LandingManualBlock, - type LandingViewportApi, -} from '@/app/(landing)/components/hero/components' - -/** - * Service-specific template messages for the hero input - */ -const SERVICE_TEMPLATES = { - slack: 'Summarizer agent that summarizes each new message in #general and sends me a DM', - gmail: 'Alert agent that flags important Gmail messages in my inbox', - outlook: - 'Auto-forwarding agent that classifies each new Outlook email and forwards to separate inboxes for further analysis', - pinecone: 'RAG chat agent that uses memories stored in Pinecone', - supabase: 'Natural language to SQL agent to query and update data in Supabase', - linear: 'Agent that uses Linear to triage issues, assign owners, and draft updates', - discord: 'Moderator agent that responds back to users in my Discord server', - airtable: 'Alert agent that validates each new record in a table and prepares a weekly report', - stripe: 'Agent that analyzes Stripe payment history to spot churn risks and generate summaries', - notion: 'Support agent that appends new support tickets to my Notion workspace', - googleSheets: 'Data science agent that analyzes Google Sheets data and generates insights', - googleDrive: 'Drive reader agent that summarizes content in my Google Drive', - jira: 'Engineering manager agent that uses Jira to update ticket statuses, generate sprint reports, and identify blockers', -} as const - -/** - * Landing blocks for the canvas preview - * Styled to match the application's workflow blocks with subblock rows - */ -const LANDING_BLOCKS: LandingManualBlock[] = [ - { - id: 'schedule', - name: 'Schedule', - color: '#7B68EE', - icon: , - positions: { - mobile: { x: 8, y: 60 }, - tablet: { x: 40, y: 120 }, - desktop: { x: 60, y: 180 }, - }, - tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }], - }, - { - id: 'knowledge', - name: 'Knowledge', - color: '#00B0B0', - icon: , - positions: { - mobile: { x: 120, y: 140 }, - tablet: { x: 220, y: 200 }, - desktop: { x: 420, y: 241 }, - }, - tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }], - }, - { - id: 'agent', - name: 'Agent', - color: '#802FFF', - icon: , - positions: { - mobile: { x: 340, y: 60 }, - tablet: { x: 540, y: 120 }, - desktop: { x: 880, y: 142 }, - }, - tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }], - }, - { - id: 'function', - name: 'Function', - color: '#FF402F', - icon: , - positions: { - mobile: { x: 480, y: 220 }, - tablet: { x: 740, y: 280 }, - desktop: { x: 880, y: 340 }, - }, - tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }], - }, -] - -/** - * Sample workflow edges for the canvas preview - */ -const SAMPLE_WORKFLOW_EDGES = [ - { id: 'e1', from: 'schedule', to: 'knowledge' }, - { id: 'e2', from: 'knowledge', to: 'agent' }, - { id: 'e3', from: 'knowledge', to: 'function' }, -] - -/** - * Hero component for the landing page featuring service integrations and workflow preview - */ -export default function Hero() { - const router = useRouter() - - /** - * State management for the text input - */ - const [textValue, setTextValue] = React.useState('') - const isEmpty = textValue.trim().length === 0 - - /** - * State for responsive icon display - */ - const [visibleIconCount, setVisibleIconCount] = React.useState(13) - const [isMobile, setIsMobile] = React.useState(false) - - /** - * React Flow state for workflow preview canvas - */ - const [rfNodes, setRfNodes] = React.useState([]) - const [rfEdges, setRfEdges] = React.useState([]) - const [groupBox, setGroupBox] = React.useState(null) - const [worldWidth, setWorldWidth] = React.useState(1000) - const viewportApiRef = React.useRef(null) - - /** - * Auto-hover animation state - */ - const [autoHoverIndex, setAutoHoverIndex] = React.useState(1) - const [isUserHovering, setIsUserHovering] = React.useState(false) - const [lastHoveredIndex, setLastHoveredIndex] = React.useState(null) - const intervalRef = React.useRef(null) - - /** - * Handle service icon click to populate textarea with template - */ - const handleServiceClick = (service: keyof typeof SERVICE_TEMPLATES) => { - setTextValue(SERVICE_TEMPLATES[service]) - } - - /** - * Set visible icon count based on screen size - */ - React.useEffect(() => { - const updateVisibleIcons = () => { - if (typeof window !== 'undefined') { - const mobile = window.innerWidth < 640 - setVisibleIconCount(mobile ? 6 : 13) - setIsMobile(mobile) - } - } - - updateVisibleIcons() - window.addEventListener('resize', updateVisibleIcons) - - return () => window.removeEventListener('resize', updateVisibleIcons) - }, []) - - /** - * Service icons array for easier indexing - */ - const serviceIcons: Array<{ - key: string - icon: React.ComponentType<{ className?: string }> - label: string - style?: React.CSSProperties - }> = [ - { key: 'slack', icon: SlackIcon, label: 'Slack' }, - { key: 'gmail', icon: GmailIcon, label: 'Gmail' }, - { key: 'outlook', icon: OutlookIcon, label: 'Outlook' }, - { key: 'pinecone', icon: PineconeIcon, label: 'Pinecone' }, - { key: 'supabase', icon: SupabaseIcon, label: 'Supabase' }, - { key: 'linear', icon: LinearIcon, label: 'Linear', style: { color: '#5E6AD2' } }, - { key: 'discord', icon: DiscordIcon, label: 'Discord', style: { color: '#5765F2' } }, - { key: 'airtable', icon: AirtableIcon, label: 'Airtable' }, - { key: 'stripe', icon: StripeIcon, label: 'Stripe', style: { color: '#635BFF' } }, - { key: 'notion', icon: NotionIcon, label: 'Notion' }, - { key: 'googleSheets', icon: GoogleSheetsIcon, label: 'Google Sheets' }, - { key: 'googleDrive', icon: GoogleDriveIcon, label: 'Google Drive' }, - { key: 'jira', icon: JiraIcon, label: 'Jira' }, - ] - - /** - * Auto-hover animation effect - */ - React.useEffect(() => { - // Start the interval when component mounts - const startInterval = () => { - intervalRef.current = setInterval(() => { - setAutoHoverIndex((prev) => (prev + 1) % visibleIconCount) - }, 2000) - } - - // Only run interval when user is not hovering - if (!isUserHovering) { - startInterval() - } - - // Cleanup on unmount or when hovering state changes - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - }, [isUserHovering, visibleIconCount]) - - /** - * Handle mouse enter on icon container - */ - const handleIconContainerMouseEnter = () => { - setIsUserHovering(true) - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - - /** - * Handle mouse leave on icon container - */ - const handleIconContainerMouseLeave = () => { - setIsUserHovering(false) - // Start from the next icon after the last hovered one - if (lastHoveredIndex !== null) { - setAutoHoverIndex((lastHoveredIndex + 1) % visibleIconCount) - } - } - - /** - * Handle form submission - */ - const handleSubmit = () => { - if (!isEmpty) { - LandingPromptStorage.store(textValue) - router.push('/signup') - } - } - - /** - * Handle keyboard shortcuts (Enter to submit) - */ - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - if (!isEmpty) { - handleSubmit() - } - } - } - - /** - * Initialize workflow preview with sample data - */ - React.useEffect(() => { - // Determine breakpoint for responsive positioning - const breakpoint = - typeof window !== 'undefined' && window.innerWidth < 640 - ? 'mobile' - : typeof window !== 'undefined' && window.innerWidth < 1024 - ? 'tablet' - : 'desktop' - - // Convert landing blocks to React Flow nodes - const nodes: Node[] = [ - // Add the loop block node as a group with custom rendering - { - id: 'loop', - type: 'group', - position: { x: 720, y: 20 }, - data: { - label: 'Loop', - }, - draggable: false, - selectable: false, - focusable: false, - connectable: false, - // Group node properties for subflow functionality - style: { - width: 1198, - height: 528, - backgroundColor: 'transparent', - border: 'none', - padding: 0, - }, - }, - // Convert blocks to nodes - ...LANDING_BLOCKS.map((block, index) => { - // Make agent and function nodes children of the loop - const isLoopChild = block.id === 'agent' || block.id === 'function' - const baseNode = { - id: block.id, - type: 'landing', - position: isLoopChild - ? { - // Adjust positions relative to loop parent (original positions - loop position) - x: block.id === 'agent' ? 160 : 160, - y: block.id === 'agent' ? 122 : 320, - } - : block.positions[breakpoint], - data: { - icon: block.icon, - color: block.color, - name: block.name, - tags: block.tags, - delay: index * 0.18, - hideTargetHandle: block.id === 'schedule', // Hide target handle for schedule node - hideSourceHandle: block.id === 'agent' || block.id === 'function', // Hide source handle for agent and function nodes - }, - sourcePosition: Position.Right, - targetPosition: Position.Left, - } - - // Add parent properties for loop children - if (isLoopChild) { - return { - ...baseNode, - parentId: 'loop', - extent: 'parent', - } - } - - return baseNode - }), - ] - - // Convert sample edges to React Flow edges - const rfEdges: Edge[] = SAMPLE_WORKFLOW_EDGES.map((e) => ({ - id: e.id, - source: e.from, - target: e.to, - type: 'landingEdge', - animated: false, - data: { delay: 0.6 }, - })) - - setRfNodes(nodes) - setRfEdges(rfEdges) - - // Calculate world width for canvas - const maxX = Math.max(...nodes.map((n) => n.position.x)) - setWorldWidth(maxX + CARD_WIDTH + 32) - }, []) - - return ( -
-

- Workflows for LLMs -

-

- Build and deploy AI agent workflows -

-
- {/* Service integration buttons */} - {serviceIcons.slice(0, visibleIconCount).map((service, index) => { - const Icon = service.icon - return ( - handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)} - onMouseEnter={() => setLastHoveredIndex(index)} - style={service.style} - isAutoHovered={!isUserHovering && index === autoHoverIndex} - > - - - ) - })} -
-
-
- -