Compare commits

..

56 Commits

Author SHA1 Message Date
Waleed
b45f3962fc v0.5.89: resume execution on refresh, google books, tool input subblock improvements 2026-02-13 00:36:54 -08:00
Waleed
07d50f8fe1 v0.5.88: interactions api for gemini, trigger machine size increase, confluence ops 2026-02-11 15:36:55 -08:00
Vikhyath Mondreti
27973953f6 v0.5.87: workflow block auth fix 2026-02-10 22:33:55 -08:00
Waleed
50585273ce v0.5.86: server side copilot, copilot mcp, error notifications, jira outputs destructuring, slack trigger improvements 2026-02-10 21:49:58 -08:00
Vikhyath Mondreti
654cb2b407 v0.5.85: deployment improvements 2026-02-09 10:49:33 -08:00
Waleed
6c66521d64 v0.5.84: model request sanitization 2026-02-07 19:06:53 -08:00
Vikhyath Mondreti
479cd347ad v0.5.83: agent skills, concurrent workers for v8s, airweave integration 2026-02-07 12:27:11 -08:00
Waleed
a3a99eda19 v0.5.82: slack trigger files, pagination for linear, executor fixes 2026-02-06 00:41:52 -08:00
Waleed
1a66d48add v0.5.81: traces fix, additional confluence tools, azure anthropic support, opus 4.6 2026-02-05 11:28:54 -08:00
Waleed
46822e91f3 v0.5.80: lock feature, enterprise modules, time formatting consolidation, files, UX and UI improvements, longer timeouts 2026-02-04 18:27:05 -08:00
Waleed
2bb68335ee v0.5.79: longer MCP tools timeout, optimize loop/parallel regeneration, enrich.so integration 2026-01-31 21:57:56 -08:00
Waleed
8528fbe2d2 v0.5.78: billing fixes, mcp timeout increase, reactquery migrations, updated tool param visibilities, DSPy and Google Maps integrations 2026-01-31 13:48:22 -08:00
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
110 changed files with 780 additions and 18027 deletions

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account } from '@sim/db/schema' import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
@@ -31,13 +31,15 @@ export async function GET(request: NextRequest) {
}) })
.from(account) .from(account)
.where(and(...whereConditions)) .where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
const accountsWithDisplayName = accounts.map((acc) => ({ const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id, id: acc.id,
accountId: acc.accountId, accountId: acc.accountId,
providerId: acc.providerId, providerId: acc.providerId,
displayName: acc.accountId || acc.providerId, displayName: userEmail || acc.providerId,
})) }))
return NextResponse.json({ accounts: accountsWithDisplayName }) return NextResponse.json({ accounts: accountsWithDisplayName })

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, credential, credentialMember, user } from '@sim/db/schema' import { account, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
@@ -7,10 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -20,7 +18,6 @@ const credentialsQuerySchema = z
.object({ .object({
provider: z.string().nullish(), provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(), workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z credentialId: z
.string() .string()
.min(1, 'Credential ID must not be empty') .min(1, 'Credential ID must not be empty')
@@ -38,79 +35,6 @@ interface GoogleIdToken {
name?: string name?: string
} }
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}
async function getFallbackDisplayName(
requestId: string,
providerParam: string | null | undefined,
accountRow: {
idToken: string | null
accountId: string
userId: string
}
) {
const providerForParse = (providerParam || 'google') as OAuthProvider
const { baseProvider } = parseProvider(providerForParse)
if (accountRow.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(accountRow.idToken)
if (decoded.email) return decoded.email
if (decoded.name) return decoded.name
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: accountRow.accountId,
})
}
}
if (baseProvider === 'github') {
return `${accountRow.accountId} (GitHub)`
}
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, accountRow.userId))
.limit(1)
if (userRecord.length > 0) {
return userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: accountRow.userId,
})
}
return `${accountRow.accountId} (${baseProvider})`
}
/** /**
* Get credentials for a specific provider * Get credentials for a specific provider
*/ */
@@ -122,7 +46,6 @@ export async function GET(request: NextRequest) {
const rawQuery = { const rawQuery = {
provider: searchParams.get('provider'), provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'), workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'), credentialId: searchParams.get('credentialId'),
} }
@@ -155,7 +78,7 @@ export async function GET(request: NextRequest) {
) )
} }
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data const { provider: providerParam, workflowId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT) // Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request) const authResult = await checkSessionOrInternalAuth(request)
@@ -165,7 +88,7 @@ export async function GET(request: NextRequest) {
} }
const requesterUserId = authResult.userId const requesterUserId = authResult.userId
let effectiveWorkspaceId = workspaceId ?? undefined const effectiveUserId = requesterUserId
if (workflowId) { if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId, workflowId,
@@ -183,145 +106,101 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status } { status: workflowAuthorization.status }
) )
} }
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
} }
if (effectiveWorkspaceId) { // Parse the provider to get base provider and feature type (if provider is present)
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId) const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
let accountsData let accountsData
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam)
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
)
}
if (credentialId && workflowId) { if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId)) accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) { } else if (credentialId) {
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, requesterUserId), eq(account.id, credentialId))) .where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
} else { } else {
// Fetch all credentials for provider and effective user
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, requesterUserId), eq(account.providerId, providerParam!))) .where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
} }
// Transform accounts into credentials // Transform accounts into credentials
const credentials = await Promise.all( const credentials = await Promise.all(
accountsData.map(async (acc) => { accountsData.map(async (acc) => {
const displayName = await getFallbackDisplayName(requestId, providerParam, acc) // Extract the feature type from providerId (e.g., 'google-default' -> 'default')
return toCredentialResponse(acc.id, displayName, acc.providerId, acc.updatedAt, acc.scope) const [_, featureType = 'default'] = acc.providerId.split('-')
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}) })
) )

View File

@@ -15,7 +15,6 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({ const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'), provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(), providerId: z.string().optional(),
accountId: z.string().optional(),
}) })
/** /**
@@ -51,20 +50,15 @@ export async function POST(request: NextRequest) {
) )
} }
const { provider, providerId, accountId } = parseResult.data const { provider, providerId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, { logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider, provider,
hasProviderId: !!providerId, hasProviderId: !!providerId,
}) })
// If a specific account row ID is provided, delete that exact account // If a specific providerId is provided, delete only that account
if (accountId) { if (providerId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
await db await db
.delete(account) .delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))

View File

@@ -38,18 +38,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const resolvedCredentialId = authz.resolvedCredentialId || credentialId const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId, credentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -37,19 +37,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const resolvedCredentialId = authz.resolvedCredentialId || credentialId const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
// Refresh access token if needed using the utility function // Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId, credentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -351,11 +351,10 @@ describe('OAuth Token API Routes', () => {
*/ */
describe('GET handler', () => { describe('GET handler', () => {
it('should return access token successfully', async () => { it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
ok: true, success: true,
authType: 'session', authType: 'session',
requesterUserId: 'test-user-id', userId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -381,8 +380,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token') expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled() expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled() expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
}) })
@@ -400,8 +399,8 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle authentication failure', async () => { it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
ok: false, success: false,
error: 'Authentication required', error: 'Authentication required',
}) })
@@ -414,16 +413,15 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any) const response = await GET(req as any)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(403) expect(response.status).toBe(401)
expect(data).toHaveProperty('error') expect(data).toHaveProperty('error')
}) })
it('should handle credential not found', async () => { it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
ok: true, success: true,
authType: 'session', authType: 'session',
requesterUserId: 'test-user-id', userId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce(undefined) mockGetCredential.mockResolvedValueOnce(undefined)
@@ -441,11 +439,10 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle missing access token', async () => { it('should handle missing access token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
ok: true, success: true,
authType: 'session', authType: 'session',
requesterUserId: 'test-user-id', userId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -468,11 +465,10 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle token refresh failure', async () => { it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({ mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
ok: true, success: true,
authType: 'session', authType: 'session',
requesterUserId: 'test-user-id', userId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',

View File

@@ -110,35 +110,23 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
} }
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const authz = await authorizeCredentialUse(request, { const authz = await authorizeCredentialUse(request, {
credentialId, credentialId,
workflowId: workflowId ?? undefined, workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false, requireWorkflowIdForInternal: false,
callerUserId,
}) })
if (!authz.ok || !authz.credentialOwnerUserId) { if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const resolvedCredentialId = authz.resolvedCredentialId || credentialId const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded( const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
requestId,
credential,
resolvedCredentialId
)
let instanceUrl: string | undefined let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) { if (credential.providerId === 'salesforce' && credential.scope) {
@@ -198,20 +186,13 @@ export async function GET(request: NextRequest) {
const { credentialId } = parseResult.data const { credentialId } = parseResult.data
const authz = await authorizeCredentialUse(request, { // For GET requests, we only support session-based authentication
credentialId, const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
requireWorkflowIdForInternal: false, if (!auth.success || auth.authType !== 'session' || !auth.userId) {
}) return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const resolvedCredentialId = authz.resolvedCredentialId || credentialId const credential = await getCredential(requestId, credentialId, auth.userId)
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -223,11 +204,7 @@ export async function GET(request: NextRequest) {
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded( const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
requestId,
credential,
resolvedCredentialId
)
// For Salesforce, extract instanceUrl from the scope field // For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined let instanceUrl: string | undefined

View File

@@ -62,23 +62,21 @@ describe('OAuth Utils', () => {
describe('getCredential', () => { describe('getCredential', () => {
it('should return credential when found', async () => { it('should return credential when found', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' } const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' } const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential])
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id') const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalledTimes(2) expect(mockDb.select).toHaveBeenCalled()
expect(mockFrom).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockLimit).toHaveBeenCalledWith(1)
expect(credential).toMatchObject(mockAccountRow) expect(credential).toEqual(mockCredential)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
}) })
it('should return undefined when credential is not found', async () => { it('should return undefined when credential is not found', async () => {
mockSelectChain([]) mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
@@ -160,17 +158,15 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => { describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => { it('should return valid access token without refresh if not expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockCredential = {
const mockAccountRow = { id: 'credential-id',
id: 'account-id',
accessToken: 'valid-token', accessToken: 'valid-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredentialRow]) mockSelectChain([mockCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -179,17 +175,15 @@ describe('OAuth Utils', () => {
}) })
it('should refresh token when expired', async () => { it('should refresh token when expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockCredential = {
const mockAccountRow = { id: 'credential-id',
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredentialRow]) mockSelectChain([mockCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain() mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({ mockRefreshOAuthToken.mockResolvedValueOnce({
@@ -207,7 +201,6 @@ describe('OAuth Utils', () => {
it('should return null if credential not found', async () => { it('should return null if credential not found', async () => {
mockSelectChain([]) mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -215,17 +208,15 @@ describe('OAuth Utils', () => {
}) })
it('should return null if refresh fails', async () => { it('should return null if refresh fails', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockCredential = {
const mockAccountRow = { id: 'credential-id',
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockSelectChain([mockCredentialRow]) mockSelectChain([mockCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null) mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, credential, credentialSetMember } from '@sim/db/schema' import { account, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm' import { and, desc, eq, inArray } from 'drizzle-orm'
import { refreshOAuthToken } from '@/lib/oauth' import { refreshOAuthToken } from '@/lib/oauth'
@@ -25,28 +25,6 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date accessTokenExpiresAt?: Date
} }
async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; usedCredentialTable: boolean } | null> {
const [credentialRow] = await db
.select({
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return { accountId: credentialRow.accountId, usedCredentialTable: true }
}
return { accountId: credentialId, usedCredentialTable: false }
}
/** /**
* Safely inserts an account record, handling duplicate constraint violations gracefully. * Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success. * If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -74,16 +52,10 @@ export async function safeAccountInsert(
* Get a credential by ID and verify it belongs to the user * Get a credential by ID and verify it belongs to the user
*/ */
export async function getCredential(requestId: string, credentialId: string, userId: string) { export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
const credentials = await db const credentials = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId))) .where(and(eq(account.id, credentialId), eq(account.userId, userId)))
.limit(1) .limit(1)
if (!credentials.length) { if (!credentials.length) {
@@ -91,10 +63,7 @@ export async function getCredential(requestId: string, credentialId: string, use
return undefined return undefined
} }
return { return credentials[0]
...credentials[0],
resolvedCredentialId: resolved.accountId,
}
} }
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> { export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
@@ -269,9 +238,7 @@ export async function refreshAccessTokenIfNeeded(
} }
// Update the token in the database // Update the token in the database
const resolvedCredentialId = await db.update(account).set(updateData).where(eq(account.id, credentialId))
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token for credential`) logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken return refreshedToken.accessToken
@@ -307,8 +274,6 @@ export async function refreshTokenIfNeeded(
credential: any, credential: any,
credentialId: string credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> { ): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired // Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
@@ -369,7 +334,7 @@ export async function refreshTokenIfNeeded(
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} }
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId)) await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`) logger.info(`[${requestId}] Successfully refreshed access token`)
return { accessToken: refreshedToken, refreshed: true } return { accessToken: refreshedToken, refreshed: true }
@@ -378,7 +343,7 @@ export async function refreshTokenIfNeeded(
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded` `[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
) )
const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId) const freshCredential = await getCredential(requestId, credentialId, credential.userId)
if (freshCredential?.accessToken) { if (freshCredential?.accessToken) {
const freshExpiresAt = freshCredential.accessTokenExpiresAt const freshExpiresAt = freshCredential.accessTokenExpiresAt
const stillValid = !freshExpiresAt || freshExpiresAt > new Date() const stillValid = !freshExpiresAt || freshExpiresAt > new Date()

View File

@@ -48,21 +48,16 @@ export async function GET(request: NextRequest) {
const shopData = await shopResponse.json() const shopData = await shopResponse.json()
const shopInfo = shopData.shop const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and( where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')),
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}) })
const now = new Date() const now = new Date()
const accountData = { const accountData = {
accessToken: accessToken, accessToken: accessToken,
accountId: stableAccountId, accountId: shopInfo.id?.toString() || shopDomain,
scope: scope || '', scope: scope || '',
updatedAt: now, updatedAt: now,
idToken: shopDomain, idToken: shopDomain,

View File

@@ -52,11 +52,7 @@ export async function POST(request: NextRequest) {
const trelloUser = await userResponse.json() const trelloUser = await userResponse.json()
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and( where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')),
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}) })
const now = new Date() const now = new Date()

View File

@@ -1,197 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireAdminMembership(credentialId: string, userId: string) {
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
const members = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const addMemberSchema = z.object({
userId: z.string().min(1),
role: z.enum(['admin', 'member']).default('member'),
})
export async function POST(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await request.json()
const parsed = addMemberSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, role } = parsed.data
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
await db
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId)
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
if (target.role === 'admin') {
const activeAdmins = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
}
await db
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,258 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('CredentialByIdAPI')
const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
accountId: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.accountId !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credential)
.innerJoin(
credentialMember,
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
)
.where(eq(credential.id, credentialId))
.limit(1)
return row ?? null
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const parseResult = updateCredentialSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
updates.displayName = parseResult.data.displayName
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || crypto.randomUUID(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
envKeys: Object.keys(current),
actingUserId: session.user.id,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,85 +0,0 @@
import { db } from '@sim/db'
import { pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
const createDraftSchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = createDraftSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,112 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembershipsAPI')
const leaveCredentialSchema = z.object({
credentialId: z.string().min(1),
})
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const memberships = await db
.select({
membershipId: credentialMember.id,
credentialId: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
logger.error('Failed to list credential memberships', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = leaveCredentialSchema.safeParse({
credentialId: new URL(request.url).searchParams.get('credentialId'),
})
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { credentialId } = parseResult.data
const [membership] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
}
if (membership.status !== 'active') {
return NextResponse.json({ success: true }, { status: 200 })
}
if (membership.role === 'admin') {
const activeAdmins = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return NextResponse.json(
{ error: 'Cannot leave credential as the last active admin' },
{ status: 400 }
)
}
}
await db
.update(credentialMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialMember.id, membership.id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to leave credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,521 +0,0 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
const listCredentialsSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!isValidEnvVarName(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.accountId, accountId)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_workspace'),
eq(credential.envKey, envKey)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envKey, envKey),
eq(credential.envOwnerUserId, envOwnerUserId)
)
)
.limit(1)
return row ?? null
}
return null
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const rawWorkspaceId = searchParams.get('workspaceId')
const rawType = searchParams.get('type')
const rawProviderId = searchParams.get('providerId')
const rawCredentialId = searchParams.get('credentialId')
const parseResult = listCredentialsSchema.safeParse({
workspaceId: rawWorkspaceId?.trim(),
type: rawType?.trim() || undefined,
providerId: rawProviderId?.trim() || undefined,
credentialId: rawCredentialId?.trim() || undefined,
})
if (!parseResult.success) {
logger.warn(`[${requestId}] Invalid credential list request`, {
workspaceId: rawWorkspaceId,
type: rawType,
providerId: rawProviderId,
errors: parseResult.error.errors,
})
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (lookupCredentialId) {
let [row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
if (!row) {
;[row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
}
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
const whereClauses = [
eq(credential.workspaceId, workspaceId),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active'),
]
if (type) {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
}
const credentials = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active')
)
)
.where(and(...whereClauses))
return NextResponse.json({ credentials })
} catch (error) {
logger.error(`[${requestId}] Failed to list credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parseResult = createCredentialSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
let resolvedDisplayName = displayName?.trim() ?? ''
const resolvedDescription = description?.trim() || null
let resolvedProviderId: string | null = providerId ?? null
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(eq(account.id, accountId!))
.limit(1)
if (!accountRow) {
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
}
if (accountRow.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Only account owners can create oauth credentials for an account' },
{ status: 403 }
)
}
if (providerId !== accountRow.providerId) {
return NextResponse.json(
{ error: 'providerId does not match the selected OAuth account' },
{ status: 400 }
)
}
if (!resolvedDisplayName) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
return NextResponse.json(
{ error: 'Only the current user can create personal env credentials for themselves' },
{ status: 403 }
)
}
resolvedProviderId = null
resolvedAccountId = null
resolvedDisplayName = resolvedEnvKey || ''
} else {
resolvedProviderId = null
resolvedAccountId = null
resolvedEnvOwnerUserId = null
resolvedDisplayName = resolvedEnvKey || ''
}
if (!resolvedDisplayName) {
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
}
const existingCredential = await findExistingCredentialBySource({
workspaceId,
type,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
})
if (existingCredential) {
const [membership] = await db
.select({
id: credentialMember.id,
status: credentialMember.status,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, existingCredential.id),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership || membership.status !== 'active') {
return NextResponse.json(
{ error: 'A credential with this source already exists in this workspace' },
{ status: 409 }
)
}
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
const [updatedCredential] = await db
.select()
.from(credential)
.where(eq(credential.id, existingCredential.id))
.limit(1)
return NextResponse.json(
{ credential: updatedCredential ?? existingCredential },
{ status: 200 }
)
}
return NextResponse.json({ credential: existingCredential }, { status: 200 })
}
const now = new Date()
const credentialId = crypto.randomUUID()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await db.transaction(async (tx) => {
await tx.insert(credential).values({
id: credentialId,
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: memberUserId,
role: memberUserId === workspaceRow.ownerId ? 'admin' : 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
}
} else {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: session.user.id,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
})
const [created] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -7,7 +7,6 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment' import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI') const logger = createLogger('EnvironmentAPI')
@@ -54,11 +53,6 @@ export async function POST(req: NextRequest) {
}, },
}) })
await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (validationError) { } catch (validationError) {
if (validationError instanceof z.ZodError) { if (validationError instanceof z.ZodError) {

View File

@@ -11,7 +11,6 @@ import {
user, user,
userStats, userStats,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -24,7 +23,6 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation') const logger = createLogger('OrganizationInvitation')
@@ -497,34 +495,6 @@ export async function PUT(
} }
}) })
if (status === 'accepted') {
const acceptedWsInvitations = await db
.select({ workspaceId: workspaceInvitation.workspaceId })
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
)
)
for (const wsInv of acceptedWsInvitations) {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: wsInv.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
}
}
// Handle Pro subscription cancellation after transaction commits // Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) { if (personalProToCancel) {
try { try {

View File

@@ -32,10 +32,9 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm' import { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { import {
badRequestResponse, badRequestResponse,
@@ -233,20 +232,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
permissionId, permissionId,
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: body.userId,
})
}
return singleResponse({ return singleResponse({
id: permissionId, id: permissionId,
workspaceId, workspaceId,

View File

@@ -536,7 +536,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }
@@ -886,7 +885,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }

View File

@@ -1,14 +1,12 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { workspaceEnvironment } from '@sim/db/schema' import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI') const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -46,10 +44,44 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( // Workspace env (encrypted)
userId, const wsEnvRow = await db
workspaceId .select()
) .from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
return NextResponse.json( return NextResponse.json(
{ {
@@ -124,12 +156,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() }, set: { variables: merged, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(merged),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env PUT error`, error) logger.error(`[${requestId}] Workspace env PUT error`, error)
@@ -196,12 +222,6 @@ export async function DELETE(
set: { variables: current, updatedAt: new Date() }, set: { variables: current, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(current),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error) logger.error(`[${requestId}] Workspace env DELETE error`, error)

View File

@@ -1,12 +1,11 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema' import { permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { import {
getUsersWithPermissions, getUsersWithPermissions,
hasWorkspaceAdminAccess, hasWorkspaceAdminAccess,
@@ -155,20 +154,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
const updatedUsers = await getUsersWithPermissions(workspaceId) const updatedUsers = await getUsersWithPermissions(workspaceId)
return NextResponse.json({ return NextResponse.json({

View File

@@ -8,27 +8,15 @@ const mockHasWorkspaceAdminAccess = vi.fn()
let dbSelectResults: any[] = [] let dbSelectResults: any[] = []
let dbSelectCallIndex = 0 let dbSelectCallIndex = 0
const mockDbSelect = vi.fn().mockImplementation(() => { const mockDbSelect = vi.fn().mockImplementation(() => ({
const makeThen = () => from: vi.fn().mockReturnThis(),
vi.fn().mockImplementation((callback: (rows: any[]) => any) => { where: vi.fn().mockReturnThis(),
const result = dbSelectResults[dbSelectCallIndex] || [] then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
dbSelectCallIndex++ const result = dbSelectResults[dbSelectCallIndex] || []
return Promise.resolve(callback ? callback(result) : result) dbSelectCallIndex++
}) return Promise.resolve(callback ? callback(result) : result)
const makeLimit = () => }),
vi.fn().mockImplementation(() => { }))
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(result)
})
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = makeLimit()
chain.then = makeThen()
return chain
})
const mockDbInsert = vi.fn().mockImplementation(() => ({ const mockDbInsert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined), values: vi.fn().mockResolvedValue(undefined),
@@ -65,10 +53,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
mockHasWorkspaceAdminAccess(userId, workspaceId), mockHasWorkspaceAdminAccess(userId, workspaceId),
})) }))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/urls', () => ({ vi.mock('@/lib/core/utils/urls', () => ({
@@ -111,10 +95,6 @@ vi.mock('@sim/db/schema', () => ({
userId: 'userId', userId: 'userId',
permissionType: 'permissionType', permissionType: 'permissionType',
}, },
workspaceEnvironment: {
workspaceId: 'workspaceId',
variables: 'variables',
},
})) }))
vi.mock('drizzle-orm', () => ({ vi.mock('drizzle-orm', () => ({
@@ -227,7 +207,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request = new NextRequest( const request = new NextRequest(
@@ -481,7 +460,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request2 = new NextRequest( const request2 = new NextRequest(

View File

@@ -6,7 +6,6 @@ import {
user, user,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspace, workspace,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -15,7 +14,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails' import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -164,20 +162,6 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id)) .where(eq(workspaceInvitation.id, invitation.id))
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: invitation.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())) return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
} }

View File

@@ -30,7 +30,6 @@ export interface OAuthRequiredModalProps {
requiredScopes?: string[] requiredScopes?: string[]
serviceId: string serviceId: string
newScopes?: string[] newScopes?: string[]
onConnect?: () => Promise<void> | void
} }
const SCOPE_DESCRIPTIONS: Record<string, string> = { const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -315,7 +314,6 @@ export function OAuthRequiredModal({
requiredScopes = [], requiredScopes = [],
serviceId, serviceId,
newScopes = [], newScopes = [],
onConnect,
}: OAuthRequiredModalProps) { }: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider) const { baseProvider } = parseProvider(provider)
@@ -361,12 +359,6 @@ export function OAuthRequiredModal({
setError(null) setError(null)
try { try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId) const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', { logger.info('Linking OAuth2:', {

View File

@@ -3,12 +3,10 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react' import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client' import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
@@ -20,9 +18,9 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET } from '@/executor/constants' import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
@@ -48,8 +46,6 @@ export function CredentialSelector({
previewValue, previewValue,
previewContextValues, previewContextValues,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -100,70 +96,64 @@ export function CredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, { } = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo( const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId), () => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId] [credentialSets, selectedCredentialSetId]
) )
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null) const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
setStoreValue(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return '' return ''
}, [ }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
selectedCredentialSet,
selectedCredential,
inaccessibleCredentialName,
selectedId,
credentialsLoading,
])
const displayValue = isEditing ? editingValue : resolvedLabel const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) const invalidSelection =
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -205,18 +195,8 @@ export function CredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({ setShowOAuthModal(true)
workspaceId, }, [])
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const getProviderIcon = useCallback((providerName: OAuthProvider) => { const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName) const { baseProvider } = parseProvider(providerName)
@@ -271,18 +251,23 @@ export function CredentialSelector({
label: cred.name, label: cred.name,
value: cred.id, value: cred.id,
})) }))
credentialItems.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
groups.push({ if (credentialItems.length > 0) {
section: 'Personal Credential', groups.push({
items: credentialItems, section: 'Personal Credential',
}) items: credentialItems,
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups } return { comboboxOptions: [], comboboxGroups: groups }
} }
@@ -292,13 +277,12 @@ export function CredentialSelector({
value: cred.id, value: cred.id,
})) }))
options.push({ if (credentials.length === 0) {
label: options.push({
credentials.length > 0 label: `Connect ${getProviderName(provider)} account`,
? `Connect another ${getProviderName(provider)} account` value: '__connect_account__',
: `Connect ${getProviderName(provider)} account`, })
value: '__connect_account__', }
})
return { comboboxOptions: options, comboboxGroups: undefined } return { comboboxOptions: options, comboboxGroups: undefined }
}, [ }, [
@@ -384,7 +368,7 @@ export function CredentialSelector({
} }
disabled={effectiveDisabled} disabled={effectiveDisabled}
editable={true} editable={true}
filterOptions={true} filterOptions={!isForeign && !isForeignCredentialSet}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''} className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
@@ -396,13 +380,15 @@ export function CredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
<Button {!isForeign && (
variant='active' <Button
onClick={() => setShowOAuthModal(true)} variant='active'
className='w-full px-[8px] py-[4px] font-medium text-[12px]' onClick={() => setShowOAuthModal(true)}
> className='w-full px-[8px] py-[4px] font-medium text-[12px]'
Update access >
</Button> Update access
</Button>
)}
</div> </div>
)} )}
@@ -421,11 +407,7 @@ export function CredentialSelector({
) )
} }
function useCredentialRefreshTriggers( function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -443,29 +425,12 @@ function useCredentialRefreshTriggers(
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [providerId, workspaceId, refetchCredentials]) }, [refetchCredentials])
} }

View File

@@ -9,7 +9,6 @@ import {
PopoverSection, PopoverSection,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
usePersonalEnvironment, usePersonalEnvironment,
useWorkspaceEnvironment, useWorkspaceEnvironment,
@@ -169,15 +168,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}, [searchTerm]) }, [searchTerm])
const openEnvironmentSettings = () => { const openEnvironmentSettings = () => {
if (workspaceId) { window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
writePendingCredentialCreateRequest({
workspaceId,
type: 'env_personal',
envKey: searchTerm.trim(),
requestedAt: Date.now(),
})
}
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
onClose?.() onClose?.()
} }
@@ -311,7 +302,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}} }}
> >
<Plus className='h-3 w-3' /> <Plus className='h-3 w-3' />
<span>Create Secret</span> <span>Create environment variable</span>
</PopoverItem> </PopoverItem>
</PopoverScrollArea> </PopoverScrollArea>
) : ( ) : (

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -124,6 +125,8 @@ export function FileSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -165,6 +168,7 @@ export function FileSelectorInput({
const disabledReason = const disabledReason =
finalDisabled || finalDisabled ||
isForeignCredential ||
missingCredential || missingCredential ||
missingDomain || missingDomain ||
missingProject || missingProject ||

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -46,6 +47,10 @@ export function FolderSelectorInput({
subBlock.canonicalParamId === 'copyDestinationId' || subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' || subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder' subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
// Central dependsOn gating // Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
@@ -114,7 +119,9 @@ export function FolderSelectorInput({
selectorContext={ selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' } selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
} }
disabled={finalDisabled || missingCredential || !selectorResolution?.key} disabled={
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'} placeholder={subBlock.placeholder || 'Select folder'}

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -72,6 +73,11 @@ export function ProjectSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled, disabled,
@@ -117,7 +123,7 @@ export function ProjectSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={selectorResolution.key} selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context} selectorContext={selectorResolution.context}
disabled={finalDisabled || missingCredential} disabled={finalDisabled || isForeignCredential || missingCredential}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'} placeholder={subBlock.placeholder || 'Select project'}

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -86,6 +87,8 @@ export function SheetSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -98,7 +101,11 @@ export function SheetSelectorInput({
const missingSpreadsheet = !normalizedSpreadsheetId const missingSpreadsheet = !normalizedSpreadsheetId
const disabledReason = const disabledReason =
finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key finalDisabled ||
isForeignCredential ||
missingCredential ||
missingSpreadsheet ||
!selectorResolution?.key
if (!selectorResolution?.key) { if (!selectorResolution?.key) {
return ( return (

View File

@@ -6,6 +6,7 @@ import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -84,6 +85,11 @@ export function SlackSelectorInput({
? (effectiveBotToken as string) || '' ? (effectiveBotToken as string) || ''
: (effectiveCredential as string) || '' : (effectiveCredential as string) || ''
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || ''
)
useEffect(() => { useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof val === 'string') { if (typeof val === 'string') {
@@ -93,7 +99,7 @@ export function SlackSelectorInput({
const requiresCredential = dependsOn.includes('credential') const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0 const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && missingCredential const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
const context: SelectorContext = useMemo( const context: SelectorContext = useMemo(
() => ({ () => ({
@@ -130,7 +136,7 @@ export function SlackSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={config.selectorKey} selectorKey={config.selectorKey}
selectorContext={context} selectorContext={context}
disabled={finalDisabled || shouldForceDisable} disabled={finalDisabled || shouldForceDisable || isForeignCredential}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || config.placeholder} placeholder={subBlock.placeholder || config.placeholder}

View File

@@ -1,8 +1,6 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
@@ -13,7 +11,8 @@ import {
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { CREDENTIAL } from '@/executor/constants'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -65,8 +64,6 @@ export function ToolCredentialSelector({
serviceId, serviceId,
disabled = false, disabled = false,
}: ToolCredentialSelectorProps) { }: ToolCredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('') const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -81,58 +78,50 @@ export function ToolCredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, { } = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null) const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
useEffect(() => { const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) { useOAuthCredentialDetail(
setInaccessibleCredentialName(null) shouldFetchForeignMeta ? selectedId : undefined,
return activeWorkflowId || undefined,
} shouldFetchForeignMeta
)
let cancelled = false const hasForeignMeta = foreignCredentials.length > 0
;(async () => { const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
onChange(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return '' return ''
}, [selectedCredential, inaccessibleCredentialName, selectedId, credentialsLoading]) }, [selectedCredential, isForeign])
const inputValue = isEditing ? editingInputValue : resolvedLabel const inputValue = isEditing ? editingInputValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) const invalidSelection =
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -160,18 +149,8 @@ export function ToolCredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({ setShowOAuthModal(true)
workspaceId, }, [])
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const comboboxOptions = useMemo(() => { const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({ const options = credentials.map((cred) => ({
@@ -179,13 +158,12 @@ export function ToolCredentialSelector({
value: cred.id, value: cred.id,
})) }))
options.push({ if (credentials.length === 0) {
label: options.push({
credentials.length > 0 label: `Connect ${getProviderName(provider)} account`,
? `Connect another ${getProviderName(provider)} account` value: '__connect_account__',
: `Connect ${getProviderName(provider)} account`, })
value: '__connect_account__', }
})
return options return options
}, [credentials, provider]) }, [credentials, provider])
@@ -235,7 +213,7 @@ export function ToolCredentialSelector({
placeholder={effectiveLabel} placeholder={effectiveLabel}
disabled={disabled} disabled={disabled}
editable={true} editable={true}
filterOptions={true} filterOptions={!isForeign}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''} className={selectedId ? 'pl-[28px]' : ''}
@@ -247,13 +225,15 @@ export function ToolCredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
<Button {!isForeign && (
variant='active' <Button
onClick={() => setShowOAuthModal(true)} variant='active'
className='w-full px-[8px] py-[4px] font-medium text-[12px]' onClick={() => setShowOAuthModal(true)}
> className='w-full px-[8px] py-[4px] font-medium text-[12px]'
Update access >
</Button> Update access
</Button>
)}
</div> </div>
)} )}
@@ -272,11 +252,7 @@ export function ToolCredentialSelector({
) )
} }
function useCredentialRefreshTriggers( function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -294,29 +270,12 @@ function useCredentialRefreshTriggers(
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [providerId, workspaceId, refetchCredentials]) }, [refetchCredentials])
} }

View File

@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
export function useForeignCredential(
provider: string | undefined,
credentialId: string | undefined
) {
const [isForeign, setIsForeign] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
useEffect(() => {
let cancelled = false
async function check() {
setLoading(true)
setError(null)
try {
if (!normalizedProvider || !normalizedCredentialId) {
if (!cancelled) setIsForeign(false)
return
}
const res = await fetch(
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
)
if (!res.ok) {
if (!cancelled) setIsForeign(true)
return
}
const data = await res.json()
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
if (!cancelled) setIsForeign(!isOwn)
} catch (e) {
if (!cancelled) {
setIsForeign(true)
setError((e as Error).message)
}
} finally {
if (!cancelled) setLoading(false)
}
}
void check()
return () => {
cancelled = true
}
}, [normalizedProvider, normalizedCredentialId])
return { isForeignCredential: isForeign, loading, error }
}

View File

@@ -255,69 +255,6 @@ const WorkflowContent = React.memo(() => {
const addNotification = useNotificationStore((state) => state.addNotification) const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
if (!pending) return
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
;(async () => {
try {
const {
displayName,
providerId,
preCount,
workspaceId: wsId,
reconnect,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
reconnect?: boolean
}
if (reconnect) {
addNotification({
level: 'info',
message: `"${displayName}" reconnected successfully.`,
})
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId, workspaceId: wsId },
})
)
return
}
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
)
const data = response.ok ? await response.json() : { credentials: [] }
const oauthCredentials = (data.credentials ?? []) as Array<{
displayName: string
providerId: string | null
}>
if (oauthCredentials.length > preCount) {
addNotification({
level: 'info',
message: `"${displayName}" credential connected successfully.`,
})
} else {
const existing = oauthCredentials.find((c) => c.providerId === providerId)
const existingName = existing?.displayName || displayName
addNotification({
level: 'info',
message: `This account is already connected as "${existingName}".`,
})
}
} catch {
// Ignore malformed sessionStorage data
}
})()
}, [])
const { const {
workflows, workflows,
activeWorkflowId, activeWorkflowId,

View File

@@ -473,7 +473,7 @@ function ConnectionsSection({
</div> </div>
)} )}
{/* Secrets */} {/* Environment Variables */}
{envVars.length > 0 && ( {envVars.length > 0 && (
<div className='mb-[2px] last:mb-0'> <div className='mb-[2px] last:mb-0'>
<div <div
@@ -489,7 +489,7 @@ function ConnectionsSection({
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]' 'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)} )}
> >
Secrets Environment Variables
</span> </span>
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(

View File

@@ -1,17 +0,0 @@
'use client'
import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager'
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
}
export function Credentials(_props: CredentialsProps) {
return (
<div className='h-full min-h-0'>
<CredentialsManager />
</div>
)
}

View File

@@ -134,7 +134,7 @@ function WorkspaceVariableRow({
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete secret</Tooltip.Content> <Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete secret</Tooltip.Content> <Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
filteredWorkspaceEntries.length === 0 && filteredWorkspaceEntries.length === 0 &&
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && ( (envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'> <div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No secrets found matching "{searchTerm}" No environment variables found matching "{searchTerm}"
</div> </div>
)} )}
</> </>

View File

@@ -2,7 +2,6 @@ export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets' export { CredentialSets } from './credential-sets/credential-sets'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools' export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug' export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment' export { EnvironmentVariables } from './environment/environment'

View File

@@ -20,6 +20,7 @@ import {
import { import {
Card, Card,
Connections, Connections,
FolderCode,
HexSimple, HexSimple,
Key, Key,
SModal, SModal,
@@ -44,11 +45,12 @@ import {
BYOK, BYOK,
Copilot, Copilot,
CredentialSets, CredentialSets,
Credentials,
CustomTools, CustomTools,
Debug, Debug,
EnvironmentVariables,
FileUploads, FileUploads,
General, General,
Integrations,
MCP, MCP,
Skills, Skills,
Subscription, Subscription,
@@ -78,7 +80,6 @@ interface SettingsModalProps {
type SettingsSection = type SettingsSection =
| 'general' | 'general'
| 'credentials'
| 'environment' | 'environment'
| 'template-profile' | 'template-profile'
| 'integrations' | 'integrations'
@@ -155,10 +156,11 @@ const allNavigationItems: NavigationItem[] = [
requiresHosted: true, requiresHosted: true,
requiresTeam: true, requiresTeam: true,
}, },
{ id: 'credentials', label: 'Credentials', icon: Connections, section: 'tools' }, { id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' }, { id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' }, { id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' }, { id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' }, { id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' }, { id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{ {
@@ -254,6 +256,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) { if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false return false
} }
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
return false
}
if (item.id === 'files' && permissionConfig.hideFilesTab) { if (item.id === 'files' && permissionConfig.hideFilesTab) {
return false return false
} }
@@ -319,9 +324,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) { if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
return 'general' return 'general'
} }
if (activeSection === 'environment' || activeSection === 'integrations') {
return 'credentials'
}
return activeSection return activeSection
}, [activeSection]) }, [activeSection])
@@ -340,7 +342,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
(sectionId: SettingsSection) => { (sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return if (sectionId === effectiveActiveSection) return
if (effectiveActiveSection === 'credentials' && environmentBeforeLeaveHandler.current) { if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return return
} }
@@ -368,11 +370,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
useEffect(() => { useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => { const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
if (event.detail.tab === 'environment' || event.detail.tab === 'integrations') { setActiveSection(event.detail.tab)
setActiveSection('credentials')
} else {
setActiveSection(event.detail.tab)
}
onOpenChange(true) onOpenChange(true)
} }
@@ -481,19 +479,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleDialogOpenChange = (newOpen: boolean) => { const handleDialogOpenChange = (newOpen: boolean) => {
if ( if (
!newOpen && !newOpen &&
effectiveActiveSection === 'credentials' && effectiveActiveSection === 'environment' &&
environmentBeforeLeaveHandler.current environmentBeforeLeaveHandler.current
) { ) {
environmentBeforeLeaveHandler.current(() => { environmentBeforeLeaveHandler.current(() => onOpenChange(false))
if (integrationsCloseHandler.current) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(false)
}
})
} else if ( } else if (
!newOpen && !newOpen &&
effectiveActiveSection === 'credentials' && effectiveActiveSection === 'integrations' &&
integrationsCloseHandler.current integrationsCloseHandler.current
) { ) {
integrationsCloseHandler.current(newOpen) integrationsCloseHandler.current(newOpen)
@@ -510,7 +502,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</VisuallyHidden.Root> </VisuallyHidden.Root>
<VisuallyHidden.Root> <VisuallyHidden.Root>
<DialogPrimitive.Description> <DialogPrimitive.Description>
Configure your workspace settings, credentials, and preferences Configure your workspace settings, environment variables, integrations, and preferences
</DialogPrimitive.Description> </DialogPrimitive.Description>
</VisuallyHidden.Root> </VisuallyHidden.Root>
@@ -547,14 +539,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</SModalMainHeader> </SModalMainHeader>
<SModalMainBody> <SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />} {effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'credentials' && ( {effectiveActiveSection === 'environment' && (
<Credentials <EnvironmentVariables
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler} registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/> />
)} )}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />} {effectiveActiveSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && (
<Integrations
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />} {effectiveActiveSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />} {effectiveActiveSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />} {effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}

View File

@@ -142,8 +142,6 @@ Return ONLY the JSON array.`,
title: 'Google Cloud Account', title: 'Google Cloud Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'vertex-ai', serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account', placeholder: 'Select Google Cloud account',
required: true, required: true,
@@ -152,19 +150,6 @@ Return ONLY the JSON array.`,
value: providers.vertex.models, value: providers.vertex.models,
}, },
}, },
{
id: 'manualCredential',
title: 'Google Cloud Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{ {
id: 'reasoningEffort', id: 'reasoningEffort',
title: 'Reasoning Effort', title: 'Reasoning Effort',
@@ -763,7 +748,6 @@ Example 3 (Array Input):
apiKey: { type: 'string', description: 'Provider API key' }, apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' }, azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' }, azureApiVersion: { type: 'string', description: 'Azure API version' },
oauthCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },

View File

@@ -32,8 +32,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
id: 'credential', id: 'credential',
title: 'Airtable Account', title: 'Airtable Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable', serviceId: 'airtable',
requiredScopes: [ requiredScopes: [
'data.records:read', 'data.records:read',
@@ -44,15 +42,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Select Airtable account', placeholder: 'Select Airtable account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Airtable Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'baseId', id: 'baseId',
title: 'Base ID', title: 'Base ID',
@@ -230,7 +219,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, records, fields, ...rest } = params const { credential, records, fields, ...rest } = params
let parsedRecords: any | undefined let parsedRecords: any | undefined
let parsedFields: any | undefined let parsedFields: any | undefined
@@ -248,7 +237,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
// Construct parameters based on operation // Construct parameters based on operation
const baseParams = { const baseParams = {
credential: oauthCredential, credential,
...rest, ...rest,
} }
@@ -266,7 +255,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Airtable access token' }, credential: { type: 'string', description: 'Airtable access token' },
baseId: { type: 'string', description: 'Airtable base identifier' }, baseId: { type: 'string', description: 'Airtable base identifier' },
tableId: { type: 'string', description: 'Airtable table identifier' }, tableId: { type: 'string', description: 'Airtable table identifier' },
// Conditional inputs // Conditional inputs

View File

@@ -32,22 +32,12 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
id: 'credential', id: 'credential',
title: 'Asana Account', title: 'Asana Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'asana', serviceId: 'asana',
requiredScopes: ['default'], requiredScopes: ['default'],
placeholder: 'Select Asana account', placeholder: 'Select Asana account',
}, },
{
id: 'manualCredential',
title: 'Asana Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'workspace', id: 'workspace',
title: 'Workspace GID', title: 'Workspace GID',
@@ -225,7 +215,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation } = params const { credential, operation } = params
const projectsArray = params.projects const projectsArray = params.projects
? params.projects ? params.projects
@@ -235,7 +225,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
: undefined : undefined
const baseParams = { const baseParams = {
accessToken: oauthCredential?.accessToken, accessToken: credential?.accessToken,
} }
switch (operation) { switch (operation) {
@@ -294,7 +284,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Asana OAuth credential' },
workspace: { type: 'string', description: 'Workspace GID' }, workspace: { type: 'string', description: 'Workspace GID' },
taskGid: { type: 'string', description: 'Task GID' }, taskGid: { type: 'string', description: 'Task GID' },
getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' }, getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' },

View File

@@ -49,20 +49,9 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
title: 'Cal.com Account', title: 'Cal.com Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'calcom', serviceId: 'calcom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
placeholder: 'Select Cal.com account', placeholder: 'Select Cal.com account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Cal.com Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// === Create Booking fields === // === Create Booking fields ===
{ {
@@ -566,7 +555,7 @@ Return ONLY valid JSON - no explanations.`,
params: (params) => { params: (params) => {
const { const {
operation, operation,
oauthCredential, credential,
attendeeName, attendeeName,
attendeeEmail, attendeeEmail,
attendeeTimeZone, attendeeTimeZone,
@@ -756,7 +745,7 @@ Return ONLY valid JSON - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Cal.com OAuth credential' }, credential: { type: 'string', description: 'Cal.com OAuth credential' },
eventTypeId: { type: 'number', description: 'Event type ID' }, eventTypeId: { type: 'number', description: 'Event type ID' },
start: { type: 'string', description: 'Start time (ISO 8601)' }, start: { type: 'string', description: 'Start time (ISO 8601)' },
end: { type: 'string', description: 'End time (ISO 8601)' }, end: { type: 'string', description: 'End time (ISO 8601)' },

View File

@@ -51,8 +51,6 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -87,15 +85,6 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'pageId', id: 'pageId',
title: 'Select Page', title: 'Select Page',
@@ -298,7 +287,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -311,7 +300,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
if (operation === 'upload_attachment') { if (operation === 'upload_attachment') {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: attachmentFile, file: attachmentFile,
@@ -322,7 +311,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
...rest, ...rest,
@@ -333,7 +322,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
oauthCredential: { type: 'string', description: 'Confluence access token' }, credential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
title: { type: 'string', description: 'Page title' }, title: { type: 'string', description: 'Page title' },
@@ -439,8 +428,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -475,15 +462,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'domain', id: 'domain',
title: 'Domain', title: 'Domain',
@@ -965,7 +943,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -990,7 +968,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'add_label') { if (operation === 'add_label') {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
prefix: labelPrefix || 'global', prefix: labelPrefix || 'global',
@@ -1000,7 +978,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'create_blogpost') { if (operation === 'create_blogpost') {
return { return {
credential: oauthCredential, credential,
operation, operation,
status: blogPostStatus || 'current', status: blogPostStatus || 'current',
...rest, ...rest,
@@ -1009,7 +987,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete') { if (operation === 'delete') {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
purge: purge || false, purge: purge || false,
@@ -1019,7 +997,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_comments') { if (operation === 'list_comments') {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
bodyFormat: bodyFormat || 'storage', bodyFormat: bodyFormat || 'storage',
@@ -1045,7 +1023,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (supportsCursor.includes(operation) && cursor) { if (supportsCursor.includes(operation) && cursor) {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
cursor, cursor,
@@ -1058,7 +1036,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('Property key is required for this operation.') throw new Error('Property key is required for this operation.')
} }
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
key: propertyKey, key: propertyKey,
@@ -1069,7 +1047,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete_page_property') { if (operation === 'delete_page_property') {
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
propertyId, propertyId,
@@ -1079,7 +1057,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'get_pages_by_label') { if (operation === 'get_pages_by_label') {
return { return {
credential: oauthCredential, credential,
operation, operation,
labelId, labelId,
cursor: cursor || undefined, cursor: cursor || undefined,
@@ -1089,7 +1067,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_space_labels') { if (operation === 'list_space_labels') {
return { return {
credential: oauthCredential, credential,
operation, operation,
cursor: cursor || undefined, cursor: cursor || undefined,
...rest, ...rest,
@@ -1102,7 +1080,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('File is required for upload attachment operation.') throw new Error('File is required for upload attachment operation.')
} }
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: normalizedFile, file: normalizedFile,
@@ -1113,7 +1091,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential: oauthCredential, credential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
blogPostId: blogPostId || undefined, blogPostId: blogPostId || undefined,
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined, versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
@@ -1126,7 +1104,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
oauthCredential: { type: 'string', description: 'Confluence access token' }, credential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' }, blogPostId: { type: 'string', description: 'Blog post identifier' },

View File

@@ -38,8 +38,6 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
id: 'credential', id: 'credential',
title: 'Dropbox Account', title: 'Dropbox Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'dropbox', serviceId: 'dropbox',
requiredScopes: [ requiredScopes: [
'account_info.read', 'account_info.read',
@@ -53,15 +51,6 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
placeholder: 'Select Dropbox account', placeholder: 'Select Dropbox account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Dropbox Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Upload operation inputs // Upload operation inputs
{ {
id: 'path', id: 'path',
@@ -363,7 +352,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Dropbox OAuth credential' }, credential: { type: 'string', description: 'Dropbox OAuth credential' },
// Common inputs // Common inputs
path: { type: 'string', description: 'Path in Dropbox' }, path: { type: 'string', description: 'Path in Dropbox' },
autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, autorename: { type: 'boolean', description: 'Auto-rename on conflict' },

View File

@@ -76,8 +76,6 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
id: 'credential', id: 'credential',
title: 'Gmail Account', title: 'Gmail Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'gmail', serviceId: 'gmail',
requiredScopes: [ requiredScopes: [
'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.send',
@@ -87,15 +85,6 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Select Gmail account', placeholder: 'Select Gmail account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Gmail Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Send Email Fields // Send Email Fields
{ {
id: 'to', id: 'to',
@@ -417,7 +406,7 @@ Return ONLY the search query - no explanations, no extra text.`,
tool: selectGmailToolId, tool: selectGmailToolId,
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
folder, folder,
addLabelIds, addLabelIds,
removeLabelIds, removeLabelIds,
@@ -478,7 +467,7 @@ Return ONLY the search query - no explanations, no extra text.`,
return { return {
...rest, ...rest,
oauthCredential, credential,
...(normalizedAttachments && { attachments: normalizedAttachments }), ...(normalizedAttachments && { attachments: normalizedAttachments }),
} }
}, },
@@ -486,7 +475,7 @@ Return ONLY the search query - no explanations, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Gmail access token' }, credential: { type: 'string', description: 'Gmail access token' },
// Send operation inputs // Send operation inputs
to: { type: 'string', description: 'Recipient email address' }, to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' }, subject: { type: 'string', description: 'Email subject' },

View File

@@ -39,22 +39,11 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
id: 'credential', id: 'credential',
title: 'Google Calendar Account', title: 'Google Calendar Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-calendar', serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'], requiredScopes: ['https://www.googleapis.com/auth/calendar'],
placeholder: 'Select Google Calendar account', placeholder: 'Select Google Calendar account',
}, },
{
id: 'manualCredential',
title: 'Google Calendar Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Calendar selector (basic mode) - not needed for list_calendars // Calendar selector (basic mode) - not needed for list_calendars
{ {
id: 'calendarId', id: 'calendarId',
@@ -523,7 +512,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
operation, operation,
attendees, attendees,
replaceExisting, replaceExisting,
@@ -587,7 +576,7 @@ Return ONLY the natural language event text - no explanations.`,
} }
return { return {
oauthCredential, credential,
...processedParams, ...processedParams,
} }
}, },
@@ -595,7 +584,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Calendar access token' }, credential: { type: 'string', description: 'Google Calendar access token' },
calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' }, calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' },
// Create/Update operation inputs // Create/Update operation inputs

View File

@@ -32,8 +32,6 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-docs', serviceId: 'google-docs',
requiredScopes: [ requiredScopes: [
@@ -42,15 +40,6 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Document selector (basic mode) // Document selector (basic mode)
{ {
id: 'documentId', id: 'documentId',
@@ -168,7 +157,7 @@ Return ONLY the document content - no explanations, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, documentId, folderId, ...rest } = params const { credential, documentId, folderId, ...rest } = params
const effectiveDocumentId = documentId ? String(documentId).trim() : '' const effectiveDocumentId = documentId ? String(documentId).trim() : ''
const effectiveFolderId = folderId ? String(folderId).trim() : '' const effectiveFolderId = folderId ? String(folderId).trim() : ''
@@ -177,14 +166,14 @@ Return ONLY the document content - no explanations, no extra text.`,
...rest, ...rest,
documentId: effectiveDocumentId || undefined, documentId: effectiveDocumentId || undefined,
folderId: effectiveFolderId || undefined, folderId: effectiveFolderId || undefined,
oauthCredential, credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Docs access token' }, credential: { type: 'string', description: 'Google Docs access token' },
documentId: { type: 'string', description: 'Document identifier (canonical param)' }, documentId: { type: 'string', description: 'Document identifier (canonical param)' },
title: { type: 'string', description: 'Document title' }, title: { type: 'string', description: 'Document title' },
folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' }, folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' },

View File

@@ -44,8 +44,6 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
id: 'credential', id: 'credential',
title: 'Google Drive Account', title: 'Google Drive Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -54,15 +52,6 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
], ],
placeholder: 'Select Google Drive account', placeholder: 'Select Google Drive account',
}, },
{
id: 'manualCredential',
title: 'Google Drive Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create/Upload File Fields // Create/Upload File Fields
{ {
id: 'fileName', id: 'fileName',
@@ -797,7 +786,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId, uploadFolderId,
createFolderParentId, createFolderParentId,
@@ -884,7 +873,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined
return { return {
oauthCredential, credential,
folderId: effectiveFolderId, folderId: effectiveFolderId,
fileId: effectiveFileId, fileId: effectiveFileId,
destinationFolderId: effectiveDestinationFolderId, destinationFolderId: effectiveDestinationFolderId,
@@ -902,7 +891,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Drive access token' }, credential: { type: 'string', description: 'Google Drive access token' },
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' }, uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' },
createFolderParentId: { type: 'string', description: 'Parent folder for create folder' }, createFolderParentId: { type: 'string', description: 'Parent folder for create folder' },

View File

@@ -34,8 +34,6 @@ export const GoogleFormsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-forms', serviceId: 'google-forms',
requiredScopes: [ requiredScopes: [
@@ -47,15 +45,6 @@ export const GoogleFormsBlock: BlockConfig = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Form selector (basic mode) // Form selector (basic mode)
{ {
id: 'formSelector', id: 'formSelector',
@@ -244,7 +233,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
operation, operation,
formId, // Canonical param from formSelector (basic) or manualFormId (advanced) formId, // Canonical param from formSelector (basic) or manualFormId (advanced)
responseId, responseId,
@@ -262,7 +251,7 @@ Example for "Add a required multiple choice question about favorite color":
...rest ...rest
} = params } = params
const baseParams = { ...rest, oauthCredential } const baseParams = { ...rest, credential }
const effectiveFormId = formId ? String(formId).trim() : undefined const effectiveFormId = formId ? String(formId).trim() : undefined
switch (operation) { switch (operation) {
@@ -320,7 +309,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google OAuth credential' }, credential: { type: 'string', description: 'Google OAuth credential' },
formId: { type: 'string', description: 'Google Form ID' }, formId: { type: 'string', description: 'Google Form ID' },
responseId: { type: 'string', description: 'Specific response ID' }, responseId: { type: 'string', description: 'Specific response ID' },
pageSize: { type: 'string', description: 'Max responses to retrieve' }, pageSize: { type: 'string', description: 'Max responses to retrieve' },

View File

@@ -42,8 +42,6 @@ export const GoogleGroupsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Groups Account', title: 'Google Groups Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-groups', serviceId: 'google-groups',
requiredScopes: [ requiredScopes: [
@@ -52,15 +50,6 @@ export const GoogleGroupsBlock: BlockConfig = {
], ],
placeholder: 'Select Google Workspace account', placeholder: 'Select Google Workspace account',
}, },
{
id: 'manualCredential',
title: 'Google Groups Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'customer', id: 'customer',
@@ -322,12 +311,12 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation, ...rest } = params const { credential, operation, ...rest } = params
switch (operation) { switch (operation) {
case 'list_groups': case 'list_groups':
return { return {
oauthCredential, credential,
customer: rest.customer, customer: rest.customer,
domain: rest.domain, domain: rest.domain,
query: rest.query, query: rest.query,
@@ -336,19 +325,19 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_group': case 'get_group':
case 'delete_group': case 'delete_group':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'create_group': case 'create_group':
return { return {
credential: oauthCredential, credential,
email: rest.email, email: rest.email,
name: rest.name, name: rest.name,
description: rest.description, description: rest.description,
} }
case 'update_group': case 'update_group':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
name: rest.newName, name: rest.newName,
email: rest.newEmail, email: rest.newEmail,
@@ -356,7 +345,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
case 'list_members': case 'list_members':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
roles: rest.roles, roles: rest.roles,
@@ -364,66 +353,66 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_member': case 'get_member':
case 'remove_member': case 'remove_member':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'add_member': case 'add_member':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
email: rest.memberEmail, email: rest.memberEmail,
role: rest.role, role: rest.role,
} }
case 'update_member': case 'update_member':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
role: rest.role, role: rest.role,
} }
case 'has_member': case 'has_member':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'list_aliases': case 'list_aliases':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'add_alias': case 'add_alias':
return { return {
credential: oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'remove_alias': case 'remove_alias':
return { return {
oauthCredential, credential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'get_settings': case 'get_settings':
return { return {
oauthCredential, credential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
case 'update_settings': case 'update_settings':
return { return {
oauthCredential, credential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
default: default:
return { oauthCredential, ...rest } return { credential, ...rest }
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Workspace OAuth credential' }, credential: { type: 'string', description: 'Google Workspace OAuth credential' },
customer: { type: 'string', description: 'Customer ID for listing groups' }, customer: { type: 'string', description: 'Customer ID for listing groups' },
domain: { type: 'string', description: 'Domain filter for listing groups' }, domain: { type: 'string', description: 'Domain filter for listing groups' },
query: { type: 'string', description: 'Search query for filtering groups' }, query: { type: 'string', description: 'Search query for filtering groups' },

View File

@@ -36,8 +36,6 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -46,15 +44,6 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector // Spreadsheet Selector
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -257,7 +246,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, values, spreadsheetId, ...rest } = params const { credential, values, spreadsheetId, ...rest } = params
const parsedValues = values ? JSON.parse(values as string) : undefined const parsedValues = values ? JSON.parse(values as string) : undefined
@@ -271,14 +260,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
values: parsedValues, values: parsedValues,
oauthCredential, credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Sheets access token' }, credential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
range: { type: 'string', description: 'Cell range' }, range: { type: 'string', description: 'Cell range' },
values: { type: 'string', description: 'Cell values data' }, values: { type: 'string', description: 'Cell values data' },
@@ -334,8 +323,6 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -344,15 +331,6 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector (basic mode) - not for create operation // Spreadsheet Selector (basic mode) - not for create operation
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -737,7 +715,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}), }),
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
values, values,
spreadsheetId, spreadsheetId,
sheetName, sheetName,
@@ -761,7 +739,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
title: (title as string)?.trim(), title: (title as string)?.trim(),
sheetTitles: sheetTitlesArray, sheetTitles: sheetTitlesArray,
oauthCredential, credential,
} }
} }
@@ -775,7 +753,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
if (operation === 'get_info') { if (operation === 'get_info') {
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
oauthCredential, credential,
} }
} }
@@ -785,7 +763,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
oauthCredential, credential,
} }
} }
@@ -796,7 +774,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
data: parsedData, data: parsedData,
oauthCredential, credential,
} }
} }
@@ -806,7 +784,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
oauthCredential, credential,
} }
} }
@@ -816,7 +794,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sourceSpreadsheetId: effectiveSpreadsheetId, sourceSpreadsheetId: effectiveSpreadsheetId,
sheetId: Number.parseInt(sheetId as string, 10), sheetId: Number.parseInt(sheetId as string, 10),
destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(), destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(),
oauthCredential, credential,
} }
} }
@@ -835,14 +813,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sheetName: effectiveSheetName, sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined, cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues, values: parsedValues,
oauthCredential, credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Sheets access token' }, credential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },

View File

@@ -46,8 +46,6 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -56,15 +54,6 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Presentation selector (basic mode) - for operations that need an existing presentation // Presentation selector (basic mode) - for operations that need an existing presentation
{ {
id: 'presentationId', id: 'presentationId',
@@ -673,7 +662,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
presentationId, presentationId,
folderId, folderId,
slideIndex, slideIndex,
@@ -690,7 +679,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
const result: Record<string, any> = { const result: Record<string, any> = {
...rest, ...rest,
presentationId: effectivePresentationId || undefined, presentationId: effectivePresentationId || undefined,
oauthCredential, credential,
} }
// Handle operation-specific params // Handle operation-specific params
@@ -810,7 +799,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Slides access token' }, credential: { type: 'string', description: 'Google Slides access token' },
presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' }, presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' },
// Write operation // Write operation
slideIndex: { type: 'number', description: 'Slide index to write to' }, slideIndex: { type: 'number', description: 'Slide index to write to' },

View File

@@ -34,8 +34,6 @@ export const GoogleVaultBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Vault Account', title: 'Google Vault Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-vault', serviceId: 'google-vault',
requiredScopes: [ requiredScopes: [
@@ -44,15 +42,6 @@ export const GoogleVaultBlock: BlockConfig = {
], ],
placeholder: 'Select Google Vault account', placeholder: 'Select Google Vault account',
}, },
{
id: 'manualCredential',
title: 'Google Vault Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create Hold inputs // Create Hold inputs
{ {
id: 'matterId', id: 'matterId',
@@ -449,10 +438,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, holdStartTime, holdEndTime, holdTerms, ...rest } = params const { credential, holdStartTime, holdEndTime, holdTerms, ...rest } = params
return { return {
...rest, ...rest,
oauthCredential, credential,
// Map hold-specific fields to their tool parameter names // Map hold-specific fields to their tool parameter names
...(holdStartTime && { startTime: holdStartTime }), ...(holdStartTime && { startTime: holdStartTime }),
...(holdEndTime && { endTime: holdEndTime }), ...(holdEndTime && { endTime: holdEndTime }),
@@ -464,7 +453,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
inputs: { inputs: {
// Core inputs // Core inputs
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Vault OAuth credential' }, credential: { type: 'string', description: 'Google Vault OAuth credential' },
matterId: { type: 'string', description: 'Matter ID' }, matterId: { type: 'string', description: 'Matter ID' },
// Create export inputs // Create export inputs

View File

@@ -39,8 +39,6 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
id: 'credential', id: 'credential',
title: 'HubSpot Account', title: 'HubSpot Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'hubspot', serviceId: 'hubspot',
requiredScopes: [ requiredScopes: [
'crm.objects.contacts.read', 'crm.objects.contacts.read',
@@ -70,15 +68,6 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
placeholder: 'Select HubSpot account', placeholder: 'Select HubSpot account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'HubSpot Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'contactId', id: 'contactId',
title: 'Contact ID or Email', title: 'Contact ID or Email',
@@ -834,7 +823,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
operation, operation,
propertiesToSet, propertiesToSet,
properties, properties,
@@ -846,7 +835,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
} = params } = params
const cleanParams: Record<string, any> = { const cleanParams: Record<string, any> = {
oauthCredential, credential,
} }
const createUpdateOps = [ const createUpdateOps = [
@@ -901,7 +890,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'HubSpot access token' }, credential: { type: 'string', description: 'HubSpot access token' },
contactId: { type: 'string', description: 'Contact ID or email' }, contactId: { type: 'string', description: 'Contact ID or email' },
companyId: { type: 'string', description: 'Company ID or domain' }, companyId: { type: 'string', description: 'Company ID or domain' },
idProperty: { type: 'string', description: 'Property name to use as unique identifier' }, idProperty: { type: 'string', description: 'Property name to use as unique identifier' },

View File

@@ -60,8 +60,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -98,15 +96,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Project selector (basic mode) // Project selector (basic mode)
{ {
id: 'projectId', id: 'projectId',
@@ -800,14 +789,14 @@ Return ONLY the comment text - no explanations.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, projectId, issueKey, ...rest } = params const { credential, projectId, issueKey, ...rest } = params
// Use canonical param IDs (raw subBlock IDs are deleted after serialization) // Use canonical param IDs (raw subBlock IDs are deleted after serialization)
const effectiveProjectId = projectId ? String(projectId).trim() : '' const effectiveProjectId = projectId ? String(projectId).trim() : ''
const effectiveIssueKey = issueKey ? String(issueKey).trim() : '' const effectiveIssueKey = issueKey ? String(issueKey).trim() : ''
const baseParams = { const baseParams = {
oauthCredential, credential,
domain: params.domain, domain: params.domain,
} }
@@ -1060,7 +1049,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
oauthCredential: { type: 'string', description: 'Jira access token' }, credential: { type: 'string', description: 'Jira access token' },
issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' }, issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' },
projectId: { type: 'string', description: 'Project identifier (canonical param)' }, projectId: { type: 'string', description: 'Project identifier (canonical param)' },
// Update/Write operation inputs // Update/Write operation inputs

View File

@@ -55,8 +55,6 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -97,15 +95,6 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'serviceDeskId', id: 'serviceDeskId',
title: 'Service Desk ID', title: 'Service Desk ID',
@@ -504,7 +493,7 @@ Return ONLY the comment text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const baseParams = { const baseParams = {
oauthCredential: params.oauthCredential, credential: params.credential,
domain: params.domain, domain: params.domain,
} }
@@ -751,7 +740,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
oauthCredential: { type: 'string', description: 'Jira Service Management access token' }, credential: { type: 'string', description: 'Jira Service Management access token' },
serviceDeskId: { type: 'string', description: 'Service desk ID' }, serviceDeskId: { type: 'string', description: 'Service desk ID' },
requestTypeId: { type: 'string', description: 'Request type ID' }, requestTypeId: { type: 'string', description: 'Request type ID' },
issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, issueIdOrKey: { type: 'string', description: 'Issue ID or key' },

View File

@@ -129,22 +129,11 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
id: 'credential', id: 'credential',
title: 'Linear Account', title: 'Linear Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'linear', serviceId: 'linear',
requiredScopes: ['read', 'write'], requiredScopes: ['read', 'write'],
placeholder: 'Select Linear account', placeholder: 'Select Linear account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Linear Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Team selector (for most operations) // Team selector (for most operations)
{ {
id: 'teamId', id: 'teamId',
@@ -1515,7 +1504,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Base params that most operations need // Base params that most operations need
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
oauthCredential: params.oauthCredential, credential: params.credential,
} }
// Operation-specific param mapping // Operation-specific param mapping
@@ -2334,7 +2323,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Linear access token' }, credential: { type: 'string', description: 'Linear access token' },
teamId: { type: 'string', description: 'Linear team identifier (canonical param)' }, teamId: { type: 'string', description: 'Linear team identifier (canonical param)' },
projectId: { type: 'string', description: 'Linear project identifier (canonical param)' }, projectId: { type: 'string', description: 'Linear project identifier (canonical param)' },
issueId: { type: 'string', description: 'Issue identifier' }, issueId: { type: 'string', description: 'Issue identifier' },

View File

@@ -33,21 +33,10 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
title: 'LinkedIn Account', title: 'LinkedIn Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'linkedin', serviceId: 'linkedin',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'], requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
placeholder: 'Select LinkedIn account', placeholder: 'Select LinkedIn account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'LinkedIn Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Share Post specific fields // Share Post specific fields
{ {
@@ -91,25 +80,25 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
}, },
params: (inputs) => { params: (inputs) => {
const operation = inputs.operation || 'share_post' const operation = inputs.operation || 'share_post'
const { oauthCredential, ...rest } = inputs const { credential, ...rest } = inputs
if (operation === 'get_profile') { if (operation === 'get_profile') {
return { return {
accessToken: oauthCredential, accessToken: credential,
} }
} }
return { return {
text: rest.text, text: rest.text,
visibility: rest.visibility || 'PUBLIC', visibility: rest.visibility || 'PUBLIC',
accessToken: oauthCredential, accessToken: credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'LinkedIn access token' }, credential: { type: 'string', description: 'LinkedIn access token' },
text: { type: 'string', description: 'Post text content' }, text: { type: 'string', description: 'Post text content' },
visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' }, visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' },
}, },

View File

@@ -36,8 +36,6 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel', serviceId: 'microsoft-excel',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -50,15 +48,6 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
title: 'Select Sheet', title: 'Select Sheet',
@@ -252,7 +241,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, values, spreadsheetId, tableName, worksheetName, ...rest } = params const { credential, values, spreadsheetId, tableName, worksheetName, ...rest } = params
// Use canonical param ID (raw subBlock IDs are deleted after serialization) // Use canonical param ID (raw subBlock IDs are deleted after serialization)
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : '' const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
@@ -280,7 +269,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
values: parsedValues, values: parsedValues,
oauthCredential, credential,
} }
if (params.operation === 'table_add') { if (params.operation === 'table_add') {
@@ -303,7 +292,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' }, credential: { type: 'string', description: 'Microsoft Excel access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
range: { type: 'string', description: 'Cell range' }, range: { type: 'string', description: 'Cell range' },
tableName: { type: 'string', description: 'Table name' }, tableName: { type: 'string', description: 'Table name' },
@@ -362,8 +351,6 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel', serviceId: 'microsoft-excel',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -376,15 +363,6 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector (basic mode) // Spreadsheet Selector (basic mode)
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -519,7 +497,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
fallbackToolId: 'microsoft_excel_read_v2', fallbackToolId: 'microsoft_excel_read_v2',
}), }),
params: (params) => { params: (params) => {
const { oauthCredential, values, spreadsheetId, sheetName, cellRange, ...rest } = params const { credential, values, spreadsheetId, sheetName, cellRange, ...rest } = params
const parsedValues = values ? JSON.parse(values as string) : undefined const parsedValues = values ? JSON.parse(values as string) : undefined
@@ -541,14 +519,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sheetName: effectiveSheetName, sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined, cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues, values: parsedValues,
oauthCredential, credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' }, credential: { type: 'string', description: 'Microsoft Excel access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },

View File

@@ -4,7 +4,7 @@ import { AuthMode } from '@/blocks/types'
import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types' import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types'
interface MicrosoftPlannerBlockParams { interface MicrosoftPlannerBlockParams {
oauthCredential: string credential: string
accessToken?: string accessToken?: string
planId?: string planId?: string
taskId?: string taskId?: string
@@ -61,8 +61,6 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-planner', serviceId: 'microsoft-planner',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -75,14 +73,6 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
// Plan ID - for various operations // Plan ID - for various operations
{ {
@@ -360,7 +350,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
operation, operation,
groupId, groupId,
planId, planId,
@@ -385,7 +375,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
const baseParams: MicrosoftPlannerBlockParams = { const baseParams: MicrosoftPlannerBlockParams = {
...rest, ...rest,
oauthCredential, credential,
} }
// Handle different task ID fields based on operation // Handle different task ID fields based on operation
@@ -570,7 +560,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft account credential' }, credential: { type: 'string', description: 'Microsoft account credential' },
groupId: { type: 'string', description: 'Microsoft 365 group ID' }, groupId: { type: 'string', description: 'Microsoft 365 group ID' },
planId: { type: 'string', description: 'Plan ID' }, planId: { type: 'string', description: 'Plan ID' },
readTaskId: { type: 'string', description: 'Task ID for read operation' }, readTaskId: { type: 'string', description: 'Task ID for read operation' },

View File

@@ -44,8 +44,6 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-teams', serviceId: 'microsoft-teams',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -72,15 +70,6 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'teamSelector', id: 'teamSelector',
title: 'Select Team', title: 'Select Team',
@@ -332,7 +321,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
operation, operation,
teamId, // Canonical param from teamSelector (basic) or manualTeamId (advanced) teamId, // Canonical param from teamSelector (basic) or manualTeamId (advanced)
chatId, // Canonical param from chatSelector (basic) or manualChatId (advanced) chatId, // Canonical param from chatSelector (basic) or manualChatId (advanced)
@@ -350,7 +339,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
...rest, ...rest,
oauthCredential, credential,
} }
if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) { if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) {
@@ -430,7 +419,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft Teams access token' }, credential: { type: 'string', description: 'Microsoft Teams access token' },
messageId: { messageId: {
type: 'string', type: 'string',
description: 'Message identifier for update/delete/reply/reaction operations', description: 'Message identifier for update/delete/reply/reaction operations',

View File

@@ -38,21 +38,10 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
id: 'credential', id: 'credential',
title: 'Notion Account', title: 'Notion Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'notion', serviceId: 'notion',
placeholder: 'Select Notion account', placeholder: 'Select Notion account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Notion Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Read/Write operation - Page ID // Read/Write operation - Page ID
{ {
id: 'pageId', id: 'pageId',
@@ -313,7 +302,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation, properties, filter, sorts, ...rest } = params const { credential, operation, properties, filter, sorts, ...rest } = params
// Parse properties from JSON string for create/add operations // Parse properties from JSON string for create/add operations
let parsedProperties let parsedProperties
@@ -362,7 +351,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
return { return {
...rest, ...rest,
oauthCredential, credential,
...(parsedProperties ? { properties: parsedProperties } : {}), ...(parsedProperties ? { properties: parsedProperties } : {}),
...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}), ...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}),
...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}), ...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}),
@@ -372,7 +361,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Notion access token' }, credential: { type: 'string', description: 'Notion access token' },
pageId: { type: 'string', description: 'Page identifier' }, pageId: { type: 'string', description: 'Page identifier' },
content: { type: 'string', description: 'Page content' }, content: { type: 'string', description: 'Page content' },
// Create page inputs // Create page inputs

View File

@@ -39,8 +39,6 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'onedrive', serviceId: 'onedrive',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -52,14 +50,6 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
// Create File Fields // Create File Fields
{ {
id: 'fileName', id: 'fileName',
@@ -365,7 +355,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId, uploadFolderId,
createFolderParentId, createFolderParentId,
@@ -415,7 +405,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
} }
return { return {
oauthCredential, credential,
...rest, ...rest,
values: normalizedValues, values: normalizedValues,
file: normalizedFile, file: normalizedFile,
@@ -430,7 +420,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft account credential' }, credential: { type: 'string', description: 'Microsoft account credential' },
// Upload and Create operation inputs // Upload and Create operation inputs
fileName: { type: 'string', description: 'File name' }, fileName: { type: 'string', description: 'File name' },
file: { type: 'json', description: 'File to upload (UserFile object)' }, file: { type: 'json', description: 'File to upload (UserFile object)' },

View File

@@ -39,8 +39,6 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'outlook', serviceId: 'outlook',
requiredScopes: [ requiredScopes: [
'Mail.ReadWrite', 'Mail.ReadWrite',
@@ -55,15 +53,6 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'to', id: 'to',
title: 'To', title: 'To',
@@ -337,7 +326,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
folder, folder,
destinationId, destinationId,
copyDestinationId, copyDestinationId,
@@ -396,14 +385,14 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
return { return {
...rest, ...rest,
oauthCredential, credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Outlook access token' }, credential: { type: 'string', description: 'Outlook access token' },
// Send operation inputs // Send operation inputs
to: { type: 'string', description: 'Recipient email address' }, to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' }, subject: { type: 'string', description: 'Email subject' },

View File

@@ -45,8 +45,6 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
id: 'credential', id: 'credential',
title: 'Pipedrive Account', title: 'Pipedrive Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'pipedrive', serviceId: 'pipedrive',
requiredScopes: [ requiredScopes: [
'base', 'base',
@@ -60,15 +58,6 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
placeholder: 'Select Pipedrive account', placeholder: 'Select Pipedrive account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Pipedrive Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'status', id: 'status',
title: 'Status', title: 'Status',
@@ -757,10 +746,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation, ...rest } = params const { credential, operation, ...rest } = params
const cleanParams: Record<string, any> = { const cleanParams: Record<string, any> = {
oauthCredential, credential,
} }
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
@@ -775,7 +764,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Pipedrive access token' }, credential: { type: 'string', description: 'Pipedrive access token' },
deal_id: { type: 'string', description: 'Deal ID' }, deal_id: { type: 'string', description: 'Deal ID' },
title: { type: 'string', description: 'Title' }, title: { type: 'string', description: 'Title' },
value: { type: 'string', description: 'Monetary value' }, value: { type: 'string', description: 'Monetary value' },

View File

@@ -43,8 +43,6 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
title: 'Reddit Account', title: 'Reddit Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'reddit', serviceId: 'reddit',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'identity', 'identity',
'read', 'read',
@@ -66,15 +64,6 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
placeholder: 'Select Reddit account', placeholder: 'Select Reddit account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Reddit Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Common fields - appear for all actions // Common fields - appear for all actions
{ {
@@ -566,7 +555,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
}, },
params: (inputs) => { params: (inputs) => {
const operation = inputs.operation || 'get_posts' const operation = inputs.operation || 'get_posts'
const { oauthCredential, ...rest } = inputs const { credential, ...rest } = inputs
if (operation === 'get_comments') { if (operation === 'get_comments') {
return { return {
@@ -574,7 +563,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
subreddit: rest.subreddit, subreddit: rest.subreddit,
sort: rest.commentSort, sort: rest.commentSort,
limit: rest.commentLimit ? Number.parseInt(rest.commentLimit) : undefined, limit: rest.commentLimit ? Number.parseInt(rest.commentLimit) : undefined,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -583,7 +572,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
subreddit: rest.subreddit, subreddit: rest.subreddit,
time: rest.controversialTime, time: rest.controversialTime,
limit: rest.controversialLimit ? Number.parseInt(rest.controversialLimit) : undefined, limit: rest.controversialLimit ? Number.parseInt(rest.controversialLimit) : undefined,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -594,7 +583,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
sort: rest.searchSort, sort: rest.searchSort,
time: rest.searchTime, time: rest.searchTime,
limit: rest.searchLimit ? Number.parseInt(rest.searchLimit) : undefined, limit: rest.searchLimit ? Number.parseInt(rest.searchLimit) : undefined,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -606,7 +595,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
url: rest.postType === 'link' ? rest.url : undefined, url: rest.postType === 'link' ? rest.url : undefined,
nsfw: rest.nsfw === 'true', nsfw: rest.nsfw === 'true',
spoiler: rest.spoiler === 'true', spoiler: rest.spoiler === 'true',
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -614,7 +603,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
id: rest.voteId, id: rest.voteId,
dir: Number.parseInt(rest.voteDirection), dir: Number.parseInt(rest.voteDirection),
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -622,14 +611,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
id: rest.saveId, id: rest.saveId,
category: rest.saveCategory, category: rest.saveCategory,
oauthCredential: oauthCredential, credential: credential,
} }
} }
if (operation === 'unsave') { if (operation === 'unsave') {
return { return {
id: rest.saveId, id: rest.saveId,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -637,7 +626,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
parent_id: rest.replyParentId, parent_id: rest.replyParentId,
text: rest.replyText, text: rest.replyText,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -645,14 +634,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
thing_id: rest.editThingId, thing_id: rest.editThingId,
text: rest.editText, text: rest.editText,
oauthCredential: oauthCredential, credential: credential,
} }
} }
if (operation === 'delete') { if (operation === 'delete') {
return { return {
id: rest.deleteId, id: rest.deleteId,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -660,7 +649,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
return { return {
subreddit: rest.subscribeSubreddit, subreddit: rest.subscribeSubreddit,
action: rest.subscribeAction, action: rest.subscribeAction,
oauthCredential: oauthCredential, credential: credential,
} }
} }
@@ -669,14 +658,14 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
sort: rest.sort, sort: rest.sort,
limit: rest.limit ? Number.parseInt(rest.limit) : undefined, limit: rest.limit ? Number.parseInt(rest.limit) : undefined,
time: rest.sort === 'top' ? rest.time : undefined, time: rest.sort === 'top' ? rest.time : undefined,
oauthCredential: oauthCredential, credential: credential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Reddit access token' }, credential: { type: 'string', description: 'Reddit access token' },
subreddit: { type: 'string', description: 'Subreddit name' }, subreddit: { type: 'string', description: 'Subreddit name' },
sort: { type: 'string', description: 'Sort order' }, sort: { type: 'string', description: 'Sort order' },
time: { type: 'string', description: 'Time filter' }, time: { type: 'string', description: 'Time filter' },

View File

@@ -62,22 +62,11 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
id: 'credential', id: 'credential',
title: 'Salesforce Account', title: 'Salesforce Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'salesforce', serviceId: 'salesforce',
requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'], requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'],
placeholder: 'Select Salesforce account', placeholder: 'Select Salesforce account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Salesforce Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Common fields for GET operations // Common fields for GET operations
{ {
id: 'fields', id: 'fields',
@@ -625,8 +614,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation, ...rest } = params const { credential, operation, ...rest } = params
const cleanParams: Record<string, any> = { oauthCredential } const cleanParams: Record<string, any> = { credential }
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== '') {
cleanParams[key] = value cleanParams[key] = value
@@ -638,7 +627,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Salesforce credential' }, credential: { type: 'string', description: 'Salesforce credential' },
}, },
outputs: { outputs: {
success: { type: 'boolean', description: 'Operation success status' }, success: { type: 'boolean', description: 'Operation success status' },

View File

@@ -38,8 +38,6 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
id: 'credential', id: 'credential',
title: 'Microsoft Account', title: 'Microsoft Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'sharepoint', serviceId: 'sharepoint',
requiredScopes: [ requiredScopes: [
'openid', 'openid',
@@ -52,14 +50,6 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
], ],
placeholder: 'Select Microsoft account', placeholder: 'Select Microsoft account',
}, },
{
id: 'manualCredential',
title: 'Microsoft Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
{ {
id: 'siteSelector', id: 'siteSelector',
@@ -413,7 +403,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, siteId, mimeType, ...rest } = params const { credential, siteId, mimeType, ...rest } = params
// siteId is the canonical param from siteSelector (basic) or manualSiteId (advanced) // siteId is the canonical param from siteSelector (basic) or manualSiteId (advanced)
const effectiveSiteId = siteId ? String(siteId).trim() : '' const effectiveSiteId = siteId ? String(siteId).trim() : ''
@@ -471,7 +461,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
// Handle file upload files parameter using canonical param // Handle file upload files parameter using canonical param
const normalizedFiles = normalizeFileInput(files) const normalizedFiles = normalizeFileInput(files)
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
oauthCredential, credential,
siteId: effectiveSiteId || undefined, siteId: effectiveSiteId || undefined,
pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined, pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
mimeType: mimeType, mimeType: mimeType,
@@ -497,7 +487,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Microsoft account credential' }, credential: { type: 'string', description: 'Microsoft account credential' },
pageName: { type: 'string', description: 'Page name' }, pageName: { type: 'string', description: 'Page name' },
columnDefinitions: { columnDefinitions: {
type: 'string', type: 'string',

View File

@@ -61,8 +61,6 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
title: 'Shopify Account', title: 'Shopify Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'shopify', serviceId: 'shopify',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'write_products', 'write_products',
'write_orders', 'write_orders',
@@ -74,15 +72,6 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
placeholder: 'Select Shopify account', placeholder: 'Select Shopify account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Shopify Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'shopDomain', id: 'shopDomain',
title: 'Shop Domain', title: 'Shop Domain',
@@ -538,7 +527,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
}, },
params: (params) => { params: (params) => {
const baseParams: Record<string, unknown> = { const baseParams: Record<string, unknown> = {
oauthCredential: params.oauthCredential, credential: params.credential,
shopDomain: params.shopDomain?.trim(), shopDomain: params.shopDomain?.trim(),
} }
@@ -785,7 +774,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Shopify access token' }, credential: { type: 'string', description: 'Shopify access token' },
shopDomain: { type: 'string', description: 'Shopify store domain' }, shopDomain: { type: 'string', description: 'Shopify store domain' },
// Product inputs // Product inputs
productId: { type: 'string', description: 'Product ID' }, productId: { type: 'string', description: 'Product ID' },

View File

@@ -69,8 +69,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'credential', id: 'credential',
title: 'Slack Account', title: 'Slack Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'slack', serviceId: 'slack',
requiredScopes: [ requiredScopes: [
'channels:read', 'channels:read',
@@ -96,20 +94,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
}, },
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Slack Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
dependsOn: ['authMethod'],
condition: {
field: 'authMethod',
value: 'oauth',
},
required: true,
},
{ {
id: 'botToken', id: 'botToken',
title: 'Bot Token', title: 'Bot Token',
@@ -563,7 +547,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
authMethod, authMethod,
botToken, botToken,
operation, operation,
@@ -613,7 +597,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
baseParams.accessToken = botToken baseParams.accessToken = botToken
} else { } else {
// Default to OAuth // Default to OAuth
baseParams.credential = oauthCredential baseParams.credential = credential
} }
switch (operation) { switch (operation) {
@@ -717,7 +701,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
authMethod: { type: 'string', description: 'Authentication method' }, authMethod: { type: 'string', description: 'Authentication method' },
destinationType: { type: 'string', description: 'Destination type (channel or dm)' }, destinationType: { type: 'string', description: 'Destination type (channel or dm)' },
oauthCredential: { type: 'string', description: 'Slack access token' }, credential: { type: 'string', description: 'Slack access token' },
botToken: { type: 'string', description: 'Bot token' }, botToken: { type: 'string', description: 'Bot token' },
channel: { type: 'string', description: 'Channel identifier (canonical param)' }, channel: { type: 'string', description: 'Channel identifier (canonical param)' },
dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' }, dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' },

View File

@@ -160,17 +160,6 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
title: 'Spotify Account', title: 'Spotify Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'spotify', serviceId: 'spotify',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
},
{
id: 'manualCredential',
title: 'Spotify Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true, required: true,
}, },
@@ -807,7 +796,7 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Spotify OAuth credential' }, credential: { type: 'string', description: 'Spotify OAuth credential' },
// Search // Search
query: { type: 'string', description: 'Search query' }, query: { type: 'string', description: 'Search query' },
type: { type: 'string', description: 'Search type' }, type: { type: 'string', description: 'Search type' },

View File

@@ -42,21 +42,10 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
title: 'Trello Account', title: 'Trello Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'trello', serviceId: 'trello',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['read', 'write'], requiredScopes: ['read', 'write'],
placeholder: 'Select Trello account', placeholder: 'Select Trello account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Trello Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'boardId', id: 'boardId',
@@ -405,7 +394,7 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Trello operation to perform' }, operation: { type: 'string', description: 'Trello operation to perform' },
oauthCredential: { type: 'string', description: 'Trello OAuth credential' }, credential: { type: 'string', description: 'Trello OAuth credential' },
boardId: { type: 'string', description: 'Board ID' }, boardId: { type: 'string', description: 'Board ID' },
listId: { type: 'string', description: 'List ID' }, listId: { type: 'string', description: 'List ID' },
cardId: { type: 'string', description: 'Card ID' }, cardId: { type: 'string', description: 'Card ID' },

View File

@@ -33,22 +33,11 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
id: 'credential', id: 'credential',
title: 'Wealthbox Account', title: 'Wealthbox Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wealthbox', serviceId: 'wealthbox',
requiredScopes: ['login', 'data'], requiredScopes: ['login', 'data'],
placeholder: 'Select Wealthbox account', placeholder: 'Select Wealthbox account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Wealthbox Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'noteId', id: 'noteId',
title: 'Note ID', title: 'Note ID',
@@ -180,14 +169,14 @@ Return ONLY the date/time string - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, operation, contactId, taskId, ...rest } = params const { credential, operation, contactId, taskId, ...rest } = params
// contactId is the canonical param for both basic (file-selector) and advanced (manualContactId) modes // contactId is the canonical param for both basic (file-selector) and advanced (manualContactId) modes
const effectiveContactId = contactId ? String(contactId).trim() : '' const effectiveContactId = contactId ? String(contactId).trim() : ''
const baseParams = { const baseParams = {
...rest, ...rest,
credential: oauthCredential, credential,
} }
if (operation === 'read_note' || operation === 'write_note') { if (operation === 'read_note' || operation === 'write_note') {
@@ -231,7 +220,7 @@ Return ONLY the date/time string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Wealthbox access token' }, credential: { type: 'string', description: 'Wealthbox access token' },
noteId: { type: 'string', description: 'Note identifier' }, noteId: { type: 'string', description: 'Note identifier' },
contactId: { type: 'string', description: 'Contact identifier' }, contactId: { type: 'string', description: 'Contact identifier' },
taskId: { type: 'string', description: 'Task identifier' }, taskId: { type: 'string', description: 'Task identifier' },

View File

@@ -34,22 +34,11 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
id: 'credential', id: 'credential',
title: 'Webflow Account', title: 'Webflow Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'webflow', serviceId: 'webflow',
requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'], requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'],
placeholder: 'Select Webflow account', placeholder: 'Select Webflow account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Webflow Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'siteSelector', id: 'siteSelector',
title: 'Site', title: 'Site',
@@ -167,7 +156,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
oauthCredential, credential,
fieldData, fieldData,
siteId, // Canonical param from siteSelector (basic) or manualSiteId (advanced) siteId, // Canonical param from siteSelector (basic) or manualSiteId (advanced)
collectionId, // Canonical param from collectionSelector (basic) or manualCollectionId (advanced) collectionId, // Canonical param from collectionSelector (basic) or manualCollectionId (advanced)
@@ -189,7 +178,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
const effectiveItemId = itemId ? String(itemId).trim() : '' const effectiveItemId = itemId ? String(itemId).trim() : ''
const baseParams = { const baseParams = {
credential: oauthCredential, credential,
siteId: effectiveSiteId, siteId: effectiveSiteId,
collectionId: effectiveCollectionId, collectionId: effectiveCollectionId,
...rest, ...rest,
@@ -214,7 +203,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Webflow OAuth access token' }, credential: { type: 'string', description: 'Webflow OAuth access token' },
siteId: { type: 'string', description: 'Webflow site identifier' }, siteId: { type: 'string', description: 'Webflow site identifier' },
collectionId: { type: 'string', description: 'Webflow collection identifier' }, collectionId: { type: 'string', description: 'Webflow collection identifier' },
itemId: { type: 'string', description: 'Item identifier' }, itemId: { type: 'string', description: 'Item identifier' },

View File

@@ -65,22 +65,11 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
id: 'credential', id: 'credential',
title: 'WordPress Account', title: 'WordPress Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wordpress', serviceId: 'wordpress',
requiredScopes: ['global'], requiredScopes: ['global'],
placeholder: 'Select WordPress account', placeholder: 'Select WordPress account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'WordPress Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Site ID for WordPress.com (required for OAuth) // Site ID for WordPress.com (required for OAuth)
{ {
@@ -678,7 +667,7 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
params: (params) => { params: (params) => {
// OAuth authentication for WordPress.com // OAuth authentication for WordPress.com
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.oauthCredential, credential: params.credential,
siteId: params.siteId, siteId: params.siteId,
} }
@@ -901,7 +890,6 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'WordPress OAuth credential' },
siteId: { type: 'string', description: 'WordPress.com site ID or domain' }, siteId: { type: 'string', description: 'WordPress.com site ID or domain' },
// Post inputs // Post inputs
postId: { type: 'number', description: 'Post ID' }, postId: { type: 'number', description: 'Post ID' },

View File

@@ -32,19 +32,9 @@ export const XBlock: BlockConfig<XResponse> = {
title: 'X Account', title: 'X Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'x', serviceId: 'x',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], requiredScopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
placeholder: 'Select X account', placeholder: 'Select X account',
}, },
{
id: 'manualCredential',
title: 'X Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
},
{ {
id: 'text', id: 'text',
title: 'Tweet Text', title: 'Tweet Text',
@@ -181,10 +171,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { oauthCredential, ...rest } = params const { credential, ...rest } = params
const parsedParams: Record<string, any> = { const parsedParams: Record<string, any> = {
credential: oauthCredential, credential: credential,
} }
Object.keys(rest).forEach((key) => { Object.keys(rest).forEach((key) => {
@@ -210,7 +200,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'X account credential' }, credential: { type: 'string', description: 'X account credential' },
text: { type: 'string', description: 'Tweet text content' }, text: { type: 'string', description: 'Tweet text content' },
replyTo: { type: 'string', description: 'Reply to tweet ID' }, replyTo: { type: 'string', description: 'Reply to tweet ID' },
mediaIds: { type: 'string', description: 'Media identifiers' }, mediaIds: { type: 'string', description: 'Media identifiers' },

View File

@@ -38,8 +38,6 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
title: 'Zoom Account', title: 'Zoom Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'zoom', serviceId: 'zoom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [ requiredScopes: [
'user:read:user', 'user:read:user',
'meeting:write:meeting', 'meeting:write:meeting',
@@ -56,15 +54,6 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
placeholder: 'Select Zoom account', placeholder: 'Select Zoom account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Zoom Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// User ID for create/list operations // User ID for create/list operations
{ {
id: 'userId', id: 'userId',
@@ -424,7 +413,7 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`,
}, },
params: (params) => { params: (params) => {
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.oauthCredential, credential: params.credential,
} }
switch (params.operation) { switch (params.operation) {
@@ -569,7 +558,7 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Zoom access token' }, credential: { type: 'string', description: 'Zoom access token' },
userId: { type: 'string', description: 'User ID or email (use "me" for authenticated user)' }, userId: { type: 'string', description: 'User ID or email (use "me" for authenticated user)' },
meetingId: { type: 'string', description: 'Meeting ID' }, meetingId: { type: 'string', description: 'Meeting ID' },
topic: { type: 'string', description: 'Meeting topic' }, topic: { type: 'string', description: 'Meeting topic' },

View File

@@ -205,6 +205,10 @@ export const CREDENTIAL_SET = {
PREFIX: 'credentialSet:', PREFIX: 'credentialSet:',
} as const } as const
export const CREDENTIAL = {
FOREIGN_LABEL: 'Saved by collaborator',
} as const
export function isCredentialSetValue(value: string | null | undefined): boolean { export function isCredentialSetValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX) return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
} }

View File

@@ -264,7 +264,6 @@ export class DAGExecutor {
executionId: this.contextExtensions.executionId, executionId: this.contextExtensions.executionId,
userId: this.contextExtensions.userId, userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext, isDeployedContext: this.contextExtensions.isDeployedContext,
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
blockStates: state.getBlockStates(), blockStates: state.getBlockStates(),
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []), blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: { metadata: {

View File

@@ -16,7 +16,6 @@ export interface ExecutionMetadata {
useDraftState: boolean useDraftState: boolean
startTime: string startTime: string
isClientSession?: boolean isClientSession?: boolean
enforceCredentialAccess?: boolean
pendingBlocks?: string[] pendingBlocks?: string[]
resumeFromSnapshot?: boolean resumeFromSnapshot?: boolean
credentialAccountUserId?: string credentialAccountUserId?: string
@@ -81,7 +80,6 @@ export interface ContextExtensions {
selectedOutputs?: string[] selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }> edges?: Array<{ source: string; target: string }>
isDeployedContext?: boolean isDeployedContext?: boolean
enforceCredentialAccess?: boolean
isChildExecution?: boolean isChildExecution?: boolean
resumeFromSnapshot?: boolean resumeFromSnapshot?: boolean
resumePendingQueue?: string[] resumePendingQueue?: string[]

View File

@@ -336,7 +336,6 @@ export class AgentBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -74,7 +74,6 @@ export class ApiBlockHandler implements BlockHandler {
executionId: ctx.executionId, executionId: ctx.executionId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -50,7 +50,6 @@ export async function evaluateConditionExpression(
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -41,7 +41,6 @@ export class FunctionBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -68,7 +68,6 @@ export class GenericBlockHandler implements BlockHandler {
executionId: ctx.executionId, executionId: ctx.executionId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
}, },
false, false,

View File

@@ -607,7 +607,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
}, },
blockData: blockDataWithPause, blockData: blockDataWithPause,
blockNameMapping: blockNameMappingWithPause, blockNameMapping: blockNameMappingWithPause,

View File

@@ -123,7 +123,6 @@ export class WorkflowBlockHandler implements BlockHandler {
contextExtensions: { contextExtensions: {
isChildExecution: true, isChildExecution: true,
isDeployedContext: ctx.isDeployedContext === true, isDeployedContext: ctx.isDeployedContext === true,
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
userId: ctx.userId, userId: ctx.userId,
executionId: ctx.executionId, executionId: ctx.executionId,

View File

@@ -168,7 +168,6 @@ export interface ExecutionContext {
executionId?: string executionId?: string
userId?: string userId?: string
isDeployedContext?: boolean isDeployedContext?: boolean
enforceCredentialAccess?: boolean
permissionConfig?: PermissionGroupConfig | null permissionConfig?: PermissionGroupConfig | null
permissionConfigLoaded?: boolean permissionConfigLoaded?: boolean

View File

@@ -1,272 +0,0 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { environmentKeys } from '@/hooks/queries/environment'
import { fetchJson } from '@/hooks/selectors/helpers'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
export type WorkspaceCredentialRole = 'admin' | 'member'
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
export interface WorkspaceCredential {
id: string
workspaceId: string
type: WorkspaceCredentialType
displayName: string
description: string | null
providerId: string | null
accountId: string | null
envKey: string | null
envOwnerUserId: string | null
createdBy: string
createdAt: string
updatedAt: string
role?: WorkspaceCredentialRole
status?: WorkspaceCredentialMemberStatus
}
export interface WorkspaceCredentialMember {
id: string
userId: string
role: WorkspaceCredentialRole
status: WorkspaceCredentialMemberStatus
joinedAt: string | null
invitedBy: string | null
createdAt: string
updatedAt: string
userName: string | null
userEmail: string | null
userImage: string | null
}
interface CredentialListResponse {
credentials?: WorkspaceCredential[]
}
interface CredentialResponse {
credential?: WorkspaceCredential | null
}
interface MembersResponse {
members?: WorkspaceCredentialMember[]
}
export const workspaceCredentialKeys = {
all: ['workspaceCredentials'] as const,
list: (workspaceId?: string, type?: string, providerId?: string) =>
['workspaceCredentials', workspaceId ?? 'none', type ?? 'all', providerId ?? 'all'] as const,
detail: (credentialId?: string) =>
['workspaceCredentials', 'detail', credentialId ?? 'none'] as const,
members: (credentialId?: string) =>
['workspaceCredentials', 'detail', credentialId ?? 'none', 'members'] as const,
}
export function useWorkspaceCredentials(params: {
workspaceId?: string
type?: WorkspaceCredentialType
providerId?: string
enabled?: boolean
}) {
const { workspaceId, type, providerId, enabled = true } = params
return useQuery<WorkspaceCredential[]>({
queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId),
queryFn: async () => {
if (!workspaceId) return []
const data = await fetchJson<CredentialListResponse>('/api/credentials', {
searchParams: {
workspaceId,
type,
providerId,
},
})
return data.credentials ?? []
},
enabled: Boolean(workspaceId) && enabled,
staleTime: 60 * 1000,
})
}
export function useWorkspaceCredential(credentialId?: string, enabled = true) {
return useQuery<WorkspaceCredential | null>({
queryKey: workspaceCredentialKeys.detail(credentialId),
queryFn: async () => {
if (!credentialId) return null
const data = await fetchJson<CredentialResponse>(`/api/credentials/${credentialId}`)
return data.credential ?? null
},
enabled: Boolean(credentialId) && enabled,
staleTime: 60 * 1000,
})
}
export function useCreateWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
workspaceId: string
type: WorkspaceCredentialType
displayName?: string
description?: string
providerId?: string
accountId?: string
envKey?: string
envOwnerUserId?: string
}) => {
const response = await fetch('/api/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create credential')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.list(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.all,
})
},
})
}
export function useUpdateWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
credentialId: string
displayName?: string
description?: string | null
accountId?: string
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: payload.displayName,
description: payload.description,
accountId: payload.accountId,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update credential')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.all,
})
},
})
}
export function useDeleteWorkspaceCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (credentialId: string) => {
const response = await fetch(`/api/credentials/${credentialId}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to delete credential')
}
return response.json()
},
onSuccess: (_data, credentialId) => {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
},
})
}
export function useWorkspaceCredentialMembers(credentialId?: string) {
return useQuery<WorkspaceCredentialMember[]>({
queryKey: workspaceCredentialKeys.members(credentialId),
queryFn: async () => {
if (!credentialId) return []
const data = await fetchJson<MembersResponse>(`/api/credentials/${credentialId}/members`)
return data.members ?? []
},
enabled: Boolean(credentialId),
staleTime: 30 * 1000,
})
}
export function useUpsertWorkspaceCredentialMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: {
credentialId: string
userId: string
role: WorkspaceCredentialRole
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: payload.userId,
role: payload.role,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update credential member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
},
})
}
export function useRemoveWorkspaceCredentialMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { credentialId: string; userId: string }) => {
const response = await fetch(
`/api/credentials/${payload.credentialId}/members?userId=${encodeURIComponent(payload.userId)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to remove credential member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
},
})
}

View File

@@ -169,9 +169,9 @@ export function useConnectOAuthService() {
interface DisconnectServiceParams { interface DisconnectServiceParams {
provider: string provider: string
providerId?: string providerId: string
serviceId: string serviceId: string
accountId?: string accountId: string
} }
/** /**
@@ -182,7 +182,7 @@ export function useDisconnectOAuthService() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => { mutationFn: async ({ provider, providerId }: DisconnectServiceParams) => {
const response = await fetch('/api/auth/oauth/disconnect', { const response = await fetch('/api/auth/oauth/disconnect', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -191,7 +191,6 @@ export function useDisconnectOAuthService() {
body: JSON.stringify({ body: JSON.stringify({
provider, provider,
providerId, providerId,
accountId,
}), }),
}) })
@@ -213,8 +212,7 @@ export function useDisconnectOAuthService() {
oauthConnectionsKeys.connections(), oauthConnectionsKeys.connections(),
previousServices.map((svc) => { previousServices.map((svc) => {
if (svc.id === serviceId) { if (svc.id === serviceId) {
const updatedAccounts = const updatedAccounts = svc.accounts?.filter((acc) => acc.id !== accountId) || []
accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
return { return {
...svc, ...svc,
accounts: updatedAccounts, accounts: updatedAccounts,

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth' import type { Credential } from '@/lib/oauth'
import { CREDENTIAL_SET } from '@/executor/constants' import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'
@@ -13,34 +13,15 @@ interface CredentialDetailResponse {
} }
export const oauthCredentialKeys = { export const oauthCredentialKeys = {
list: (providerId?: string, workspaceId?: string, workflowId?: string) => list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const,
[
'oauthCredentials',
providerId ?? 'none',
workspaceId ?? 'none',
workflowId ?? 'none',
] as const,
detail: (credentialId?: string, workflowId?: string) => detail: (credentialId?: string, workflowId?: string) =>
['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const, ['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
} }
interface FetchOAuthCredentialsParams { export async function fetchOAuthCredentials(providerId: string): Promise<Credential[]> {
providerId: string
workspaceId?: string
workflowId?: string
}
export async function fetchOAuthCredentials(
params: FetchOAuthCredentialsParams
): Promise<Credential[]> {
const { providerId, workspaceId, workflowId } = params
if (!providerId) return [] if (!providerId) return []
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', { const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
searchParams: { searchParams: { provider: providerId },
provider: providerId,
workspaceId,
workflowId,
},
}) })
return data.credentials ?? [] return data.credentials ?? []
} }
@@ -59,44 +40,10 @@ export async function fetchOAuthCredentialDetail(
return data.credentials ?? [] return data.credentials ?? []
} }
interface UseOAuthCredentialsOptions { export function useOAuthCredentials(providerId?: string, enabled = true) {
enabled?: boolean
workspaceId?: string
workflowId?: string
}
function resolveOptions(
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
): Required<UseOAuthCredentialsOptions> {
if (typeof enabledOrOptions === 'boolean') {
return {
enabled: enabledOrOptions,
workspaceId: '',
workflowId: '',
}
}
return {
enabled: enabledOrOptions?.enabled ?? true,
workspaceId: enabledOrOptions?.workspaceId ?? '',
workflowId: enabledOrOptions?.workflowId ?? '',
}
}
export function useOAuthCredentials(
providerId?: string,
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
) {
const { enabled, workspaceId, workflowId } = resolveOptions(enabledOrOptions)
return useQuery<Credential[]>({ return useQuery<Credential[]>({
queryKey: oauthCredentialKeys.list(providerId, workspaceId, workflowId), queryKey: oauthCredentialKeys.list(providerId),
queryFn: () => queryFn: () => fetchOAuthCredentials(providerId ?? ''),
fetchOAuthCredentials({
providerId: providerId ?? '',
workspaceId: workspaceId || undefined,
workflowId: workflowId || undefined,
}),
enabled: Boolean(providerId) && enabled, enabled: Boolean(providerId) && enabled,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}) })
@@ -115,12 +62,7 @@ export function useOAuthCredentialDetail(
}) })
} }
export function useCredentialName( export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
credentialId?: string,
providerId?: string,
workflowId?: string,
workspaceId?: string
) {
// Check if this is a credential set value // Check if this is a credential set value
const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
const credentialSetId = isCredentialSet const credentialSetId = isCredentialSet
@@ -135,11 +77,7 @@ export function useCredentialName(
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials( const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
providerId, providerId,
{ Boolean(providerId) && !isCredentialSet
enabled: Boolean(providerId) && !isCredentialSet,
workspaceId,
workflowId,
}
) )
const selectedCredential = credentials.find((cred) => cred.id === credentialId) const selectedCredential = credentials.find((cred) => cred.id === credentialId)
@@ -154,18 +92,18 @@ export function useCredentialName(
shouldFetchDetail shouldFetchDetail
) )
const detailCredential = foreignCredentials[0]
const hasForeignMeta = foreignCredentials.length > 0 const hasForeignMeta = foreignCredentials.length > 0
const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading
const displayName = const displayName =
credentialSetData?.name ?? selectedCredential?.name ?? detailCredential?.name ?? null credentialSetData?.name ??
selectedCredential?.name ??
(hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ??
(isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null)
return { return {
displayName, displayName,
isLoading: isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading),
credentialsLoading ||
foreignLoading ||
(isCredentialSet && credentialSetLoading && !credentialSetData),
hasForeignMeta, hasForeignMeta,
} }
} }

View File

@@ -14,7 +14,7 @@ import {
oneTimeToken, oneTimeToken,
organization, organization,
} from 'better-auth/plugins' } from 'better-auth/plugins'
import { and, eq, inArray, sql } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Stripe from 'stripe' import Stripe from 'stripe'
import { import {
@@ -55,10 +55,6 @@ import {
} from '@/lib/core/config/feature-flags' } from '@/lib/core/config/feature-flags'
import { PlatformEvents } from '@/lib/core/telemetry' import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import {
handleCreateCredentialFromDraft,
handleReconnectCredential,
} from '@/lib/credentials/draft-hooks'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -154,6 +150,16 @@ export const auth = betterAuth({
account: { account: {
create: { create: {
before: async (account) => { before: async (account) => {
// Only one credential per (userId, providerId) is allowed
// If user reconnects (even with a different external account), delete the old one
// and let Better Auth create the new one (returning false breaks account linking flow)
const existing = await db.query.account.findFirst({
where: and(
eq(schema.account.userId, account.userId),
eq(schema.account.providerId, account.providerId)
),
})
const modifiedAccount = { ...account } const modifiedAccount = { ...account }
if (account.providerId === 'salesforce' && account.accessToken) { if (account.providerId === 'salesforce' && account.accessToken) {
@@ -183,121 +189,32 @@ export const auth = betterAuth({
} }
} }
// Handle Microsoft refresh token expiry
if (isMicrosoftProvider(account.providerId)) { if (isMicrosoftProvider(account.providerId)) {
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} }
if (existing) {
// Delete the existing account so Better Auth can create the new one
// This allows account linking/re-authorization to succeed
await db.delete(schema.account).where(eq(schema.account.id, existing.id))
// Preserve the existing account ID so references (like workspace notifications) continue to work
modifiedAccount.id = existing.id
logger.info('[account.create.before] Deleted existing account for re-authorization', {
userId: account.userId,
providerId: account.providerId,
existingAccountId: existing.id,
preservingId: true,
})
// Sync webhooks for credential sets after reconnecting (in after hook)
}
return { data: modifiedAccount } return { data: modifiedAccount }
}, },
after: async (account) => { after: async (account) => {
/**
* Migrate credentials from stale account rows to the newly created one.
*
* Each getUserInfo appends a random UUID to the stable external ID so
* that Better Auth never blocks cross-user connections. This means
* re-connecting the same external identity creates a new row. We detect
* the stale siblings here by comparing the stable prefix (everything
* before the trailing UUID), migrate any credential FKs to the new row,
* then delete the stale rows.
*/
try {
const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
const stablePrefix = account.accountId.replace(UUID_SUFFIX_RE, '')
if (stablePrefix && stablePrefix !== account.accountId) {
const siblings = await db
.select({ id: schema.account.id, accountId: schema.account.accountId })
.from(schema.account)
.where(
and(
eq(schema.account.userId, account.userId),
eq(schema.account.providerId, account.providerId),
sql`${schema.account.id} != ${account.id}`
)
)
const staleRows = siblings.filter(
(row) => row.accountId.replace(UUID_SUFFIX_RE, '') === stablePrefix
)
if (staleRows.length > 0) {
const staleIds = staleRows.map((row) => row.id)
await db
.update(schema.credential)
.set({ accountId: account.id })
.where(inArray(schema.credential.accountId, staleIds))
await db.delete(schema.account).where(inArray(schema.account.id, staleIds))
logger.info('[account.create.after] Migrated credentials from stale accounts', {
userId: account.userId,
providerId: account.providerId,
newAccountId: account.id,
migratedFrom: staleIds,
})
}
}
} catch (error) {
logger.error('[account.create.after] Failed to clean up stale accounts', {
userId: account.userId,
providerId: account.providerId,
error,
})
}
/**
* If a pending credential draft exists for this (userId, providerId),
* either create a new credential or reconnect an existing one.
*
* - draft.credentialId is null: create a new credential (normal connect flow)
* - draft.credentialId is set: update existing credential's accountId (reconnect flow)
*/
try {
const [draft] = await db
.select()
.from(schema.pendingCredentialDraft)
.where(
and(
eq(schema.pendingCredentialDraft.userId, account.userId),
eq(schema.pendingCredentialDraft.providerId, account.providerId),
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
)
)
.limit(1)
if (draft) {
const now = new Date()
if (draft.credentialId) {
await handleReconnectCredential({
draft,
newAccountId: account.id,
workspaceId: draft.workspaceId,
now,
})
} else {
await handleCreateCredentialFromDraft({
draft,
accountId: account.id,
providerId: account.providerId,
userId: account.userId,
now,
})
}
await db
.delete(schema.pendingCredentialDraft)
.where(eq(schema.pendingCredentialDraft.id, draft.id))
}
} catch (error) {
logger.error('[account.create.after] Failed to process credential draft', {
userId: account.userId,
providerId: account.providerId,
error,
})
}
try { try {
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage') const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
await ensureUserStatsExists(account.userId) await ensureUserStatsExists(account.userId)
@@ -1570,7 +1487,7 @@ export const auth = betterAuth({
}) })
return { return {
id: `${(data.user_id || data.hub_id).toString()}-${crypto.randomUUID()}`, id: `${data.user_id || data.hub_id.toString()}-${crypto.randomUUID()}`,
name: data.user || 'HubSpot User', name: data.user || 'HubSpot User',
email: data.user || `hubspot-${data.hub_id}@hubspot.com`, email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
emailVerified: true, emailVerified: true,
@@ -1624,7 +1541,7 @@ export const auth = betterAuth({
const data = await response.json() const data = await response.json()
return { return {
id: `${(data.user_id || data.sub).toString()}-${crypto.randomUUID()}`, id: `${data.user_id || data.sub}-${crypto.randomUUID()}`,
name: data.name || 'Salesforce User', name: data.name || 'Salesforce User',
email: data.email || `salesforce-${data.user_id}@salesforce.com`, email: data.email || `salesforce-${data.user_id}@salesforce.com`,
emailVerified: data.email_verified || true, emailVerified: data.email_verified || true,
@@ -1683,7 +1600,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${profile.data.id.toString()}-${crypto.randomUUID()}`, id: `${profile.data.id}-${crypto.randomUUID()}`,
name: profile.data.name || 'X User', name: profile.data.name || 'X User',
email: `${profile.data.username}@x.com`, email: `${profile.data.username}@x.com`,
image: profile.data.profile_image_url, image: profile.data.profile_image_url,
@@ -1763,7 +1680,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`, id: `${profile.account_id}-${crypto.randomUUID()}`,
name: profile.name || profile.display_name || 'Confluence User', name: profile.name || profile.display_name || 'Confluence User',
email: profile.email || `${profile.account_id}@atlassian.com`, email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || undefined, image: profile.picture || undefined,
@@ -1874,7 +1791,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`, id: `${profile.account_id}-${crypto.randomUUID()}`,
name: profile.name || profile.display_name || 'Jira User', name: profile.name || profile.display_name || 'Jira User',
email: profile.email || `${profile.account_id}@atlassian.com`, email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || undefined, image: profile.picture || undefined,
@@ -1924,7 +1841,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${data.id.toString()}-${crypto.randomUUID()}`, id: `${data.id}-${crypto.randomUUID()}`,
name: data.email ? data.email.split('@')[0] : 'Airtable User', name: data.email ? data.email.split('@')[0] : 'Airtable User',
email: data.email || `${data.id}@airtable.user`, email: data.email || `${data.id}@airtable.user`,
emailVerified: !!data.email, emailVerified: !!data.email,
@@ -1973,7 +1890,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${crypto.randomUUID()}`, id: `${profile.bot?.owner?.user?.id || profile.id}-${crypto.randomUUID()}`,
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User', name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
email: profile.person?.email || `${profile.id}@notion.user`, email: profile.person?.email || `${profile.id}@notion.user`,
emailVerified: !!profile.person?.email, emailVerified: !!profile.person?.email,
@@ -2040,7 +1957,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${data.id.toString()}-${crypto.randomUUID()}`, id: `${data.id}-${crypto.randomUUID()}`,
name: data.name || 'Reddit User', name: data.name || 'Reddit User',
email: `${data.name}@reddit.user`, email: `${data.name}@reddit.user`,
image: data.icon_img || undefined, image: data.icon_img || undefined,
@@ -2112,7 +2029,7 @@ export const auth = betterAuth({
const viewer = data.viewer const viewer = data.viewer
return { return {
id: `${viewer.id.toString()}-${crypto.randomUUID()}`, id: `${viewer.id}-${crypto.randomUUID()}`,
email: viewer.email, email: viewer.email,
name: viewer.name, name: viewer.name,
emailVerified: true, emailVerified: true,
@@ -2175,7 +2092,7 @@ export const auth = betterAuth({
const data = await response.json() const data = await response.json()
return { return {
id: `${data.account_id.toString()}-${crypto.randomUUID()}`, id: `${data.account_id}-${crypto.randomUUID()}`,
email: data.email, email: data.email,
name: data.name?.display_name || data.email, name: data.name?.display_name || data.email,
emailVerified: data.email_verified || false, emailVerified: data.email_verified || false,
@@ -2226,7 +2143,7 @@ export const auth = betterAuth({
const now = new Date() const now = new Date()
return { return {
id: `${profile.gid.toString()}-${crypto.randomUUID()}`, id: `${profile.gid}-${crypto.randomUUID()}`,
name: profile.name || 'Asana User', name: profile.name || 'Asana User',
email: profile.email || `${profile.gid}@asana.user`, email: profile.email || `${profile.gid}@asana.user`,
image: profile.photo?.image_128x128 || undefined, image: profile.photo?.image_128x128 || undefined,
@@ -2461,7 +2378,7 @@ export const auth = betterAuth({
const profile = await response.json() const profile = await response.json()
return { return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`, id: `${profile.id}-${crypto.randomUUID()}`,
name: name:
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
email: profile.email || `${profile.id}@zoom.user`, email: profile.email || `${profile.id}@zoom.user`,
@@ -2528,7 +2445,7 @@ export const auth = betterAuth({
const profile = await response.json() const profile = await response.json()
return { return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`, id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.display_name || 'Spotify User', name: profile.display_name || 'Spotify User',
email: profile.email || `${profile.id}@spotify.user`, email: profile.email || `${profile.id}@spotify.user`,
emailVerified: true, emailVerified: true,

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema' import { account, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -12,25 +12,23 @@ export interface CredentialAccessResult {
requesterUserId?: string requesterUserId?: string
credentialOwnerUserId?: string credentialOwnerUserId?: string
workspaceId?: string workspaceId?: string
resolvedCredentialId?: string
} }
/** /**
* Centralizes auth + credential membership checks for OAuth usage. * Centralizes auth + collaboration rules for credential use.
* - Workspace-scoped credential IDs enforce active credential_member access. * - Uses checkSessionOrInternalAuth to authenticate the caller
* - Legacy account IDs are resolved to workspace-scoped credentials when workflowId is provided. * - Fetches credential owner
* - Direct legacy account-ID access without workflowId is restricted to account owners only. * - Authorization rules:
* - session: allow if requester owns the credential; otherwise require workflowId and
* verify BOTH requester and owner have access to the workflow's workspace
* - internal_jwt: require workflowId (by default) and verify credential owner has access to the
* workflow's workspace (requester identity is the system/workflow)
*/ */
export async function authorizeCredentialUse( export async function authorizeCredentialUse(
request: NextRequest, request: NextRequest,
params: { params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
credentialId: string
workflowId?: string
requireWorkflowIdForInternal?: boolean
callerUserId?: string
}
): Promise<CredentialAccessResult> { ): Promise<CredentialAccessResult> {
const { credentialId, workflowId, requireWorkflowIdForInternal = true, callerUserId } = params const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
const auth = await checkSessionOrInternalAuth(request, { const auth = await checkSessionOrInternalAuth(request, {
requireWorkflowId: requireWorkflowIdForInternal, requireWorkflowId: requireWorkflowIdForInternal,
@@ -39,192 +37,71 @@ export async function authorizeCredentialUse(
return { ok: false, error: auth.error || 'Authentication required' } return { ok: false, error: auth.error || 'Authentication required' }
} }
const [workflowContext] = workflowId // Lookup credential owner
? await db const [credRow] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
: [null]
if (workflowId && (!workflowContext || !workflowContext.workspaceId)) {
return { ok: false, error: 'Workflow not found' }
}
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return { ok: false, error: 'Unsupported credential type for OAuth access' }
}
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, platformCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const effectiveCallerId =
callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
if (effectiveCallerId) {
const requesterPerm = await getUserEntityPermissions(
effectiveCallerId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, effectiveCallerId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return {
ok: false,
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
}
}
if (requesterPerm === null) {
return {
ok: false,
error: 'You do not have access to this workspace.',
}
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
platformCredential.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.accountId,
}
}
if (workflowContext?.workspaceId) {
const [workspaceCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
accountId: credential.accountId,
})
.from(credential)
.where(
and(
eq(credential.type, 'oauth'),
eq(credential.workspaceId, workflowContext.workspaceId),
eq(credential.accountId, credentialId)
)
)
.limit(1)
if (!workspaceCredential?.accountId) {
return { ok: false, error: 'Credential not found' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, workspaceCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const legacyCallerId = callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
if (legacyCallerId) {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, workspaceCredential.id),
eq(credentialMember.userId, legacyCallerId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
workflowContext.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
workspaceId: workflowContext.workspaceId,
resolvedCredentialId: workspaceCredential.accountId,
}
}
const [legacyAccount] = await db
.select({ userId: account.userId }) .select({ userId: account.userId })
.from(account) .from(account)
.where(eq(account.id, credentialId)) .where(eq(account.id, credentialId))
.limit(1) .limit(1)
if (!legacyAccount) { if (!credRow) {
return { ok: false, error: 'Credential not found' } return { ok: false, error: 'Credential not found' }
} }
if (auth.authType === 'internal_jwt') { const credentialOwnerUserId = credRow.userId
// If requester owns the credential, allow immediately
if (auth.authType !== 'internal_jwt' && auth.userId === credentialOwnerUserId) {
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId,
}
}
// For collaboration paths, workflowId is required to scope to a workspace
if (!workflowId) {
return { ok: false, error: 'workflowId is required' } return { ok: false, error: 'workflowId is required' }
} }
if (auth.userId !== legacyAccount.userId) { const [wf] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf || !wf.workspaceId) {
return { ok: false, error: 'Workflow not found' }
}
if (auth.authType === 'internal_jwt') {
// Internal calls: verify credential owner belongs to the workflow's workspace
const ownerPerm = await getUserEntityPermissions(
credentialOwnerUserId,
'workspace',
wf.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId,
workspaceId: wf.workspaceId,
}
}
// Session: verify BOTH requester and owner belong to the workflow's workspace
const requesterPerm = await getUserEntityPermissions(auth.userId, 'workspace', wf.workspaceId)
const ownerPerm = await getUserEntityPermissions(
credentialOwnerUserId,
'workspace',
wf.workspaceId
)
if (requesterPerm === null || ownerPerm === null) {
return { ok: false, error: 'Unauthorized' } return { ok: false, error: 'Unauthorized' }
} }
@@ -232,7 +109,7 @@ export async function authorizeCredentialUse(
ok: true, ok: true,
authType: auth.authType as CredentialAccessResult['authType'], authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId, requesterUserId: auth.userId,
credentialOwnerUserId: legacyAccount.userId, credentialOwnerUserId,
resolvedCredentialId: credentialId, workspaceId: wf.workspaceId,
} }
} }

View File

@@ -1,14 +1,10 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { credential, environment, workflow, workspaceEnvironment } from '@sim/db/schema' import { environment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { z } from 'zod' import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
interface SetEnvironmentVariablesParams { interface SetEnvironmentVariablesParams {
variables: Record<string, any> | Array<{ name: string; value: string }> variables: Record<string, any> | Array<{ name: string; value: string }>
@@ -58,179 +54,74 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
const normalized = normalizeVariables(variables || {}) const normalized = normalizeVariables(variables || {})
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
const requestedKeys = Object.keys(validatedVariables) // Fetch existing personal environment variables
const workflowId = params.workflowId const existingData = await db
.select()
const workspaceKeySet = new Set<string>() .from(environment)
let resolvedWorkspaceId: string | null = null .where(eq(environment.userId, authenticatedUserId))
.limit(1)
if (requestedKeys.length > 0 && workflowId) { const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (wf?.workspaceId) {
resolvedWorkspaceId = wf.workspaceId
const existingWorkspaceCredentials = await db
.select({ envKey: credential.envKey })
.from(credential)
.where(
and(
eq(credential.workspaceId, wf.workspaceId),
eq(credential.type, 'env_workspace'),
inArray(credential.envKey, requestedKeys)
)
)
for (const row of existingWorkspaceCredentials) {
if (row.envKey) workspaceKeySet.add(row.envKey)
}
}
}
const personalVars: Record<string, string> = {}
const workspaceVars: Record<string, string> = {}
for (const [key, value] of Object.entries(validatedVariables)) {
if (workspaceKeySet.has(key)) {
workspaceVars[key] = value
} else {
personalVars[key] = value
}
}
const toEncrypt: Record<string, string> = {}
const added: string[] = [] const added: string[] = []
const updated: string[] = [] const updated: string[] = []
const workspaceUpdated: string[] = [] for (const [key, newVal] of Object.entries(validatedVariables)) {
if (!(key in existingEncrypted)) {
if (Object.keys(personalVars).length > 0) { toEncrypt[key] = newVal
const existingData = await db added.push(key)
.select() } else {
.from(environment) try {
.where(eq(environment.userId, authenticatedUserId)) const { decrypted } = await decryptSecret(existingEncrypted[key])
.limit(1) if (decrypted !== newVal) {
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
const toEncrypt: Record<string, string> = {}
for (const [key, newVal] of Object.entries(personalVars)) {
if (!(key in existingEncrypted)) {
toEncrypt[key] = newVal
added.push(key)
} else {
try {
const { decrypted } = await decryptSecret(existingEncrypted[key])
if (decrypted !== newVal) {
toEncrypt[key] = newVal
updated.push(key)
}
} catch {
toEncrypt[key] = newVal toEncrypt[key] = newVal
updated.push(key) updated.push(key)
} }
} catch {
toEncrypt[key] = newVal
updated.push(key)
} }
} }
}
const newlyEncrypted = await Object.entries(toEncrypt).reduce( const newlyEncrypted = await Object.entries(toEncrypt).reduce(
async (accP, [key, val]) => { async (accP, [key, val]) => {
const acc = await accP const acc = await accP
const { encrypted } = await encryptSecret(val) const { encrypted } = await encryptSecret(val)
return { ...acc, [key]: encrypted } return { ...acc, [key]: encrypted }
}, },
Promise.resolve({} as Record<string, string>) Promise.resolve({} as Record<string, string>)
) )
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted } const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
await db // Save to personal environment variables (keyed by userId)
.insert(environment) await db
.values({ .insert(environment)
id: crypto.randomUUID(), .values({
userId: authenticatedUserId, id: crypto.randomUUID(),
variables: finalEncrypted,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: finalEncrypted, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: authenticatedUserId, userId: authenticatedUserId,
envKeys: Object.keys(finalEncrypted), variables: finalEncrypted,
updatedAt: new Date(),
}) })
} .onConflictDoUpdate({
target: [environment.userId],
if (Object.keys(workspaceVars).length > 0 && resolvedWorkspaceId) { set: { variables: finalEncrypted, updatedAt: new Date() },
const wsRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, resolvedWorkspaceId))
.limit(1)
const existingWsEncrypted = (wsRows[0]?.variables as Record<string, string>) || {}
const toEncryptWs: Record<string, string> = {}
for (const [key, newVal] of Object.entries(workspaceVars)) {
toEncryptWs[key] = newVal
workspaceUpdated.push(key)
}
const newlyEncryptedWs = await Object.entries(toEncryptWs).reduce(
async (accP, [key, val]) => {
const acc = await accP
const { encrypted } = await encryptSecret(val)
return { ...acc, [key]: encrypted }
},
Promise.resolve({} as Record<string, string>)
)
const mergedWs = { ...existingWsEncrypted, ...newlyEncryptedWs }
await db
.insert(workspaceEnvironment)
.values({
id: crypto.randomUUID(),
workspaceId: resolvedWorkspaceId,
variables: mergedWs,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: mergedWs, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: resolvedWorkspaceId,
envKeys: Object.keys(workspaceVars),
actingUserId: authenticatedUserId,
}) })
}
const totalProcessed = added.length + updated.length + workspaceUpdated.length logger.info('Saved personal environment variables', {
logger.info('Saved environment variables', {
userId: authenticatedUserId, userId: authenticatedUserId,
addedCount: added.length, addedCount: added.length,
updatedCount: updated.length, updatedCount: updated.length,
workspaceUpdatedCount: workspaceUpdated.length, totalCount: Object.keys(finalEncrypted).length,
}) })
const parts: string[] = []
if (added.length > 0) parts.push(`${added.length} personal secret(s) added`)
if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`)
if (workspaceUpdated.length > 0)
parts.push(`${workspaceUpdated.length} workspace secret(s) updated`)
return { return {
message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`, message: `Successfully processed ${Object.keys(validatedVariables).length} personal environment variable(s): ${added.length} added, ${updated.length} updated`,
variableCount: Object.keys(validatedVariables).length, variableCount: Object.keys(validatedVariables).length,
variableNames: Object.keys(validatedVariables), variableNames: Object.keys(validatedVariables),
totalVariableCount: Object.keys(finalEncrypted).length,
addedVariables: added, addedVariables: added,
updatedVariables: updated, updatedVariables: updated,
workspaceUpdatedVariables: workspaceUpdated,
} }
}, },
} }

View File

@@ -1,62 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
type ActiveCredentialMember = typeof credentialMember.$inferSelect
type CredentialRecord = typeof credential.$inferSelect
export interface CredentialActorContext {
credential: CredentialRecord | null
member: ActiveCredentialMember | null
hasWorkspaceAccess: boolean
canWriteWorkspace: boolean
isAdmin: boolean
}
/**
* Resolves user access context for a credential.
*/
export async function getCredentialActorContext(
credentialId: string,
userId: string
): Promise<CredentialActorContext> {
const [credentialRow] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow) {
return {
credential: null,
member: null,
hasWorkspaceAccess: false,
canWriteWorkspace: false,
isAdmin: false,
}
}
const workspaceAccess = await checkWorkspaceAccess(credentialRow.workspaceId, userId)
const [memberRow] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, userId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
const isAdmin = memberRow?.role === 'admin'
return {
credential: credentialRow,
member: memberRow ?? null,
hasWorkspaceAccess: workspaceAccess.hasAccess,
canWriteWorkspace: workspaceAccess.canWrite,
isAdmin,
}
}

View File

@@ -1,77 +0,0 @@
'use client'
export const PENDING_OAUTH_CREDENTIAL_DRAFT_KEY = 'sim.pending-oauth-credential-draft'
export const PENDING_CREDENTIAL_CREATE_REQUEST_KEY = 'sim.pending-credential-create-request'
export interface PendingOAuthCredentialDraft {
workspaceId: string
providerId: string
displayName: string
existingCredentialIds: string[]
existingAccountIds: string[]
requestedAt: number
}
interface PendingOAuthCredentialCreateRequest {
workspaceId: string
type: 'oauth'
providerId: string
displayName: string
serviceId: string
requiredScopes: string[]
requestedAt: number
}
interface PendingSecretCredentialCreateRequest {
workspaceId: string
type: 'env_personal' | 'env_workspace'
envKey?: string
requestedAt: number
}
export type PendingCredentialCreateRequest =
| PendingOAuthCredentialCreateRequest
| PendingSecretCredentialCreateRequest
function parseJson<T>(raw: string | null): T | null {
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
export function readPendingOAuthCredentialDraft(): PendingOAuthCredentialDraft | null {
if (typeof window === 'undefined') return null
return parseJson<PendingOAuthCredentialDraft>(
window.sessionStorage.getItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
)
}
export function writePendingOAuthCredentialDraft(payload: PendingOAuthCredentialDraft) {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY, JSON.stringify(payload))
}
export function clearPendingOAuthCredentialDraft() {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY)
}
export function readPendingCredentialCreateRequest(): PendingCredentialCreateRequest | null {
if (typeof window === 'undefined') return null
return parseJson<PendingCredentialCreateRequest>(
window.sessionStorage.getItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
)
}
export function writePendingCredentialCreateRequest(payload: PendingCredentialCreateRequest) {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY, JSON.stringify(payload))
}
export function clearPendingCredentialCreateRequest() {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
}

View File

@@ -1,148 +0,0 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
const logger = createLogger('CredentialDraftHooks')
/**
* Creates a new credential from a pending draft (normal OAuth connect flow).
*/
export async function handleCreateCredentialFromDraft(params: {
draft: { workspaceId: string; displayName: string; description: string | null }
accountId: string
providerId: string
userId: string
now: Date
}) {
const { draft, accountId, providerId, userId, now } = params
const credentialId = crypto.randomUUID()
try {
await db.insert(schema.credential).values({
id: credentialId,
workspaceId: draft.workspaceId,
type: 'oauth',
displayName: draft.displayName,
description: draft.description ?? null,
providerId,
accountId,
createdBy: userId,
createdAt: now,
updatedAt: now,
})
await db.insert(schema.credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: userId,
createdAt: now,
updatedAt: now,
})
logger.info('Created credential from draft', {
credentialId,
displayName: draft.displayName,
providerId,
accountId,
})
} catch (insertError: unknown) {
const code =
insertError && typeof insertError === 'object' && 'code' in insertError
? (insertError as { code: string }).code
: undefined
if (code !== '23505') {
throw insertError
}
logger.info('Credential already exists, skipping draft', {
providerId,
accountId,
})
}
}
/**
* Reconnects an existing credential to a new OAuth account.
* Handles unique constraint checks and orphaned account cleanup.
*/
export async function handleReconnectCredential(params: {
draft: { credentialId: string | null; workspaceId: string; displayName: string }
newAccountId: string
workspaceId: string
now: Date
}) {
const { draft, newAccountId, workspaceId, now } = params
if (!draft.credentialId) return
const [existingCredential] = await db
.select({ id: schema.credential.id, accountId: schema.credential.accountId })
.from(schema.credential)
.where(eq(schema.credential.id, draft.credentialId))
.limit(1)
if (!existingCredential) {
logger.warn('Credential not found for reconnect, skipping', {
credentialId: draft.credentialId,
})
return
}
const oldAccountId = existingCredential.accountId
if (oldAccountId === newAccountId) {
logger.info('Account unchanged during reconnect, skipping update', {
credentialId: draft.credentialId,
accountId: newAccountId,
})
return
}
const [conflicting] = await db
.select({ id: schema.credential.id })
.from(schema.credential)
.where(
and(
eq(schema.credential.workspaceId, workspaceId),
eq(schema.credential.accountId, newAccountId),
sql`${schema.credential.id} != ${draft.credentialId}`
)
)
.limit(1)
if (conflicting) {
logger.warn('New account already used by another credential, skipping reconnect', {
credentialId: draft.credentialId,
newAccountId,
conflictingCredentialId: conflicting.id,
})
return
}
await db
.update(schema.credential)
.set({ accountId: newAccountId, updatedAt: now })
.where(eq(schema.credential.id, draft.credentialId))
logger.info('Reconnected credential to new account', {
credentialId: draft.credentialId,
oldAccountId,
newAccountId,
})
if (oldAccountId) {
const [stillReferenced] = await db
.select({ id: schema.credential.id })
.from(schema.credential)
.where(eq(schema.credential.accountId, oldAccountId))
.limit(1)
if (!stillReferenced) {
await db.delete(schema.account).where(eq(schema.account.id, oldAccountId))
logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId })
}
}
}

Some files were not shown because too many files have changed in this diff Show More