From e2db107ee9eb12f58bf8419f055124a29d01a142 Mon Sep 17 00:00:00 2001 From: Cameron Taylor <50385537+ct3685@users.noreply.github.com> Date: Mon, 4 May 2026 17:32:24 -0400 Subject: [PATCH 1/3] fix(credentials): resolve case-mismatched credential lookups returning 500 (#1063) ## Summary Fixes the 500 error users hit when clicking **Add new credential** inline on a Jira node in an existing chatflow. Root cause was a case mismatch between the legacy `JiraApi` baked into saved chatflow JSON and the live credential pool key `jiraApi`, compounded by an over-broad `catch` in the components-credentials service that masked NOT_FOUND as 500. Three layers of defense, plus a code review: - **Server**: `componentsCredentialsService.getComponentByName` / `getSingleComponentsCredentialIcon` now do a case-insensitive fallback against the in-memory pool, and the `catch` re-throws `InternalFlowiseError` as-is so 404 stays 404. - **Frontend**: canvas `CredentialInputHandler.addAsyncOption` resolves stale `credentialNames` to the current canonical key before issuing the lookup, and surfaces unresolved names as a persistent error toast instead of silently `console.error`-ing. - **Data**: one-shot AAI migration (`1770000000001-NormalizeLegacyCredentialNames`) rewrites `JiraApi` -> `jiraApi` inside `chat_flow.flowData` JSON (in any `credentialNames` array or `credentialName` field) and inside the `credential.credentialName` column. Idempotent, narrow `LIKE` pre-filter, no-op `down()`. - **Docs**: `JIRA_ATLASSIAN_REVIEW.md` walks every Jira / Atlassian touchpoint with prioritized findings (6 Critical / 6 High / 6 Medium / 6 Low) for follow-up tickets. ### Why no node version bumps No node interface or `init()` behavior changed. The migration is reconciling stored data with credential code that already says `name: 'jiraApi'`. Version bumps would only be needed if/when we act on review findings H2 (host validation) or M3 (move host onto the credential). ## Test plan - [ ] \`pnpm --filter flowise-server build\` compiles cleanly - [ ] \`pnpm --filter flowise-ui build\` compiles cleanly - [ ] \`GET /api/v1/components-credentials/jiraApi\` returns 200 with the credential object (canonical case still works) - [ ] \`GET /api/v1/components-credentials/JiraApi\` returns 200 with the same \`jiraApi\` object (regression for the user-reported case) - [ ] \`GET /api/v1/components-credentials/totallyMadeUp\` returns a clean **404** (not 500) with a single-prefixed message - [ ] Snapshot DB, run \`pnpm migration:run\`, inspect \`[NormalizeLegacyCredentialNames]\` log output - confirm only expected rows touched - [ ] Re-running the migration is a no-op (idempotency check) - [ ] In the UI, open the affected chatflow with the Jira node, click **Add new credential** - dialog opens with the live \`jiraApi\` template - [ ] Smoke test on a non-Jira node (e.g. OpenAI) - Add new credential still works (canonical-case path unaffected) - [ ] Editing an existing Jira credential row whose stored \`credentialName\` was \`JiraApi\` opens the dialog without a 500 ## Out of scope (called out in the review doc) - Renaming the 5 PascalCase credentials (\`PostgresApi\`, \`PostgresUrl\`, \`MySQLApi\`, \`AlibabaApi\`, \`E2BApi\`) - The 6 Critical security findings in the Atlassian OAuth flow (origin checks, \`state\` reuse, in-process pending-registration map, \`JSON.stringify(Error)\`, \`mcp_client_secret\` round-tripping, \`updateAndRefreshAtlassianToken\` 500-wrapping) - flagged for separate tickets - MySQL / MariaDB / SQLite migration parity (no \`aai/\` subfolder for those dialects in this repo; case-insensitive server lookup still covers them at runtime) --- JIRA_ATLASSIAN_REVIEW.md | 356 ++++++++++++++++++ ...00000001-NormalizeLegacyCredentialNames.ts | 183 +++++++++ .../src/database/migrations/postgres/index.ts | 5 +- .../services/components-credentials/index.ts | 73 ++-- .../views/canvas/CredentialInputHandler.jsx | 85 ++++- 5 files changed, 671 insertions(+), 31 deletions(-) create mode 100644 JIRA_ATLASSIAN_REVIEW.md create mode 100644 packages/server/src/database/migrations/postgres/aai/1770000000001-NormalizeLegacyCredentialNames.ts diff --git a/JIRA_ATLASSIAN_REVIEW.md b/JIRA_ATLASSIAN_REVIEW.md new file mode 100644 index 00000000000..30639f58cce --- /dev/null +++ b/JIRA_ATLASSIAN_REVIEW.md @@ -0,0 +1,356 @@ +# Jira / Atlassian Code Review + +Scope: every file in the repo that touches Jira or Atlassian — credentials, Flowise nodes, MCP servers, server-side OAuth + token refresh, AAI ingest/sync utils, and UI surfaces. + +Findings are grouped by severity. Each finding cites the file:line so you can jump straight to it. The companion fixes for the credential-name 500 ship in: +- `packages/server/src/services/components-credentials/index.ts` (case-insensitive lookup + correct status propagation) +- `packages/ui/src/views/canvas/CredentialInputHandler.jsx` (canonical-name normalization + user-visible toast) +- `packages/server/src/database/migrations/postgres/aai/1770000000001-NormalizeLegacyCredentialNames.ts` (data normalization) + +Everything below is *additional* to those changes. + +--- + +## Surfaces inventory + +| Area | Files | +| --- | --- | +| Credentials | `packages/components/credentials/JiraApi.credential.ts`, `packages/components/credentials/AtlassianOauth.credential.ts` | +| Flowise nodes | `packages/components/nodes/tools/Jira/{Jira.ts, core.ts}`, `packages/components/nodes/tools/MCP/Jira/JiraMCP.ts`, `packages/components/nodes/tools/MCP/Atlassian/AtlassianMcp.ts`, `packages/components/nodes/documentloaders/Jira/Jira.ts` | +| Server OAuth + refresh | `packages/server/src/routes/atlassian-auth/index.ts`, `packages/server/src/controllers/atlassian-auth/index.ts`, `packages/server/src/services/credentials/index.ts` (`updateAndRefreshAtlassianToken`), `packages/server/src/utils/index.ts` (`registerOAuthClient`, `refreshStoredCredentialTokens`, `exchangeCodeForTokens`) | +| AAI Jira utils | `packages-answers/utils/src/jira/{client.ts, getJiraTickets.ts, models/*}`, `packages-answers/utils/src/ingest/jira.ts`, `packages-answers/utils/src/utilities/jiraAdfToMarkdown.ts` | +| UI | `packages-answers/ui/src/JiraSettings.tsx`, `packages/ui/src/ui-component/button/AtlassianAuthButton.jsx`, `packages/ui/src/views/credentials/AddEditCredentialDialog.jsx` (Atlassian OAuth handler) | +| Disabled / dead | `apps/web/app/(Main UI)/settings/integrations/[[...app]]/page.tsx` | + +--- + +## Critical (security or data integrity) + +### C1. OAuth popup `postMessage` handlers do not verify `event.origin` + +[packages/ui/src/views/credentials/AddEditCredentialDialog.jsx](packages/ui/src/views/credentials/AddEditCredentialDialog.jsx) `handleAtlassianOAuth` (line ~575) and `handleSalesforceOAuth` / `handleGoogleOAuth` all do: + +```jsx +const handleMessage = (event) => { + if (event.data?.type === 'AUTH_SUCCESS' && event.data.user) { + setCredentialData((prevData) => ({ + ...prevData, + access_token: event.data.user.access_token, + ... +``` + +They never check `event.origin`. Any tab that holds a window reference (or any iframe in the page) can `postMessage` an `AUTH_SUCCESS` payload into the listener and inject attacker-supplied tokens into a credential that the user is about to save. The same window even calls `setCredentialData` and auto-fills the credential name. + +**Fix:** assert `event.origin === window.location.origin` (or whatever origin you actually return the popup to) before trusting `event.data`. Apply uniformly to the Google, Salesforce, and Atlassian handlers. + +### C2. MCP `state` parameter == sessionId, no CSRF binding + +In [packages/ui/src/views/credentials/AddEditCredentialDialog.jsx](packages/ui/src/views/credentials/AddEditCredentialDialog.jsx) the `handleAtlassianOAuth` function builds the auth URL with: + +```jsx +const authParams = new URLSearchParams({ + response_type: 'code', + client_id: mcpData.client_id, + redirect_uri: mcpData.redirect_uri, + scope: mcpData.scope, + state: mcpData.sessionId, // Use sessionId as state parameter + audience: 'api.atlassian.com', + prompt: 'consent' +}) +``` + +`sessionId` here is the value returned by `registerOAuthClient` and is also used to look up the in-memory `pendingRegistrations` map. Using the same value as `state` and as the lookup key: + +1. Removes the per-user CSRF guarantee that `state` is supposed to provide (any party that obtains the sessionId can forge a callback). +2. Means the callback handler does not bind the OAuth result to the user who initiated the flow — `pendingRegistrations` is keyed by sessionId only and `passport.authenticate('atlassian-dynamic')` runs on a public route (no `verifyToken` / `verifyAAIToken` middleware on `/api/v1/atlassian-auth/*`). + +**Fix:** +- Generate a random `state` independently of `sessionId` and store the (state -> sessionId, userId) mapping server-side. +- On callback, look the state up, verify it has not been consumed, and tie the resulting credential to the original user. + +### C3. `pendingRegistrations` is an in-process `Map` — breaks behind multiple workers + +`registerOAuthClient` in [packages/server/src/utils/index.ts](packages/server/src/utils/index.ts) (line ~2225) stores client_secret + metadata in an in-process `Map`. The Atlassian OAuth callback that consumes it can land on a different worker (PM2 cluster, multiple containers behind a load balancer, queue mode with separate workers, etc.) and will silently fail with “Invalid or expired session ID.” + +**Fix:** persist pending registrations to Redis (the codebase already uses Redis for sessions when `MODE=queue`) or a short-lived DB table. + +### C4. Atlassian auth callback leaks raw error objects to the client window + +[packages/server/src/controllers/atlassian-auth/index.ts](packages/server/src/controllers/atlassian-auth/index.ts) at line 49: + +```ts +window.opener.postMessage({ + type: 'AUTH_ERROR', + error: ${JSON.stringify(error)} +}, '*'); +``` + +Three problems on one line: +1. `JSON.stringify(error)` returns `"{}"` for native `Error` instances (no enumerable props) — this is exactly the empty `stack: {}` the user observed in their browser network tab. The opener gets no actionable error message. +2. The same handler `'*'` targets any origin, so a sibling tab/iframe can intercept the error message. +3. If a downstream library throws an error that *is* JSON-serializable but contains stack/internal info, that gets shipped to the browser unfiltered. + +**Fix:** +- Sanitize: ship `{ message: error?.message ?? 'Atlassian auth failed' }` only. +- Use a real origin string instead of `'*'` (same as C1). +- The success branch on line 30 has the same `'*'` issue and posts the entire `req.user` object verbatim (all access tokens, refresh tokens, MCP client_secret) — see C5 for tightening. + +### C5. Atlassian OAuth callback ships client_secret to the browser + +The success branch of the callback above posts `JSON.stringify(req.user)` to `window.opener`. Looking at the message handler in [AddEditCredentialDialog.jsx](packages/ui/src/views/credentials/AddEditCredentialDialog.jsx) (lines 577–588), `req.user` carries `access_token`, `refresh_token`, `mcp_client_id`, **and `mcp_client_secret`**. The browser then writes these into the credential dialog and POSTs them back to the server to be stored encrypted. + +The `mcp_client_secret` should never round-trip through the browser. The server already has it in `pendingRegistrations`; it should write the credential row server-side and only return a credential ID (or a redacted summary) to the popup. + +**Fix:** create or update the credential server-side in the callback, store the secret directly in the encrypted credential payload, and return only `{ credentialId, displayName }` to the opener. + +### C6. `updateAndRefreshAtlassianToken` re-wraps NOT_FOUND / BAD_REQUEST as 500 + +[packages/server/src/services/credentials/index.ts](packages/server/src/services/credentials/index.ts) lines 245–277 has the **same catch-and-rewrap bug** I just fixed in `componentsCredentialsService`: + +```ts +} catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: credentialsService.updateAndRefreshAtlassianToken - ${getErrorMessage(error)}` + ) +} +``` + +Both the `NOT_FOUND` (line 255) and the `BAD_REQUEST` (line 262) `InternalFlowiseError`s thrown inside the `try` get re-wrapped as 500 by this `catch`, masking the real status from the client. + +**Fix:** mirror the pattern I applied in `componentsCredentialsService.getComponentByName` — `if (error instanceof InternalFlowiseError) throw error;` then re-wrap only unknown errors. Same fix is worth applying to `updateAndRefreshToken` directly above it (line 200) and to `getCredentialById` / `updateCredential` / `deleteCredentials` in the same file, all of which exhibit this pattern. + +--- + +## High + +### H1. `JiraApi` credential description still says “on Github” + +[packages/components/credentials/JiraApi.credential.ts](packages/components/credentials/JiraApi.credential.ts) line 14–15: + +```ts +this.description = + 'Refer to official guide on how to get accessToken on Github' +``` + +Looks like a copy-paste from `GithubApi.credential.ts`. The link is correct, the body text is wrong. Update to “…on how to get an Atlassian API token.” + +### H2. `JiraApi` credential is missing required validation flags + +Same file, all three inputs (`username`, `accessToken`, `host`) are declared with no `optional: false`, no `placeholder` for `host`, and no validator. The host field also accepts trailing slashes / partial URLs which `core.ts` then concatenates blindly: + +```ts +const url = `${this.jiraHost}/rest/api/3/${endpoint}` +``` + +A user typing `example.atlassian.net` (no protocol) breaks every tool call with a misleading error. A trailing slash produces `//rest/api/3/...` which Jira accepts but is sloppy. + +**Fix:** +- Add `placeholder: 'https://example.atlassian.net'` and a `description` that calls out the protocol/no-trailing-slash rule. +- In `core.ts:makeJiraRequest`, normalize `this.jiraHost` once in the constructor (strip trailing `/`, prepend `https://` if missing). + +### H3. `Jira_Tools` node missing `tags: ['AAI']` + +[packages/components/nodes/tools/Jira/Jira.ts](packages/components/nodes/tools/Jira/Jira.ts) class declaration (lines 6–15) does not declare a `tags` field, and the constructor never assigns `tags = ['AAI']`. The Jira MCP node, document loader, and Atlassian MCP node all do. + +Per repo convention (`CLAUDE.md`: *"Always include `tags: ['AAI']` for all TheAnswer-specific components"*), this excludes the `Jira` tool from the Answer tab grouping in the UI. + +**Fix:** add `tags: string[]` to the class shape and `this.tags = ['AAI']` in the constructor. + +### H4. `JiraApi` credential password fields visible in network log on save + +The `accessToken` field is `type: 'password'`, but the credential dialog `addNewCredential` POSTs the entire `plainDataObj` (decrypted tokens) to `/api/v1/credentials`. Combined with C1 / C5 above, this is the third place an OAuth flow could leak tokens. Recommended hardening: + +- Confirm the auth-only OAuth flow is the **only** path that ever holds tokens. For the manual `jiraApi` credential there's nothing we can do (user has to type their token), but make sure the dialog's `console.error('OAuth2 authorization error:', error)` and the snackbar `enqueueSnackbar({ message: ... error.message ... })` calls don't echo `plainDataObj` contents. + +### H5. `getJiraTickets` swallows the failure and returns `undefined` + +[packages-answers/utils/src/jira/getJiraTickets.ts](packages-answers/utils/src/jira/getJiraTickets.ts) line ~79–end: + +```ts +} catch (error) { + console.error('GetJiraTickets:ERROR', error) +} +``` + +There is no `return` and no rethrow, so the function returns `undefined` on any failure (network error, 401, JQL parse error, malformed Jira response). Callers like `procesProjectUpdated` then run `(await Promise.all(...)).flat()` over `undefined`, or `issues.map(...)` and crash with a noisy `Cannot read property 'map' of undefined` that isn't tied back to the original Jira error. + +**Fix:** rethrow, or `return [] as JiraIssue[]`. Consider also surfacing the rate-limit case (429) instead of returning `null` from `fetchJiraData` and letting the caller try to `.issues` on null. + +### H6. `JiraClient.getCloudId` will crash for an Atlassian account with no Jira product + +[packages-answers/utils/src/jira/client.ts](packages-answers/utils/src/jira/client.ts) line 35–41: + +```ts +async getCloudId() { + const appData = await this.getAppData() + const jiraData = appData.find((app: any) => app.scopes?.some((scope: string) => scope.includes('jira'))) + console.log('[CloudId]', jiraData.id) + return jiraData.id +} +``` + +`appData.find(...)` returns `undefined` if the OAuth user has only granted Confluence scopes, and `jiraData.id` then throws `TypeError: Cannot read properties of undefined (reading 'id')`. The constructor catches this with `.catch((err) => console.log(err))` (line 25), which then causes every subsequent `await this.cloudId` to resolve to `undefined`, producing URLs like `https://api.atlassian.com/ex/jira/undefined/rest/api/3/...`. + +**Fix:** throw a typed error if no Jira-scoped resource is returned. Surface that as a credential-level UI error so users know to re-authorize with Jira scopes. + +--- + +## Medium + +### M1. Atlassian auth route is unauthenticated + +[packages/server/src/routes/atlassian-auth/index.ts](packages/server/src/routes/atlassian-auth/index.ts) declares three routes: + +```ts +router.get('/', atlassianAuthController.authenticate) +router.get('/callback', passport.authenticate('atlassian-dynamic', { session: false }), atlassianAuthController.atlassianAuthCallback) +router.get('/mcp-initialize', atlassianAuthController.mcpInitialize) +``` + +None of them call `verifyToken` / `verifyAAIToken` / `enforceAbility`. `mcp-initialize` registers a fresh OAuth client (third-party API call to Atlassian) on every GET, populating `pendingRegistrations` with no rate limiting. + +**Fix:** require an authenticated user on `mcp-initialize` so we can attribute pending registrations to a user (which also helps fix C2). Keep `/callback` open (third-party redirect) but bind the callback to the originating user via `state` (see C2). Add `express-rate-limit` to `mcp-initialize` regardless. + +### M2. Atlassian token refresh has no leeway on `expiration_time` + +`refreshStoredCredentialTokens` in `packages/server/src/utils/index.ts` (line ~2336+) refreshes when called explicitly; the MCP node says token refresh is "handled automatically by server before node initialization" but the actual trigger logic (somewhere upstream) appears to compare `expiration_time` against `Date.now()` exactly. Recommend adding a 60–120 s leeway to avoid races where a refresh fires at the wall-clock boundary. + +### M3. `JiraMCP` credential reads `host` from the credential, not from a node input + +[packages/components/nodes/tools/MCP/Jira/JiraMCP.ts](packages/components/nodes/tools/MCP/Jira/JiraMCP.ts) line 109: + +```ts +const jiraUrl = getCredentialParam('host', credentialData, nodeData) +``` + +The `JiraApi` credential has `host` as one of three inputs, but the **non-MCP** Jira tool (`packages/components/nodes/tools/Jira/Jira.ts`) takes `jiraHost` from a *node input* instead. So users who edit one and not the other end up with mismatched hosts. The data loader (`packages/components/nodes/documentloaders/Jira/Jira.ts`) also takes `host` from a node input. + +**Fix:** pick one. Standard pattern is "host belongs on the credential" because it's tied to the API token. Drop the `host` input from the tool / loader nodes and always read it from the credential. + +### M4. `JiraSettings.tsx` calls `setFilters({ datasources: { jira: { ... } } })` without merging + +[packages-answers/ui/src/JiraSettings.tsx](packages-answers/ui/src/JiraSettings.tsx) lines 116, 123: + +```tsx +onChange={(value: string[]) => setFilters({ datasources: { jira: { project: value } } })} +``` + +This replaces the entire `filters` object (which `appSettings.filters` is also typed as a union containing `confluence`, `slack`, etc.). Toggling Jira project filter wipes all sibling filters. + +**Fix:** spread previous state: `setFilters((prev) => ({ ...prev, datasources: { ...prev.datasources, jira: { ...prev.datasources?.jira, project: value } } }))`. + +### M5. Dead route `apps/web/app/(Main UI)/settings/integrations/[[...app]]/page.tsx` + +Currently: + +```tsx +const SettingsIntegrationsAppPage = async ({ params }: any) => { + return null + // const appSettings = await getAppSettings(); + // return ; +} +``` + +Returns `null`, with the real implementation commented out. This is the entry point that previously surfaced the `JiraSettings` component to end users. Either: +- Delete the route + remove the `IntegrationSetting.tsx` / `JiraSettings.tsx` plumbing, or +- Re-enable it (and run through M4 first so it doesn't shred filter state). + +Right now the dead route is misleading anyone navigating to `/settings/integrations/jira`. + +### M6. `getJiraTickets` constructs URLs with un-encoded JQL + +```ts +let endpoint = `search?jql=${jql}&maxResults=...` +``` + +JQL values with spaces, `=`, `,`, or quotes need `encodeURIComponent`. The same pattern recurs three times in this file. The `JiraTool.makeJiraRequest` chain uses `URLSearchParams` correctly; this older util doesn't. + +--- + +## Low + +### L1. `Jira_Tools.init` throws raw `Error` without typing + +`Jira.ts` lines 379–389: + +```ts +if (!username) throw new Error('No username found in credential') +if (!accessToken) throw new Error('No access token found in credential') +if (!jiraHost) throw new Error('No Jira host provided') +``` + +These bubble up untyped. Other tool nodes use the `INodeOutputsValue` error pattern or a custom error class. Not user-facing damage, but it makes Datadog/Sentry grouping noisier. + +### L2. `JiraMcp` and `Jira_Tools.transformNodeInputsToToolArgs` have several `if` ladders that silently lose the source field name + +```ts +if (nodeData.inputs?.projectKey) defaultParams.projectKey = nodeData.inputs.projectKey +if (nodeData.inputs?.issueType) defaultParams.issueType = nodeData.inputs.issueType +... +``` + +This is fine but the value `0` for `issueMaxResults` will be skipped (truthiness). Recommend `!= null` checks. + +### L3. `JiraMCP.ts` MCP server is launched with `command: process.execPath` + +[packages/components/nodes/tools/MCP/Jira/JiraMCP.ts](packages/components/nodes/tools/MCP/Jira/JiraMCP.ts) line 113. This works but is fragile — running under a sandboxed deployment (e.g. some Docker images strip /proc/self/exe) breaks this. Consider a fallback to `node` resolved from `PATH`. + +### L4. `jiraAdfToMarkdown.ts` is half-commented and only handles a tiny subset of node types + +[packages-answers/utils/src/utilities/jiraAdfToMarkdown.ts](packages-answers/utils/src/utilities/jiraAdfToMarkdown.ts) — the original implementation is commented out (lines 20+). The active implementation (below the comment block) is shorter and likely missing node types like `mediaSingle`, `expand`, `panel`, `codeBlock`. Either delete the stale comment block or revive the missing handlers. + +### L5. `processJiraUpdated` has a typo (`requierd`) + +[packages-answers/utils/src/ingest/jira.ts](packages-answers/utils/src/ingest/jira.ts) lines 38 and 82: + +```ts +if (!user) throw new Error('User is requierd') +``` + +Trivial spelling fix; appears twice. + +### L6. `AtlassianAuthButton` renders unconditionally for `componentCredential.name === 'atlassianOAuth'` + +[packages/ui/src/ui-component/button/AtlassianAuthButton.jsx](packages/ui/src/ui-component/button/AtlassianAuthButton.jsx) — fine as-is, but the dialog renders Google / Salesforce / Atlassian buttons in sequence, each gated by their own name check. If you ever add a fourth provider this will be five conditional buttons in a row. Worth refactoring to a `` registry once we add the next one. + +--- + +## Notes / decisions for follow-up + +### N1. PascalCase credential names — leave them + +The repo currently has five `credential.name` values that start with an uppercase letter (`PostgresApi`, `PostgresUrl`, `MySQLApi`, `AlibabaApi`, `E2BApi`). All the current node code references them with the exact PascalCase string, so they work — they're just inconsistent with the rest. Renaming them would require: +- Updating every node `credentialNames` reference +- A data migration like the one shipped with this PR for both `chat_flow.flowData` and `credential.credentialName` +- A coordinated UI release because cached frontends would briefly request the old name + +The case-insensitive lookup we just added in `componentsCredentialsService.getComponentByName` already makes mixed-case requests work, so the cost/benefit doesn't justify the rename right now. Revisit if/when we standardize the credentials nomenclature broadly. + +### N2. `scripts/integration-mapping.json` and `scripts/integration-report.md` are stale + +Both files reference `"name": "JiraApi"` (uppercase). They are not loaded at runtime (only `scripts/analyze-integrations.ts` and the docs validators read them). Worth regenerating on the next docs pass so they don't mislead future readers / agents. + +### N3. AAI multi-tenant risk in credentials service + +The credential service (`packages/server/src/services/credentials/index.ts`) requires `req.user.activeWorkspaceId` to be present (the `Credential.workspaceId` column is `NOT NULL`). The `verifyAAIToken` flow sets this via `populateWorkspaceData`, which has a fallback that creates a default workspace if missing. The legacy `verifyToken` (HS256 passport) sets it from the JWT meta. **Both** paths are fragile if their downstream queries fail — and the failure mode is silent (just a 500 on the eventual INSERT). + +Worth a separate hardening pass: in `credentialsController.createCredential`, fail fast with a 400 when `activeWorkspaceId` is missing, with a clear message pointing the user at the workspace setup flow. Otherwise the same 500 the user reported here will keep recurring whenever the workspace bootstrap is incomplete. + +--- + +## Suggested follow-up tickets + +| Ticket | Surface | Severity | +| --- | --- | --- | +| Verify `event.origin` in OAuth `postMessage` listeners | UI / security | Critical (C1) | +| Random `state` independent of sessionId for Atlassian MCP OAuth | Server + UI | Critical (C2) | +| Move `pendingRegistrations` to Redis | Server | Critical (C3) | +| Sanitize `JSON.stringify(error)` in OAuth callback HTML | Server | Critical (C4) | +| Stop round-tripping `mcp_client_secret` through the browser | Server + UI | Critical (C5) | +| Apply `if (err instanceof InternalFlowiseError) throw err` pattern across `credentialsService` | Server | Critical (C6) | +| Fix `JiraApi` credential description text | Components | High (H1) | +| Normalize `host` field across Jira nodes | Components | Medium (M3) | +| Decide on `/settings/integrations/[[...app]]` route | Web | Medium (M5) | +| Encode JQL when building `getJiraTickets` URLs | AAI utils | Medium (M6) | +| Fail fast on missing `activeWorkspaceId` in credential create | Server | Medium (N3) | diff --git a/packages/server/src/database/migrations/postgres/aai/1770000000001-NormalizeLegacyCredentialNames.ts b/packages/server/src/database/migrations/postgres/aai/1770000000001-NormalizeLegacyCredentialNames.ts new file mode 100644 index 00000000000..91dfbfda0d3 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/aai/1770000000001-NormalizeLegacyCredentialNames.ts @@ -0,0 +1,183 @@ +/* eslint-disable no-console */ +import { MigrationInterface, QueryRunner } from 'typeorm' + +/** + * Normalize legacy credential names baked into saved chatflow JSON and into + * `credential.credentialName` rows. + * + * Background: a few credentials were renamed at some point (e.g. `JiraApi` -> + * `jiraApi`). The new code registers them under the lowercase canonical key, + * but old chatflow JSON and old credential rows still carry the historical + * casing. When the canvas's `addAsyncOption` handler issues a request like + * `GET /components-credentials/JiraApi`, the in-memory pool lookup fails and + * surfaces as a 500 (the catch in the service used to swallow NOT_FOUND). + * + * This migration rewrites every known legacy name to its canonical form so + * existing data lines up with the current credential pool. The case-insensitive + * lookup added to `componentsCredentialsService.getComponentByName` keeps + * unknown legacy names working too, but normalizing the data here removes the + * ambiguity for anyone introspecting the DB later. + */ +export class NormalizeLegacyCredentialNames1770000000001 implements MigrationInterface { + name = 'NormalizeLegacyCredentialNames1770000000001' + + /** + * Map of legacy credential names -> canonical names. + * + * Add new entries here whenever a credential's `name` property changes. + * Keys are matched case-sensitively against stored data so we only rewrite + * the exact historical strings we know about (no surprise normalization of + * intentionally PascalCase names like `PostgresApi` / `MySQLApi` / `E2BApi`). + */ + private static readonly LEGACY_NAME_MAP: Record = { + JiraApi: 'jiraApi' + } + + public async up(queryRunner: QueryRunner): Promise { + const legacyMap = NormalizeLegacyCredentialNames1770000000001.LEGACY_NAME_MAP + const legacyKeys = Object.keys(legacyMap) + if (!legacyKeys.length) { + console.log('[NormalizeLegacyCredentialNames] No legacy mappings configured, nothing to do.') + return + } + + console.log(`[NormalizeLegacyCredentialNames] Normalizing ${legacyKeys.length} legacy credential name(s):`, legacyKeys) + + await this.normalizeChatFlowFlowData(queryRunner, legacyMap) + await this.normalizeCredentialRows(queryRunner, legacyMap) + + console.log('[NormalizeLegacyCredentialNames] Completed.') + } + + public async down(): Promise { + // Intentional no-op: a legacy name like `JiraApi` is no longer registered by + // any credential class, so reverting would only re-introduce the broken state. + } + + /** + * Walk every `chat_flow.flowData` JSON blob and rewrite any string that + * appears inside a `credentialNames` array (or any `credentialName` string + * field) when it matches a known legacy key. + * + * Uses a recursive scan rather than targeting specific paths because the + * Flowise serialization shape varies between node types (inputAnchors vs + * inputParams vs nested credential blocks). + */ + private async normalizeChatFlowFlowData(queryRunner: QueryRunner, legacyMap: Record): Promise { + const candidateLikes = Object.keys(legacyMap) + .map((key) => `"flowData" LIKE '%${key.replace(/'/g, "''")}%'`) + .join(' OR ') + + const rows: Array<{ id: string; flowData: string | null }> = await queryRunner.query( + `SELECT id, "flowData" FROM "chat_flow" WHERE ${candidateLikes}` + ) + + if (!rows.length) { + console.log('[NormalizeLegacyCredentialNames] No chat_flow rows reference legacy credential names.') + return + } + + console.log(`[NormalizeLegacyCredentialNames] Inspecting ${rows.length} chat_flow row(s)...`) + + let updatedCount = 0 + for (const row of rows) { + if (!row.flowData) continue + + let parsed: unknown + try { + parsed = JSON.parse(row.flowData) + } catch (err) { + console.warn(`[NormalizeLegacyCredentialNames] Skipping chat_flow ${row.id} - flowData is not valid JSON.`) + continue + } + + const { value, changed } = rewriteLegacyNamesInPlace(parsed, legacyMap) + if (!changed) continue + + const serialized = JSON.stringify(value) + await queryRunner.query(`UPDATE "chat_flow" SET "flowData" = $1 WHERE "id" = $2`, [serialized, row.id]) + updatedCount += 1 + } + + console.log(`[NormalizeLegacyCredentialNames] Rewrote ${updatedCount} chat_flow row(s).`) + } + + /** + * Rewrite `credential.credentialName` for any rows whose value matches a + * known legacy key. This unblocks the Edit-existing-credential flow which + * also calls `GET /components-credentials/` with the stored value. + */ + private async normalizeCredentialRows(queryRunner: QueryRunner, legacyMap: Record): Promise { + let totalUpdated = 0 + for (const [legacy, canonical] of Object.entries(legacyMap)) { + const result = await queryRunner.query(`UPDATE "credential" SET "credentialName" = $1 WHERE "credentialName" = $2`, [ + canonical, + legacy + ]) + // node-postgres returns [rows, count] for UPDATE via TypeORM; fall back to 0 if shape differs. + const affected = Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0 + if (affected) { + console.log(`[NormalizeLegacyCredentialNames] credential.credentialName: ${legacy} -> ${canonical} (${affected} row(s))`) + } + totalUpdated += affected + } + if (!totalUpdated) { + console.log('[NormalizeLegacyCredentialNames] No credential rows needed renaming.') + } + } +} + +/** + * Recursively walk an arbitrary JSON value and replace any leaf string that + * matches a key in `legacyMap` when the leaf appears in a position that + * historically held a credential name. We cover three positions: + * - inside arrays named `credentialNames` + * - as the value of a key called `credentialName` + * - as a free-standing string inside any `credentialNames` array + * + * Returns the (possibly mutated) value and whether anything changed. The + * traversal mutates objects/arrays in place to avoid deep-cloning large flow + * graphs, then returns the same reference. + */ +function rewriteLegacyNamesInPlace(value: unknown, legacyMap: Record): { value: unknown; changed: boolean } { + let changed = false + + const visit = (node: any, parentKey: string | null): void => { + if (Array.isArray(node)) { + // Replace strings directly when the array is keyed as credentialNames. + if (parentKey === 'credentialNames') { + for (let i = 0; i < node.length; i += 1) { + const entry = node[i] + if (typeof entry === 'string' && legacyMap[entry]) { + node[i] = legacyMap[entry] + changed = true + } else if (entry && typeof entry === 'object') { + visit(entry, null) + } + } + } else { + for (const entry of node) { + if (entry && typeof entry === 'object') { + visit(entry, null) + } + } + } + return + } + + if (node && typeof node === 'object') { + for (const key of Object.keys(node)) { + const child = node[key] + if (key === 'credentialName' && typeof child === 'string' && legacyMap[child]) { + node[key] = legacyMap[child] + changed = true + } else if (child && typeof child === 'object') { + visit(child, key) + } + } + } + } + + visit(value, null) + return { value, changed } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 1a581a32b5e..e832e5bc16c 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -96,6 +96,7 @@ import { AddOrganizationConfig1753200000001 } from './aai/1753200000001-AddOrgan import { AddGuardrailsMetadataToChatMessage1753200000002 } from './aai/1753200000002-AddGuardrailsMetadataToChatMessage' import { UpdateFiddlerCredentialsVisibility1768413137117 } from './aai/1768413137117-UpdateFiddlerCredentialsVisibility' import { MoveDefaultChatflowsToPersonalWorkspace1770000000000 } from './aai/1770000000000-MoveDefaultChatflowsToPersonalWorkspace' +import { NormalizeLegacyCredentialNames1770000000001 } from './aai/1770000000001-NormalizeLegacyCredentialNames' export const postgresMigrations = [ Init1693891895163, @@ -193,5 +194,7 @@ export const postgresMigrations = [ // AAI: Backfill workspaceId - runs LAST after all feature migrations AAIBackfillWorkspaceId1760000000002, // AAI: AGENT-674 - Move default sidekick chatflows to Personal Workspaces - MoveDefaultChatflowsToPersonalWorkspace1770000000000 + MoveDefaultChatflowsToPersonalWorkspace1770000000000, + // AAI: Normalize legacy credential names in chat_flow.flowData and credential rows (e.g. JiraApi -> jiraApi) + NormalizeLegacyCredentialNames1770000000001 ] diff --git a/packages/server/src/services/components-credentials/index.ts b/packages/server/src/services/components-credentials/index.ts index c65295b9024..191c112a1f4 100644 --- a/packages/server/src/services/components-credentials/index.ts +++ b/packages/server/src/services/components-credentials/index.ts @@ -4,6 +4,23 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' +// Resolve a credential name against the live pool using a case-insensitive fallback. +// Old saved chatflows / credential rows can carry historical casing (e.g. `JiraApi`) +// that no longer matches the camelCase keys registered by current credential classes +// (e.g. `jiraApi`). Returning the canonical key here keeps downstream lookups happy. +const resolveCredentialKey = (requestedName: string, pool: Record): string | undefined => { + if (Object.prototype.hasOwnProperty.call(pool, requestedName)) { + return requestedName + } + const lowered = requestedName.toLowerCase() + for (const key of Object.keys(pool)) { + if (key.toLowerCase() === lowered) { + return key + } + } + return undefined +} + // Get all component credentials const getAllComponentsCredentials = async (): Promise => { try { @@ -25,33 +42,39 @@ const getAllComponentsCredentials = async (): Promise => { const getComponentByName = async (credentialName: string) => { try { const appServer = getRunningExpressApp() + const pool = appServer.nodesPool.componentCredentials if (!credentialName.includes('&')) { - if (Object.prototype.hasOwnProperty.call(appServer.nodesPool.componentCredentials, credentialName)) { - return appServer.nodesPool.componentCredentials[credentialName] - } else { - throw new InternalFlowiseError( - StatusCodes.NOT_FOUND, - `Error: componentsCredentialsService.getSingleComponentsCredential - Credential ${credentialName} not found` - ) + const resolvedKey = resolveCredentialKey(credentialName, pool) + if (resolvedKey) { + return pool[resolvedKey] } + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + `Error: componentsCredentialsService.getComponentByName - Credential ${credentialName} not found` + ) } else { const dbResponse = [] for (const name of credentialName.split('&')) { - if (Object.prototype.hasOwnProperty.call(appServer.nodesPool.componentCredentials, name)) { - dbResponse.push(appServer.nodesPool.componentCredentials[name]) + const resolvedKey = resolveCredentialKey(name, pool) + if (resolvedKey) { + dbResponse.push(pool[resolvedKey]) } else { throw new InternalFlowiseError( StatusCodes.NOT_FOUND, - `Error: componentsCredentialsService.getSingleComponentsCredential - Credential ${name} not found` + `Error: componentsCredentialsService.getComponentByName - Credential ${name} not found` ) } } return dbResponse } } catch (error) { + // Preserve InternalFlowiseError status (e.g. 404) instead of always re-wrapping as 500. + if (error instanceof InternalFlowiseError) { + throw error + } throw new InternalFlowiseError( StatusCodes.INTERNAL_SERVER_ERROR, - `Error: componentsCredentialsService.getSingleComponentsCredential - ${getErrorMessage(error)}` + `Error: componentsCredentialsService.getComponentByName - ${getErrorMessage(error)}` ) } } @@ -60,22 +83,26 @@ const getComponentByName = async (credentialName: string) => { const getSingleComponentsCredentialIcon = async (credentialName: string) => { try { const appServer = getRunningExpressApp() - if (Object.prototype.hasOwnProperty.call(appServer.nodesPool.componentCredentials, credentialName)) { - const credInstance = appServer.nodesPool.componentCredentials[credentialName] - if (credInstance.icon === undefined) { - throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Credential ${credentialName} icon not found`) - } + const pool = appServer.nodesPool.componentCredentials + const resolvedKey = resolveCredentialKey(credentialName, pool) + if (!resolvedKey) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Credential ${credentialName} not found`) + } + const credInstance = pool[resolvedKey] + if (credInstance.icon === undefined) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Credential ${credentialName} icon not found`) + } - if (credInstance.icon.endsWith('.svg') || credInstance.icon.endsWith('.png') || credInstance.icon.endsWith('.jpg')) { - const filepath = credInstance.icon - return filepath - } else { - throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Credential ${credentialName} icon is missing icon`) - } + if (credInstance.icon.endsWith('.svg') || credInstance.icon.endsWith('.png') || credInstance.icon.endsWith('.jpg')) { + const filepath = credInstance.icon + return filepath } else { - throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Credential ${credentialName} not found`) + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Credential ${credentialName} icon is missing icon`) } } catch (error) { + if (error instanceof InternalFlowiseError) { + throw error + } throw new InternalFlowiseError( StatusCodes.INTERNAL_SERVER_ERROR, `Error: componentsCredentialsService.getSingleComponentsCredentialIcon - ${getErrorMessage(error)}` diff --git a/packages/ui/src/views/canvas/CredentialInputHandler.jsx b/packages/ui/src/views/canvas/CredentialInputHandler.jsx index 700f1db46d8..ae3d21b6d01 100644 --- a/packages/ui/src/views/canvas/CredentialInputHandler.jsx +++ b/packages/ui/src/views/canvas/CredentialInputHandler.jsx @@ -1,9 +1,10 @@ import PropTypes from 'prop-types' import { useEffect, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' // material-ui -import { IconButton } from '@mui/material' -import { IconEdit } from '@tabler/icons-react' +import { Button, IconButton } from '@mui/material' +import { IconEdit, IconX } from '@tabler/icons-react' // project import import { AsyncDropdown } from '@/ui-component/dropdown/AsyncDropdown' @@ -14,11 +15,13 @@ import CredentialListDialog from '@/views/credentials/CredentialListDialog' import credentialsApi from '@/api/credentials' import { useAuth } from '@/hooks/useAuth' import { FLOWISE_CREDENTIAL_ID } from '@/store/constant' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' // ===========================|| CredentialInputHandler ||=========================== // const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false }) => { const ref = useRef(null) + const dispatch = useDispatch() const [credentialId, setCredentialId] = useState(data?.credential || (data?.inputs && data.inputs[FLOWISE_CREDENTIAL_ID]) || '') const [showCredentialListDialog, setShowCredentialListDialog] = useState(false) const [credentialListDialogProps, setCredentialListDialogProps] = useState({}) @@ -27,6 +30,37 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false } const [reloadTimestamp, setReloadTimestamp] = useState(Date.now().toString()) const { hasPermission } = useAuth() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + // Resolve possibly-legacy credentialNames (e.g. saved chatflow has 'JiraApi' but + // the live pool now uses 'jiraApi') against the current canonical list. Old chatflow + // JSON can outlive credential renames, so we normalize before issuing lookups. + const resolveCanonicalCredentialNames = async (requestedNames) => { + try { + const allComponentsResp = await credentialsApi.getAllComponentsCredentials() + const liveNames = Array.isArray(allComponentsResp?.data) ? allComponentsResp.data.map((c) => c.name) : [] + const liveByLower = new Map(liveNames.map((n) => [n.toLowerCase(), n])) + + const resolved = [] + const unresolved = [] + for (const requested of requestedNames) { + const canonical = liveByLower.get(String(requested).toLowerCase()) + if (canonical) { + resolved.push(canonical) + } else { + unresolved.push(requested) + } + } + return { resolved, unresolved } + } catch (error) { + // If we can't fetch the list (network/auth issue) just fall back to the raw input + // so the original behavior is preserved. + console.error('Failed to resolve canonical credential names:', error) + return { resolved: [...requestedNames], unresolved: [] } + } + } + const editCredential = (credentialId) => { const dialogProp = { type: 'EDIT', @@ -38,14 +72,48 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false } setShowSpecificCredentialDialog(true) } + const showCredentialErrorToast = (message) => { + enqueueSnackbar({ + message, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + const addAsyncOption = async () => { try { - let names = '' - if (inputParam.credentialNames.length > 1) { - names = inputParam.credentialNames.join('&') - } else { - names = inputParam.credentialNames[0] + const requestedNames = Array.isArray(inputParam.credentialNames) ? inputParam.credentialNames : [] + if (!requestedNames.length) { + showCredentialErrorToast('No credential type configured for this input.') + return } + + // Heal stale chatflow JSON: the saved node may reference a legacy credential + // name (e.g. `JiraApi`) that has since been renamed (e.g. `jiraApi`). Map each + // requested name to its current canonical key before we ask the server for it. + const { resolved, unresolved } = await resolveCanonicalCredentialNames(requestedNames) + + if (unresolved.length) { + showCredentialErrorToast( + `Could not find credential type${unresolved.length > 1 ? 's' : ''}: ${unresolved.join( + ', ' + )}. The chatflow may reference an outdated integration.` + ) + } + + if (!resolved.length) { + return + } + + const names = resolved.length > 1 ? resolved.join('&') : resolved[0] const componentCredentialsResp = await credentialsApi.getSpecificComponentCredential(names) if (componentCredentialsResp.data) { if (Array.isArray(componentCredentialsResp.data)) { @@ -68,6 +136,9 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false } } } catch (error) { console.error(error) + const apiMessage = + typeof error?.response?.data === 'object' ? error.response.data.message : error?.response?.data || error?.message + showCredentialErrorToast(`Failed to load credential type: ${apiMessage || 'Unknown error'}`) } } From 1154e74a08f099ef1769ce250acef8b95acc07d6 Mon Sep 17 00:00:00 2001 From: Cameron Taylor <50385537+ct3685@users.noreply.github.com> Date: Tue, 5 May 2026 14:24:38 -0400 Subject: [PATCH 2/3] chore(llmchain): drop noisy *****FINAL RESULT***** console output (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `LLMChain.run` was logging the raw prediction result to stdout for **every** chain invocation: ``` *****FINAL RESULT***** { json: { ... } } ``` That is fine for one-off local debugging but spams server logs in any chatflow that uses an LLM Chain — most visibly during CSV transformer cron runs that invoke the chain once per row. Removed both `console.log` lines (and their `eslint-disable` markers) from `packages/components/nodes/chains/LLMChain/LLMChain.ts`. ## Why Surfaced while triaging the CSV cron output (PR #1062) — every processed row was emitting two stdout lines from the LLM Chain node, drowning the structured logger output and making the cron logs hard to read. ## Test plan - [ ] Build succeeds (`pnpm --filter flowise-components build`). - [ ] Run a chatflow that uses an `LLMChain` node and confirm the `*****FINAL RESULT*****` block no longer appears in server logs. - [ ] CSV transformer cron run logs are now clean of the raw `{ json: { ... } }` dumps for each row. --- packages/components/nodes/chains/LLMChain/LLMChain.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/components/nodes/chains/LLMChain/LLMChain.ts b/packages/components/nodes/chains/LLMChain/LLMChain.ts index 8013581264a..afbf583461d 100644 --- a/packages/components/nodes/chains/LLMChain/LLMChain.ts +++ b/packages/components/nodes/chains/LLMChain/LLMChain.ts @@ -150,10 +150,6 @@ class LLMChain_Chains implements INode { } promptValues = injectOutputParser(this.outputParser, chain, promptValues) const res = await runPrediction(inputVariables, chain, input, promptValues, options, nodeData) - // eslint-disable-next-line no-console - console.log('\x1b[93m\x1b[1m\n*****FINAL RESULT*****\n\x1b[0m\x1b[0m') - // eslint-disable-next-line no-console - console.log(res) return res } } From 1a2dd33196ecc34c5e8cbd028253eb0c4dce1595 Mon Sep 17 00:00:00 2001 From: Cameron Taylor <50385537+ct3685@users.noreply.github.com> Date: Tue, 5 May 2026 19:03:21 -0400 Subject: [PATCH 3/3] feat(csv-transformer): API-sourced worker status, global theme fixes, and CSV UI polish (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### CSV Transformer — Worker Status via API - Added `GET /api/v1/csv-parser/worker-status` endpoint on the Flowise server that exposes whether `ENABLE_CSV_RUN_CRON` is enabled - Introduced `packages/server/src/utils/isCsvRunCronEnabled.ts` as single source of truth for the flag (used by both cron registration and the new endpoint) - `CsvTransformer.Client.tsx` now fetches worker status from the API at runtime — the web app no longer needs `ENABLE_CSV_RUN_CRON` in its environment variables - Removed `cronEnabled` prop from `CsvTransformerClient` and `page.tsx`; banner state is now `loading | enabled | disabled | error` driven by the live API response ### Global Theme Fixes (`muiOverrides.ts`) - Fixed `MuiChip`: replaced blanket `glassSubtle` spread (invisible text in both modes) with solid, mode-aware surface + explicit `text.primary` label; scoped to `:not([class*="MuiChip-color"])` and `:not(.MuiChip-colorDefault)` so status chips keep MUI palette colors - Fixed `MuiButton.contained` dark mode: was `glassPrimary` = `rgba(0,0,0,0.6)` (near-invisible); now solid `#2196f3` with proper hover - Fixed `MuiPaper`, `MuiDialog`: replaced `glassSecondary` with solid surfaces + explicit `text.primary` - Fixed `MuiMenu`/`MuiMenuItem`: solid backgrounds + `action.hover` hover (was `glassHover` = white-on-white in light mode) - Fixed `MuiButton.root`: removed `glassSubtle` spread (was adding `backdropFilter: blur` to every button) - Fixed `MuiTabs.root`: removed `glassSubtle` (was adding blur + near-white background) - Fixed `MuiAccordion`, `MuiAlert`: replaced glass spreads with solid surfaces + explicit text colors - Fixed `MuiBackdrop`: removed `blur(8px)` that was blurring the full page on every dropdown/select ### CSV Transformer UI Polish - `ProcessCsv.tsx`: column selection chips now use solid `#2196f3` for selected / visible mode-aware grey for unselected (via `sx` theme callback); no longer relies on `palette.primary.main` - `ProcessCsv.tsx`: AI Processor `Select` uses `MenuProps` to suppress full-page backdrop blur; added "Create new processor…" option that opens CSV marketplace in a new tab - `ProcessCsv.tsx`: all navigation buttons (`Back`, `Cancel`, `Next`, `Process and Download`) use consistent `variant="outlined"` style - Overview sidebar step titles: replaced `color="primary"` (invisible in dark mode) with `color="text.primary"` / `color="text.secondary"` - `ProcessingHistory.tsx`: DataGrid replaced hardcoded `color: 'white'` with `text.primary` / `text.secondary` palette tokens - `parseCsv.ts`: strip BOM/zero-width chars from headers; deduplicate column names; export `formatCsvHeaderForUi` helper - Fixed HTML entity literals (`'`, `"`, `'`) inside JS string literals in `ProcessCsv.tsx`, `jlinc-partnership.tsx`, `webinar-thank-you.tsx` ## Test Plan - [ ] CSV Transformer: upload CSV, verify all column chips visible and togglable in both light and dark mode - [ ] CSV Transformer: verify "Create new processor…" opens marketplace in new tab - [ ] CSV Transformer: verify worker status banner shows correct state without `ENABLE_CSV_RUN_CRON` on the web env - [ ] Processing History: verify table text readable in light and dark mode - [ ] Spot-check chips, buttons, menus, alerts, dialogs in light and dark mode across the app - [ ] Confirm `flowise` server build passes (`pnpm --filter flowise build`) --- .../csv-transformer/CsvTransformerClient.tsx | 8 +- .../(main-layout)/csv-transformer/page.tsx | 4 +- .../CsvTransfomer/CsvTransformer.Client.tsx | 66 ++++- .../ui/src/CsvTransfomer/ProcessCsv.tsx | 262 +++++++++++++----- .../src/CsvTransfomer/ProcessingHistory.tsx | 23 +- .../ui/src/CsvTransfomer/parseCsv.ts | 34 ++- .../src/GuardrailsSettings/AdvancedMode.tsx | 54 ---- .../src/GuardrailsSettings/MasterConfig.tsx | 90 +----- .../ui/src/GuardrailsSettings/SimpleMode.tsx | 17 +- .../ui/src/theme/components/muiOverrides.ts | 228 ++++++++++++--- packages/docs/src/pages/jlinc-partnership.tsx | 6 +- packages/docs/src/pages/webinar-thank-you.tsx | 2 +- .../src/controllers/csv-parser/index.ts | 13 + .../server/src/routes/csv-parser/index.ts | 1 + packages/server/src/utils/cron.ts | 4 +- .../server/src/utils/isCsvRunCronEnabled.ts | 7 + packages/ui/src/themes/compStyleOverride.js | 71 ++++- 17 files changed, 595 insertions(+), 295 deletions(-) create mode 100644 packages/server/src/utils/isCsvRunCronEnabled.ts diff --git a/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/CsvTransformerClient.tsx b/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/CsvTransformerClient.tsx index da2e37f7ac5..08cad0b3223 100644 --- a/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/CsvTransformerClient.tsx +++ b/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/CsvTransformerClient.tsx @@ -3,12 +3,8 @@ import dynamic from 'next/dynamic' const View = dynamic(() => import('@ui/CsvTransfomer'), { ssr: false }) -interface Props { - cronEnabled: boolean -} - -const CsvTransformerClient = ({ cronEnabled }: Props) => { - return +const CsvTransformerClient = () => { + return } export default CsvTransformerClient diff --git a/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/page.tsx b/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/page.tsx index 9a8b64bb941..2416bb0fa10 100644 --- a/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/page.tsx +++ b/apps/web/app/(Main UI)/sidekick-studio/(main-layout)/csv-transformer/page.tsx @@ -2,11 +2,9 @@ import React from 'react' import CsvTransformerClient from './CsvTransformerClient' const Page = () => { - const cronEnabled = process.env.ENABLE_CSV_RUN_CRON === 'true' - return ( <> - + ) } diff --git a/packages-answers/ui/src/CsvTransfomer/CsvTransformer.Client.tsx b/packages-answers/ui/src/CsvTransfomer/CsvTransformer.Client.tsx index 597fdbb7b94..0b00975629c 100644 --- a/packages-answers/ui/src/CsvTransfomer/CsvTransformer.Client.tsx +++ b/packages-answers/ui/src/CsvTransfomer/CsvTransformer.Client.tsx @@ -19,9 +19,7 @@ import { Container, Box, Stack, Tabs, Tab, Typography, Alert, AlertTitle } from import ProcessCsv from './ProcessCsv' import ProcessingHistory from './ProcessingHistory' -interface CsvTransformerProps { - cronEnabled?: boolean -} +type WorkerBannerState = 'loading' | 'enabled' | 'disabled' | 'error' function TabPanel(props: any) { const { children, currentValue, value, ...other } = props @@ -38,9 +36,10 @@ function TabPanel(props: any) { ) } -const CsvTransformer = ({ cronEnabled = false }: CsvTransformerProps) => { +const CsvTransformer = () => { const { user, isLoading } = useUser() const [chatflows, setChatflows] = useState([]) + const [workerBanner, setWorkerBanner] = useState('loading') const searchParams = useSearchParams() const router = useRouter() const tab = searchParams.get('tab') ?? 'process' @@ -65,6 +64,41 @@ const CsvTransformer = ({ cronEnabled = false }: CsvTransformerProps) => { fetchChatflows() }, [fetchChatflows]) + useEffect(() => { + if (isLoading) return + if (!user) { + setWorkerBanner('error') + return + } + let cancelled = false + const loadWorkerStatus = async () => { + const baseURL = sessionStorage.getItem('baseURL') || '' + const token = sessionStorage.getItem('access_token') + if (!baseURL || !token) { + if (!cancelled) setWorkerBanner('error') + return + } + try { + const response = await fetch(`${baseURL}/api/v1/csv-parser/worker-status`, { + headers: { + 'x-request-from': 'aai', + Authorization: `Bearer ${token}` + } + }) + if (!response.ok) throw new Error('worker-status failed') + const data = (await response.json()) as { workerEnabled?: boolean } + if (cancelled) return + setWorkerBanner(data.workerEnabled ? 'enabled' : 'disabled') + } catch { + if (!cancelled) setWorkerBanner('error') + } + } + loadWorkerStatus() + return () => { + cancelled = true + } + }, [isLoading, user]) + // Auto-refresh when user returns from marketplace (only if no CSV chatflows currently) useEffect(() => { const handleVisibilityChange = () => { @@ -100,16 +134,30 @@ const CsvTransformer = ({ cronEnabled = false }: CsvTransformerProps) => { AI CSV Transformer - {cronEnabled ? ( + {workerBanner === 'loading' && ( + + Checking worker status + Loading background CSV processing status from the API server… + + )} + {workerBanner === 'enabled' && ( Background processing is enabled - Submitted CSVs will be picked up by the cron worker on this environment. + Submitted CSVs will be picked up by the cron worker on the Flowise/API server (status from API). - ) : ( + )} + {workerBanner === 'disabled' && ( Background processing is disabled - CSV runs can be created but they will not be processed until ENABLE_CSV_RUN_CRON=true is set on the - server (then the worker is restarted). + CSV runs can be created but will not be processed until ENABLE_CSV_RUN_CRON=true is set on the + Flowise/API server and that process is restarted. + + )} + {workerBanner === 'error' && ( + + Could not load worker status + The UI could not read CSV worker status from the API (session, network, or server error). Background processing may + still be enabled on the server. )} diff --git a/packages-answers/ui/src/CsvTransfomer/ProcessCsv.tsx b/packages-answers/ui/src/CsvTransfomer/ProcessCsv.tsx index a496d45a896..624000d4880 100644 --- a/packages-answers/ui/src/CsvTransfomer/ProcessCsv.tsx +++ b/packages-answers/ui/src/CsvTransfomer/ProcessCsv.tsx @@ -2,9 +2,12 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react' import { useSearchParams } from 'next/navigation' import { useDropzone } from 'react-dropzone' import { Controller, useForm } from 'react-hook-form' -import { InputLabel, Select, MenuItem } from '@mui/material' - import { + InputLabel, + Select, + MenuItem, + Divider, + ListItemIcon, Stack, Box, Button, @@ -30,10 +33,19 @@ import { User } from 'types' import DownloadOutlined from '@mui/icons-material/DownloadOutlined' import CloseOutlined from '@mui/icons-material/CloseOutlined' import FilePresentOutlined from '@mui/icons-material/FilePresentOutlined' +import AddIcon from '@mui/icons-material/Add' import CsvNoticeCard from './CsvNoticeCard' import SnackMessage from '../SnackMessage' -import { parseCsvWithHeaders, parseCsvWithoutHeaders } from './parseCsv' +import { parseCsvWithHeaders, parseCsvWithoutHeaders, formatCsvHeaderForUi } from './parseCsv' + +/** Sentinel Select value — opens CSV marketplace; never stored as processorId. */ +const CREATE_NEW_CSV_PROCESSOR_VALUE = '__aai_csv_create_new__' + +function openCsvProcessorMarketplace() { + localStorage.setItem('answerai.csv.install-intent', 'true') + window.open('/sidekick-studio/marketplaces?usecase=CSV', '_blank', 'noopener,noreferrer') +} interface ChatFlow { id: string @@ -143,6 +155,34 @@ const ProcessCsv = ({ onRefreshChatflows?: () => Promise }) => { const theme = useTheme() + + /** Avoid full-screen blurred modal backdrop from global MuiBackdrop — keep a normal dropdown feel. */ + const csvProcessorSelectMenuProps = useMemo( + () => ({ + disableScrollLock: true, + BackdropProps: { + sx: { + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + backgroundColor: 'transparent' + } + }, + PaperProps: { + sx: { + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + backgroundImage: 'none', + bgcolor: 'background.paper', + border: '1px solid', + borderColor: 'divider', + boxShadow: theme.shadows[8], + maxHeight: 360 + } + } + }), + [theme.shadows] + ) + const [headers, setHeaders] = useState([]) const [rows, setRows] = useState([]) const [file, setFile] = useState(null) @@ -385,14 +425,19 @@ const ProcessCsv = ({ () => ({ ...{ ...baseStyle, - backgroundColor: theme.palette.grey[50], - borderColor: theme.palette.primary.main, - color: theme.palette.primary.main, + // Theme-aware tokens so the dropzone reads correctly in both + // light and dark mode. Hardcoding `grey[50]` produced a bright + // white surface in dark mode, and `primary.main` resolves to a + // translucent white in the AnswerAI dark theme — leaving the + // border + text effectively invisible. + backgroundColor: theme.palette.action.hover, + borderColor: theme.palette.divider, + color: theme.palette.text.primary, padding: theme.spacing(4), cursor: 'pointer' }, ...(isFocused ? { borderColor: theme.palette.secondary.main } : {}), - ...(isDragAccept ? { borderColor: theme.palette.primary.main } : {}), + ...(isDragAccept ? { borderColor: theme.palette.success.main } : {}), ...(isDragReject ? { borderColor: theme.palette.error.main } : {}) }), [isFocused, isDragAccept, isDragReject, theme] @@ -529,7 +574,7 @@ const ProcessCsv = ({ ) : ( - {'Drag 'n' drop a CSV file here, or click to select a file'} + Drag and drop a CSV file here, or click to select a file )} @@ -586,26 +631,49 @@ const ProcessCsv = ({ label='AI Processor' required error={!!errors.processorId} - disabled={chatflows.length === 0} fullWidth + displayEmpty + MenuProps={csvProcessorSelectMenuProps} + onChange={(e) => { + const v = e.target.value as string + if (v === CREATE_NEW_CSV_PROCESSOR_VALUE) { + openCsvProcessorMarketplace() + return + } + field.onChange(e) + }} + renderValue={(selected) => { + if (!selected) { + return ( + + {chatflows.length === 0 + ? 'Select or create a CSV processor' + : 'Select an AI processor'} + + ) + } + const cf = chatflows.find((c) => c.id === selected) + return cf?.name ?? selected + }} > - {chatflows.length === 0 ? ( - - No CSV processors available + {chatflows.map((chatflow) => ( + + {chatflow.name} - ) : ( - chatflows.map((chatflow) => ( - - {chatflow.name} - - )) - )} + ))} + {chatflows.length > 0 && } + + + + + Create new processor… + {errors.processorId?.message || (chatflows.length === 0 - ? 'Install a CSV processor from the marketplace below to continue' - : 'Select the AI model to process your CSV')} + ? 'Choose Create new processor or use the card below, then refresh.' + : 'Pick a processor above, or use Create new processor to open CSV templates in a new tab—install one, then refresh.')} )} @@ -736,43 +804,74 @@ const ProcessCsv = ({ ( - - - {headers.map((header) => { - const isSelected = value.includes(header) - return ( - { - if (!isSelected) { - onChange([...value, header]) - } else { - onChange(value.filter((col: string) => col !== header)) - } - }} - color={isSelected ? 'primary' : 'default'} - variant={isSelected ? 'filled' : 'outlined'} - sx={{ - '&:hover': { - backgroundColor: isSelected ? 'primary.main' : 'action.hover' - } - }} - /> - ) - })} - - - )} + render={({ field: { value, onChange } }) => { + const selected = Array.isArray(value) ? value : [] + return ( + + + {headers.map((header, index) => { + const visible = formatCsvHeaderForUi(header, index) + const isSelected = selected.includes(header) + return ( + { + if (!isSelected) { + onChange([...selected, header]) + } else { + onChange(selected.filter((col: string) => col !== header)) + } + }} + sx={(t) => ({ + cursor: 'pointer', + ...(isSelected + ? { + bgcolor: '#2196f3', + border: '1px solid #2196f3', + '& .MuiChip-label': { color: '#fff' }, + '&:hover': { bgcolor: '#1976d2', borderColor: '#1976d2' } + } + : { + bgcolor: + t.palette.mode === 'light' + ? 'rgba(15,23,42,0.25)' + : 'rgba(255,255,255,0.12)', + border: '1px solid', + borderColor: + t.palette.mode === 'light' + ? 'rgba(15,23,42,0.25)' + : 'rgba(255,255,255,0.22)', + '& .MuiChip-label': { + color: t.palette.text.primary + }, + '&:hover': { + bgcolor: + t.palette.mode === 'light' + ? 'rgba(15,23,42,0.15)' + : 'rgba(255,255,255,0.2)', + borderColor: + t.palette.mode === 'light' + ? 'rgba(15,23,42,0.45)' + : 'rgba(255,255,255,0.4)' + } + }) + })} + /> + ) + })} + + + ) + }} /> @@ -839,7 +938,7 @@ const ProcessCsv = ({ = 1 ? 'pointer' : 'default', @@ -920,20 +1019,35 @@ const ProcessCsv = ({ Selected columns: {watchedValues.sourceColumns.length} - {watchedValues.sourceColumns.slice(0, 3).map((col) => ( - 20 ? `${col.substring(0, 20)}...` : col} - title={col} // Full text on hover - size='small' - sx={{ maxWidth: '150px' }} - /> - ))} + {watchedValues.sourceColumns.slice(0, 3).map((col, i) => { + const display = formatCsvHeaderForUi(col, i) + return ( + 20 ? `${display.substring(0, 20)}…` : display} + title={display} + size='small' + sx={{ + maxWidth: '150px', + bgcolor: '#2196f3', + border: '1px solid #2196f3', + '& .MuiChip-label': { color: '#fff' } + }} + /> + ) + })} {watchedValues.sourceColumns.length > 3 && ( ({ + bgcolor: + t.palette.mode === 'light' ? 'rgba(15,23,42,0.06)' : 'rgba(255,255,255,0.25)', + border: '1px solid', + borderColor: + t.palette.mode === 'light' ? 'rgba(15,23,42,0.2)' : 'rgba(255,255,255,0.18)', + '& .MuiChip-label': { color: t.palette.text.secondary } + })} /> )} @@ -950,7 +1064,7 @@ const ProcessCsv = ({ = 2 ? 'pointer' : 'default', @@ -1162,7 +1276,7 @@ const ProcessCsv = ({ {activeStep === 3 ? ( ) : ( - )} diff --git a/packages-answers/ui/src/CsvTransfomer/ProcessingHistory.tsx b/packages-answers/ui/src/CsvTransfomer/ProcessingHistory.tsx index 0f061f9cac4..10dde23e5ad 100644 --- a/packages-answers/ui/src/CsvTransfomer/ProcessingHistory.tsx +++ b/packages-answers/ui/src/CsvTransfomer/ProcessingHistory.tsx @@ -294,20 +294,35 @@ const ProcessingHistory = ({ user }: { user: User }) => { ) }} sx={{ + borderColor: 'divider', '& .super-app-theme--header': { fontWeight: 'bold', fontSize: '0.875rem', - color: 'white' + color: 'text.primary' }, '& .MuiDataGrid-cell': { fontSize: '0.825rem', - color: 'white' + color: 'text.primary' }, '& .MuiDataGrid-columnHeaders': { - fontWeight: 'bold' + fontWeight: 'bold', + color: 'text.primary', + borderBottomColor: 'divider' + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + color: 'text.primary' + }, + '& .MuiDataGrid-row': { + '&:hover': { + bgcolor: 'action.hover' + } }, '& .MuiTablePagination-root': { - color: 'white' + color: 'text.primary', + '& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': { + color: 'text.secondary' + } } }} /> diff --git a/packages-answers/ui/src/CsvTransfomer/parseCsv.ts b/packages-answers/ui/src/CsvTransfomer/parseCsv.ts index 319174314fb..ef02763650f 100644 --- a/packages-answers/ui/src/CsvTransfomer/parseCsv.ts +++ b/packages-answers/ui/src/CsvTransfomer/parseCsv.ts @@ -14,6 +14,23 @@ function generateColumnName(index: number): string { return `Column ${index + 1}` } +/** + * Strip BOM, zero-width, and format chars so headers are not visually blank in the UI. + */ +function sanitizeHeaderLabel(raw: string): string { + return String(raw ?? '') + .replace(/^\uFEFF/, '') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .trim() +} + +/** Readable label for chips and UI (handles invisible-only header cells). */ +export function formatCsvHeaderForUi(raw: string, columnIndex?: number): string { + const v = sanitizeHeaderLabel(String(raw ?? '')) + if (v) return v + return typeof columnIndex === 'number' ? generateColumnName(columnIndex) : 'Column' +} + /** * Parse CSV content using RFC 4180 compliant parser with headers */ @@ -32,11 +49,22 @@ export function parseCsvWithoutHeaders(input: string): ParsedCsvResult { * Parse CSV with headers */ function parseWithHeaders(input: string): ParsedCsvResult { + const headerCounts = new Map() + const result = Papa.parse>(input.trim(), { header: true, skipEmptyLines: true, comments: '#', - transformHeader: (header) => header.trim() // Clean up header names + transformHeader: (header, index) => { + const colIndex = typeof index === 'number' ? index : 0 + let base = sanitizeHeaderLabel(String(header ?? '')) + if (!base) { + base = generateColumnName(colIndex) + } + const n = (headerCounts.get(base) ?? 0) + 1 + headerCounts.set(base, n) + return n === 1 ? base : `${base} (${n})` + } }) // Be very lenient with errors - Papa Parse can handle most cases @@ -56,8 +84,8 @@ function parseWithHeaders(input: string): ParsedCsvResult { throw new Error('CSV has no header row or headers could not be determined.') } - // Filter out empty header names - const cleanHeaders = headers.filter((header) => header && header.trim() !== '') + // After transformHeader, names should be non-empty; keep only real labels + const cleanHeaders = headers.filter((h) => sanitizeHeaderLabel(h) !== '') if (cleanHeaders.length === 0) { throw new Error('CSV has no valid header names.') } diff --git a/packages-answers/ui/src/GuardrailsSettings/AdvancedMode.tsx b/packages-answers/ui/src/GuardrailsSettings/AdvancedMode.tsx index 511376a2312..a4be722887d 100644 --- a/packages-answers/ui/src/GuardrailsSettings/AdvancedMode.tsx +++ b/packages-answers/ui/src/GuardrailsSettings/AdvancedMode.tsx @@ -100,51 +100,6 @@ const innerTabsSx = { } } -// Shared sx for sliders. Default MUI Slider uses `primary.main` for the rail, -// track and thumb — all invisible in the AnswerAI dark theme. Anchor on -// `text.primary` so the slider track is visible regardless of mode. -const sliderSx = { - color: 'text.primary', - '& .MuiSlider-rail': { - opacity: 0.32 - }, - '& .MuiSlider-track': { - border: 'none' - }, - '& .MuiSlider-thumb': { - boxShadow: 'none', - '&:hover, &.Mui-focusVisible': { - boxShadow: '0 0 0 6px rgba(127, 127, 127, 0.16)' - } - }, - '& .MuiSlider-mark': { - backgroundColor: 'text.secondary', - opacity: 0.6 - }, - '& .MuiSlider-markLabel': { - color: 'text.secondary', - fontSize: 12 - } -} - -// Shared sx for the per-section Enable switches (Safety, PII, Faithfulness). -// The default MUI Switch reads as grey-on-grey in the AnswerAI dark theme -// because the active state resolves to translucent `primary.main`. Anchor -// checked thumb + track on `info.main` (Material Blue, shared across modes) -// so "on" is unmistakable and consistent with the page-level switches. -const sectionSwitchSx = { - '& .MuiSwitch-switchBase.Mui-checked': { - color: 'info.main', - '& + .MuiSwitch-track': { - backgroundColor: 'info.main', - opacity: 0.5 - } - }, - '& .MuiSwitch-switchBase.Mui-checked:hover': { - backgroundColor: 'rgba(33, 150, 243, 0.08)' - } -} - // Shared sx for the AccordionSummary section heads ("Input Validation", // "Output Validation", "Advanced Settings"). Aligns with the subtitle2 + // fontWeight 600 rhythm used elsewhere on the page and gives the summary a @@ -374,7 +329,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o setSafetyEnabled(e.target.checked) markChanged() }} - sx={sectionSwitchSx} /> } label='Enable' @@ -413,7 +367,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o { value: 1, label: '1.0' } ]} valueLabelDisplay='auto' - sx={sliderSx} /> @@ -505,7 +458,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o step={0.01} valueLabelDisplay='auto' size='small' - sx={sliderSx} /> @@ -557,7 +509,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o setPiiEnabled(e.target.checked) markChanged() }} - sx={sectionSwitchSx} /> } label='Enable' @@ -595,7 +546,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o { value: 1, label: '1.0' } ]} valueLabelDisplay='auto' - sx={sliderSx} /> @@ -691,7 +641,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o step={0.01} valueLabelDisplay='auto' size='small' - sx={sliderSx} /> @@ -769,7 +718,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o setFaithfulnessEnabled(e.target.checked) markChanged() }} - sx={sectionSwitchSx} /> } label='Enable' @@ -800,7 +748,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o { value: 0.1, label: '0.1' } ]} valueLabelDisplay='auto' - sx={sliderSx} /> @@ -861,7 +808,6 @@ export default function AdvancedMode({ config, onSave, saving, error, success, o step={1} marks valueLabelDisplay='auto' - sx={sliderSx} /> diff --git a/packages-answers/ui/src/GuardrailsSettings/MasterConfig.tsx b/packages-answers/ui/src/GuardrailsSettings/MasterConfig.tsx index c7590d38be9..1f9f4efb788 100644 --- a/packages-answers/ui/src/GuardrailsSettings/MasterConfig.tsx +++ b/packages-answers/ui/src/GuardrailsSettings/MasterConfig.tsx @@ -37,40 +37,6 @@ const ConfirmDialog = dynamic(() => import('flowise-ui/src/ui-component/dialog/C const FIDDLER_CREDENTIAL_NAME = 'fiddlerApi' -// Shared sx for "neutral" outlined action buttons (Edit, Change, Recheck). -// MUI's default outlined Button anchors text + border on `primary.main`, -// which in the AnswerAI dark theme resolves to rgba(255,255,255,0.12) — -// translucent white. The buttons end up reading as if they were disabled -// even when fully enabled. Anchor on `text.primary` + `divider` so they -// have clear contrast in both modes; the actual `disabled` state is left -// to MUI's default treatment so it still reads as inert when applicable. -const outlinedActionSx = { - color: 'text.primary', - borderColor: 'divider', - '&:hover': { - borderColor: 'text.primary', - bgcolor: 'action.hover' - } -} - -// Shared sx for the page switches ("Enable Guardrails", "Observability-only"). -// The default MUI `color='primary'` resolves to translucent white in the -// AnswerAI dark theme, making the on-state nearly invisible. Anchor the -// checked state on `info.main` (Material Blue, shared across light/dark) so -// the on-state reads unambiguously in both modes. -const pageSwitchSx = { - '& .MuiSwitch-switchBase.Mui-checked': { - color: 'info.main', - '& + .MuiSwitch-track': { - backgroundColor: 'info.main', - opacity: 0.5 - } - }, - '& .MuiSwitch-switchBase.Mui-checked:hover': { - backgroundColor: 'rgba(33, 150, 243, 0.08)' - } -} - interface GuardrailConfig { enabled?: boolean credentialId?: string @@ -356,7 +322,7 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC {/* Enable/Disable Toggle */} } + control={} label='Enable Guardrails' sx={{ mb: 2 }} /> @@ -410,12 +376,7 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC {/* Show Change when user owns any credentials they could switch to */} {credentials.length > 0 && ( - )} @@ -480,7 +441,6 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC disabled={!enabled || editLoading} onClick={handleEditCredential} startIcon={editLoading ? : } - sx={outlinedActionSx} > Edit @@ -500,7 +460,6 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC size='small' disabled={!enabled} onClick={() => setShowCredentialDropdown(!showCredentialDropdown)} - sx={outlinedActionSx} > Change @@ -556,7 +515,6 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC onClick={loadCapabilities} disabled={loadingCapabilities} startIcon={loadingCapabilities ? : } - sx={outlinedActionSx} > Recheck @@ -742,43 +700,12 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC disabled={!enabled} aria-label='Failure mode' size='small' - sx={(theme) => { - // The AnswerAI theme defines `primary.{main,light,dark}` as - // translucent whites/slates rather than a vivid color, so anchoring - // the selected state on `primary.*` produced ghosted text in dark - // mode. We use MUI's mode-balanced `action.selected` token + a - // visible border + `text.primary` for guaranteed contrast in both - // modes — same pattern used for menu/table selection across the app. - const isDark = theme.palette.mode === 'dark' - return { - '& .MuiToggleButton-root': { - color: 'text.primary', - borderColor: 'divider', - textTransform: 'none', - fontWeight: 500, - px: 1.5, - transition: 'background-color 120ms ease, border-color 120ms ease' - }, - '& .MuiToggleButton-root:hover': { - bgcolor: 'action.hover' - }, - '& .MuiToggleButton-root.Mui-selected': { - bgcolor: 'action.selected', - color: 'text.primary', - fontWeight: 600, - borderColor: isDark ? 'rgba(255, 255, 255, 0.45)' : 'rgba(15, 23, 42, 0.5)', - '&:hover': { - bgcolor: isDark ? 'rgba(255, 255, 255, 0.22)' : 'rgba(15, 23, 42, 0.12)' - } - } - } - }} > - + Fail open (allow, warn) - + Fail closed (block, 503) @@ -792,14 +719,7 @@ export default function MasterConfig({ config, onConfigChange, onSave }: MasterC {/* Shadow pilot toggle. Violations are recorded but never block. */} - } + control={} label={ Observability-only (shadow mode) diff --git a/packages-answers/ui/src/GuardrailsSettings/SimpleMode.tsx b/packages-answers/ui/src/GuardrailsSettings/SimpleMode.tsx index 826fb73f698..3cf30f63bdc 100644 --- a/packages-answers/ui/src/GuardrailsSettings/SimpleMode.tsx +++ b/packages-answers/ui/src/GuardrailsSettings/SimpleMode.tsx @@ -175,27 +175,12 @@ export default function SimpleMode({ config, onSave, saving, error, success, onC - {/* Save uses a local sx override to force a solid disabled background. - The global theme applies a gradient `background` to all contained - buttons; MUI's default disabled state only resets `backgroundColor`, - which leaves the gradient bleeding through at low contrast in both - modes. We zero out `background` + `boxShadow` on disabled here so - the button reads unmistakably as disabled when no preset is chosen. */} diff --git a/packages-answers/ui/src/theme/components/muiOverrides.ts b/packages-answers/ui/src/theme/components/muiOverrides.ts index 99889a1249e..6871b045ff1 100644 --- a/packages-answers/ui/src/theme/components/muiOverrides.ts +++ b/packages-answers/ui/src/theme/components/muiOverrides.ts @@ -5,11 +5,15 @@ import { Components, Theme } from '@mui/material/styles' import { glassmorphismTokens } from '../tokens/glassmorphism' -import { colorTokens } from '../tokens/colors' +import { colorTokens, statusColors } from '../tokens/colors' export const muiComponentOverrides = (mode: 'light' | 'dark'): Components> => { const glass = glassmorphismTokens[mode] const colors = colorTokens[mode] + // Heavier neutral border used for "selected" affordances (toggle buttons, + // selected cards) so the chosen state reads unambiguously in both themes. + const selectedBorder = mode === 'dark' ? 'rgba(255, 255, 255, 0.45)' : 'rgba(15, 23, 42, 0.5)' + const selectedBorderHover = mode === 'dark' ? 'rgba(255, 255, 255, 0.6)' : 'rgba(15, 23, 42, 0.7)' return { // Global CSS Baseline @@ -137,16 +141,20 @@ export const muiComponentOverrides = (mode: 'light' | 'dark'): Components - Got it! We'll tailor the workshop follow-ups to your goals. + Got it! We'll tailor the workshop follow-ups to your goals. )} diff --git a/packages/server/src/controllers/csv-parser/index.ts b/packages/server/src/controllers/csv-parser/index.ts index 518ed2fd099..18bcc43eda5 100644 --- a/packages/server/src/controllers/csv-parser/index.ts +++ b/packages/server/src/controllers/csv-parser/index.ts @@ -3,6 +3,7 @@ import csvParserService from '../../services/csv-parser' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { StatusCodes } from 'http-status-codes' import { CreateCsvParseRunRequest } from '../../types/csvTypes' +import { isCsvRunCronEnabled } from '../../utils/isCsvRunCronEnabled' const getAllCsvParseRuns = async (req: Request, res: Response, next: NextFunction) => { try { @@ -76,6 +77,17 @@ const createCsvParseRun = async (req: Request, res: Response, next: NextFunction } } +const getWorkerStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.user) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Error: csvParserController.getWorkerStatus - Unauthorized') + } + return res.json({ workerEnabled: isCsvRunCronEnabled() }) + } catch (error) { + next(error) + } +} + const getProcessedCsvSignedUrl = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.user) { @@ -96,6 +108,7 @@ const getProcessedCsvSignedUrl = async (req: Request, res: Response, next: NextF export default { getAllCsvParseRuns, + getWorkerStatus, getCsvParseRunById, createCsvParseRun, getProcessedCsvSignedUrl diff --git a/packages/server/src/routes/csv-parser/index.ts b/packages/server/src/routes/csv-parser/index.ts index 93cea695e3b..8e67b75e45d 100644 --- a/packages/server/src/routes/csv-parser/index.ts +++ b/packages/server/src/routes/csv-parser/index.ts @@ -4,6 +4,7 @@ import csvParserController from '../../controllers/csv-parser' const router = express.Router() router.get('/', csvParserController.getAllCsvParseRuns) +router.get('/worker-status', csvParserController.getWorkerStatus) router.get('/:id', csvParserController.getCsvParseRunById) router.post('/', csvParserController.createCsvParseRun) router.get('/:id/signed-url', csvParserController.getProcessedCsvSignedUrl) diff --git a/packages/server/src/utils/cron.ts b/packages/server/src/utils/cron.ts index 58b0d189fe0..fb3046d6f44 100644 --- a/packages/server/src/utils/cron.ts +++ b/packages/server/src/utils/cron.ts @@ -1,6 +1,7 @@ import cron from 'node-cron' import axios from 'axios' import logger from './logger' +import { isCsvRunCronEnabled } from './isCsvRunCronEnabled' import initCsvRun from '../jobs/initCsvRun' import processCsvRows from '../jobs/processCsvRows' import generateCsv from '../jobs/generateCsv' @@ -22,7 +23,6 @@ const API_HOST = process.env.API_HOST || `http://localhost:${process.env.PORT || * Default: true */ const ENABLE_BILLING_SYNC_CRON = process.env.ENABLE_BILLING_SYNC_CRON !== 'false' -const ENABLE_CSV_RUN_CRON = process.env.ENABLE_CSV_RUN_CRON === 'true' /** * Initialize cron jobs @@ -51,7 +51,7 @@ export function initCronJobs() { logger.info('📅 [cron]: Billing usage sync cron job is disabled') } - if (ENABLE_CSV_RUN_CRON) { + if (isCsvRunCronEnabled()) { logger.info('📅 [cron]: Initializing csv run cron job') initCsvRun() processCsvRows() diff --git a/packages/server/src/utils/isCsvRunCronEnabled.ts b/packages/server/src/utils/isCsvRunCronEnabled.ts new file mode 100644 index 00000000000..9bde4b31830 --- /dev/null +++ b/packages/server/src/utils/isCsvRunCronEnabled.ts @@ -0,0 +1,7 @@ +/** + * Single source of truth for ENABLE_CSV_RUN_CRON (Flowise server process env). + * Used by cron registration and the csv-parser worker-status API. + */ +export function isCsvRunCronEnabled(): boolean { + return process.env.ENABLE_CSV_RUN_CRON === 'true' +} diff --git a/packages/ui/src/themes/compStyleOverride.js b/packages/ui/src/themes/compStyleOverride.js index 8f24b0c7cfe..c95fbbe5a77 100644 --- a/packages/ui/src/themes/compStyleOverride.js +++ b/packages/ui/src/themes/compStyleOverride.js @@ -178,22 +178,91 @@ export default function componentStyleOverrides(theme) { } } }, + // SLIDER + // Anchor on `text.primary` so rail/track/thumb stay visible regardless + // of mode. Mirrors the unified theme override for consistency between + // TheAnswer pages and Flowise core canvas/dialogs. MuiSlider: { styleOverrides: { root: { + color: theme.darkTextPrimary, '&.Mui-disabled': { color: theme.colors?.grey300 } }, + rail: { + opacity: 0.32 + }, + track: { + border: 'none' + }, + thumb: { + boxShadow: 'none', + '&:hover, &.Mui-focusVisible': { + boxShadow: '0 0 0 6px rgba(127, 127, 127, 0.16)' + } + }, mark: { - backgroundColor: theme.paper, + backgroundColor: theme.darkTextSecondary, + opacity: 0.6, width: '4px' }, + markLabel: { + color: theme.darkTextSecondary, + fontSize: 12 + }, valueLabel: { color: theme?.colors?.primaryLight } } }, + // SWITCH + // Anchor checked state on Material Blue (`#2196f3`) so the on-state + // reads unambiguously across both Flowise core themes. Matches the + // unified TheAnswer theme — switches in ChatflowGuardrails / ShareChatbot + // / canvas dialogs visually align with switches in the rest of the app. + MuiSwitch: { + styleOverrides: { + switchBase: { + '&.Mui-checked': { + color: '#2196f3', + '& + .MuiSwitch-track': { + backgroundColor: '#2196f3', + opacity: 0.5 + }, + '&:hover': { + backgroundColor: 'rgba(33, 150, 243, 0.08)' + } + } + } + } + }, + // TOGGLE BUTTON + // Selected state on a translucent neutral overlay + heavier border so + // the chosen option is unmistakable in both themes. + MuiToggleButton: { + styleOverrides: { + root: { + color: theme.darkTextPrimary, + borderColor: theme.divider, + textTransform: 'none', + transition: 'background-color 120ms ease, border-color 120ms ease', + '&:hover': { + backgroundColor: theme?.customization?.isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.04)' + }, + '&.Mui-selected': { + backgroundColor: theme?.customization?.isDarkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)', + color: theme.darkTextPrimary, + fontWeight: 600, + borderColor: theme?.customization?.isDarkMode ? 'rgba(255, 255, 255, 0.45)' : 'rgba(15, 23, 42, 0.5)', + '&:hover': { + backgroundColor: theme?.customization?.isDarkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)', + borderColor: theme?.customization?.isDarkMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(15, 23, 42, 0.7)' + } + } + } + } + }, MuiDivider: { styleOverrides: { root: {