Skip to content

feat(platform): multi-tenancy isolation, per-org providers, and org lifecycle UX#1573

Merged
larryro merged 5 commits into
mainfrom
feat/multi-tenancy-isolation
Apr 19, 2026
Merged

feat(platform): multi-tenancy isolation, per-org providers, and org lifecycle UX#1573
larryro merged 5 commits into
mainfrom
feat/multi-tenancy-isolation

Conversation

@larryro
Copy link
Copy Markdown
Collaborator

@larryro larryro commented Apr 19, 2026

Summary

  • Data, config, and billing isolation across orgs. resolveOrgSlug() is threaded through 21+ backend callsites (workflow engine, triggers, integrations, OAuth, agent tools, agent chat) that previously hardcoded 'default'; thread/archived-thread queries now filter by organizationId; workflow LLM node and OpenAI-compat now run checkBudget and record per-org ledger usage.
  • Per-org provider resolution. orgSlug flows through language-model resolution (failover, resolve_model, agent chat, summarization, openai_compat, image tool, thread title, workflow LLM node) so each org consumes its own provider files and keys. NoProviderAvailableError surfaces a translated hint when a tenant has no configured model.
  • Org lifecycle + switcher UX. Better Auth organizationHooks enforce slug uniqueness on create and scaffold per-domain config into /examples/{slug}/; owner-only delete cleans up filesystem state via cleanupOrgFilesystem (guarded by verifyPathWithinBase) with an audit entry. A dedicated /dashboard/switching route awaits session refetch + invalidates the ['auth','session'] TanStack cache before navigating, preserves the current subpath + query + hash across switches, and eliminates the double-skeleton flash. user.lastActiveOrganizationId is persisted via Better Auth so login lands on the last-used org.
  • Dashboard/guards. dashboard/index.tsx is a 0/1/N post-login org picker; dashboard/\$id.tsx silently syncs session to match the active route; /dashboard/create-organization is reachable even for existing members.
  • i18n (en/de/fr) for org switcher, Your-organizations list, delete dialog, toasts, aria labels, and the missing-API-key chat hint.

Test plan

  • Create org A + org B as different users; verify threads, providers, integrations, and workflows in A are invisible from B.
  • Switch orgs while on /settings/governance?group=... and verify the tab + query + hash survive.
  • Configure providers only in org A; trigger chat/summarization/workflow LLM node/image tool in org B → expect NoProviderAvailableError with localized hint.
  • Owner deletes an org → audit entry written, /examples/{slug}/ is removed, non-default slug enforced, membership cleanup intact.
  • Log out from org B, log back in → dashboard lands on B (last-active).
  • Run workflow LLM node and OpenAI-compat endpoint for an org at budget limit → both should be blocked by checkBudget and usage logged per-org.
  • Create an org with a slug colliding with an existing one → beforeCreateOrganization rejects it.
  • Verify de/fr translations render for switcher, delete dialog, and missing-API-key hint.

Summary by CodeRabbit

  • New Features

    • Added organization switcher in the user menu for quick organization switching
    • Added organization management in settings with ability to create, delete, and switch between organizations
    • Implemented organization-specific provider and workflow configurations
    • Enhanced organization creation form with name validation and slug preview
  • Improvements

    • Chat history, search, and provider settings now scoped to current organization
    • Better error messaging when AI provider API keys are not configured for an organization
    • System now remembers your last active organization across sessions

larryro added 5 commits April 19, 2026 10:13
Enforce data, configuration, and billing isolation across organizations
within a single instance so multiple tenants can operate independently.

- Add resolveOrgSlug() helper and thread the owning org's slug through
  21+ backend callsites (workflow engine, triggers, integrations, OAuth,
  agent tools, agent chat) that previously hardcoded 'default'.
- Fix settings UI leaks on Integrations and Providers pages that read
  from the default org regardless of route.
- Wire Better Auth organizationHooks: beforeCreateOrganization enforces
  slug uniqueness; afterCreateOrganization schedules a scaffolder that
  seeds per-domain config into /examples/{newOrgSlug}/.
- Rewrite dashboard/index.tsx as a 0/1/N post-login org picker and add
  an active-org-vs-route guard on dashboard/\$id.tsx that silently syncs
  the session to match the route.
- Add org switcher to user-button with team-filter reset. Every setActive
  callsite now invalidates the ['auth','session'] TanStack cache to
  prevent redirect loops caused by the 5-minute staleTime.
- Add recordOrgSwitch mutation writing an entered_organization audit
  log entry for each tenant selection.
- Close workflow LLM node and OpenAI-compat billing gaps: both now call
  checkBudget pre-LLM and record usage in the per-org ledger.
- Update imports for the new @better-auth/api-key split package and fix
  the list() response shape change.
- Thread orgSlug through language-model resolution (failover,
  resolve_model, agent chat, summarization, openai_compat, image tool,
  thread title, workflow LLM node) so each org uses its own provider
  files and API keys.
- Filter listThreads/listArchivedThreads by organizationId so users who
  belong to multiple orgs don't see other tenants' threads.
- Persist user.lastActiveOrganizationId via Better Auth; record_org_switch
  writes it and users/get_last_active_org reads it so the dashboard
  auto-selects the same org after logout/login.
- Replace the multi-org picker on /dashboard with a session → persistent
  → first-membership resolver and move explicit switching into the org
  settings page; /dashboard/create-organization is now reachable for
  users who already belong to an org.
- Validate org name against the slug regex with a live slug preview in
  OrganizationForm and navigate to the new org's dashboard on creation.
- Introduce NoProviderAvailableError with a user-facing message, map it
  in sanitize-chat-error, and add errorHintMissingApiKey copy in
  en/de/fr.
- Add owner-only "Delete" button on the "Your organizations" list
  in Settings → Organization with a destructive ConfirmDialog. New
  `prepareOrganizationDeletion` mutation enforces owner role, refuses
  the default slug, writes an `organization_deleted` audit entry, and
  schedules `cleanupOrgFilesystem` to rm -rf each loader's per-org
  dir (agents/providers/integrations/workflows), guarded by
  `verifyPathWithinBase` against slug traversal.
- Move org switching onto a dedicated /dashboard/switching route so
  setActive + session invalidation + audit + navigation all happen in
  one place without racing the old route's unmount and the new
  route's guard — fixes the double-skeleton flash. The route awaits
  a session refetch before navigating so the downstream dashboard
  mounts coherently.
- Preserve the current subpath across switches (e.g. settings/...,
  chat, workflows) via a `?subpath=` search param on the switching
  route, instead of always resetting to the default landing page.
- Replace the skeleton with a centered spinner + translated text for
  the switching state — a transient route, not real content.
- Localise all org-management UI strings (EN / DE / FR) for the
  switcher label, Your-organizations list, delete dialog, toasts,
  and aria labels.
Capture location.href (full path + search + hash) instead of just
pathname so switching orgs while on /settings/governance?group=...
keeps the active tab, and hash anchors survive the round-trip to
/dashboard/switching and back.
Four CI jobs were red on this PR. Root causes:

- format:check — betterAuth/generated_schema.ts had stale formatting
  after the better-auth API-key split; ran oxfmt to normalize.
- Backend tests — the new resolveOrgSlug() helper calls
  components.betterAuth.adapter.findOne, but several agent-tool tests
  mock _generated/api without the components export. Stub
  resolveOrgSlug directly in each affected test so they don't need to
  reach into the betterAuth component at all.
- UI tests (dashboard-layout) — DashboardLayout now uses
  useQueryClient/useQuery for session state and useMutation to record
  org switches. Added mocks for @tanstack/react-query, the
  record_org_switch api ref, and authClient.
- UI tests (user-button) — UserButton now pulls organizations via
  useUserOrganizationsWithDetails (which needs useConvexAuth) and
  useLocation. Extended the existing mocks accordingly.

No production code changes — tests only, plus a formatting-only diff
on the auto-generated betterAuth schema.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

This PR implements multi-organization support with organization switching and org-scoped resource access throughout the platform. It introduces a new /dashboard/switching route for handling organization transitions, adds an organization switcher menu in the user button component, and implements organization deletion flows with audit logging. The backend changes include org-scoped filtering for chat threads, organization-specific provider and integration resolution, org-scoped workflow and agent tool execution, and new queries for retrieving user organizations with details. Additional features include budget enforcement per organization, last-active organization persistence, organization scaffolding for filesystem setup, and comprehensive error handling for missing API keys by organization.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: multi-tenancy isolation, per-organization providers, and organization lifecycle UX improvements. It directly maps to the substantial work across backend callsites, provider scoping, and organization management features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multi-tenancy-isolation

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 22

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
services/platform/convex/integrations/test_connection.ts (1)

244-252: ⚠️ Potential issue | 🟠 Major

Handle org-slug resolution failures inside the existing failure path

resolveOrgSlug/loadIntegration run before try, so exceptions here bypass the standardized { success: false } response and the credential error-status update logic.

💡 Suggested fix
-  const orgSlug = await resolveOrgSlug(ctx, credential.organizationId);
-  const integration = await ctx.runAction(
-    internal.integrations.load_integration.loadIntegration,
-    {
-      orgSlug,
-      organizationId: credential.organizationId,
-      slug: credential.slug,
-    },
-  );
-
-  if (!integration) {
-    return {
-      success: false,
-      message: 'Integration not found',
-    };
-  }
-
   // Dry-run mode: inline overrides skip DB status mutations (credentials not yet persisted)
   const isDryRun = !!(
     args.sqlConnectionConfig ||
     args.basicAuth ||
     args.apiKeyAuth ||
     args.oauth2Auth ||
     args.connectionConfig
   );
 
   try {
+    const orgSlug = await resolveOrgSlug(ctx, credential.organizationId);
+    const integration = await ctx.runAction(
+      internal.integrations.load_integration.loadIntegration,
+      {
+        orgSlug,
+        organizationId: credential.organizationId,
+        slug: credential.slug,
+      },
+    );
+
+    if (!integration) {
+      return {
+        success: false,
+        message: 'Integration not found',
+      };
+    }
+
     debugLog(`Test Connection Testing ${integration.name} integration...`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/integrations/test_connection.ts` around lines 244 -
252, The calls to resolveOrgSlug and
internal.integrations.load_integration.loadIntegration happen before the try
block so any exception escapes the standardized failure path and skips the
credential error-status update; move the await resolveOrgSlug(ctx,
credential.organizationId) and the
ctx.runAction(internal.integrations.load_integration.loadIntegration, {...})
invocation inside the existing try (or wrap them in their own try/catch that
delegates to the same failure handler) so that any errors are caught and the
function returns the canonical { success: false } response and executes the
credential error-status update logic for the same error-handling flow.
services/platform/convex/lib/agent_chat/start_agent_chat.ts (1)

170-173: ⚠️ Potential issue | 🔴 Critical

Validate thread ownership against organizationId before downstream writes/scheduling.

Line 338 forwards caller-supplied organizationId into a downstream internal action, but this helper currently doesn’t enforce a thread/org ownership check immediately after loading the thread. Add the check before any save/patch/scheduler calls to prevent cross-org effects if a threadId leaks.

🛡️ Suggested guard placement
   const thread = await ctx.runQuery(components.agent.threads.getThread, {
     threadId,
   });
+  if (!thread) {
+    throw new Error('Thread not found');
+  }
+  if (thread.organizationId !== organizationId) {
+    throw new Error('Forbidden: thread does not belong to organization');
+  }
Based on learnings: "For conversation write paths, validate args.organizationId matches the loaded conversation.organizationId immediately after fetching the conversation and before any inserts/patches."

Also applies to: 338-339

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/lib/agent_chat/start_agent_chat.ts` around lines 170
- 173, After loading the thread via
ctx.runQuery(components.agent.threads.getThread) (the local variable thread),
immediately validate that the caller-supplied organizationId matches
thread.organizationId and abort (throw or return an authorization error) if they
differ; perform this check before any downstream calls that save/patch/schedule
work (including the internal action that receives organizationId) so no
cross-organization writes occur. Ensure the check is placed in
start_agent_chat.ts right after the getThread call and before any further
operations that forward organizationId.
services/platform/convex/lib/summarization/internal_actions.ts (1)

11-27: ⚠️ Potential issue | 🟠 Major

Require organizationId for summarization model resolution.

Line 11 making organizationId optional allows silent fallback to global/default provider resolution. In a tenant-scoped provider system, this can route summarization through the wrong org context when a caller forgets to pass org data.

🔧 Proposed fix
 export const autoSummarizeIfNeeded = internalAction({
   args: {
     threadId: v.string(),
-    organizationId: v.optional(v.string()),
+    organizationId: v.string(),
   },
@@
-    const orgSlug = args.organizationId
-      ? await resolveOrgSlug(ctx, args.organizationId)
-      : undefined;
+    const orgSlug = await resolveOrgSlug(ctx, args.organizationId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/lib/summarization/internal_actions.ts` around lines
11 - 27, The handler currently treats organizationId as optional which allows
silent fallback to a global provider; change the input schema to require
organizationId (replace v.optional(v.string()) with v.string()) and update the
handler in the same action to validate that args.organizationId is present
before calling resolveOrgSlug and resolveLanguageModelWithFallback (or throw a
clear error), so resolveOrgSlug and resolveLanguageModelWithFallback are always
invoked with a tenant-scoped orgSlug derived from the provided organizationId.
services/platform/app/routes/dashboard/$id/settings/integrations.tsx (1)

118-133: ⚠️ Potential issue | 🟠 Major

Key ensuredRef by organization, not just by integration slug.

ensuredRef.current survives dashboard org switches. Once org A adds "shopify" to the set, org B will skip the same slug and never auto-create its missing credential row. The same one-way flag also suppresses retries after a failed installIntegration call.

Suggested fix
   const installFn = useAction(api.integrations.file_actions.installIntegration);
   const ensuredRef = useRef(new Set<string>());
+
+  useEffect(() => {
+    ensuredRef.current.clear();
+  }, [orgSlug]);
+
   useEffect(() => {
     if (!orgSlug || !credentials || !fileIntegrations.length) return;
@@
       if (!item.installed) continue;
       const slug = String(item.slug);
-      if (!credSlugs.has(slug) && !ensuredRef.current.has(slug)) {
-        ensuredRef.current.add(slug);
-        void installFn({ orgSlug, slug, organizationId });
+      const ensureKey = `${orgSlug}:${slug}`;
+      if (!credSlugs.has(slug) && !ensuredRef.current.has(ensureKey)) {
+        ensuredRef.current.add(ensureKey);
+        void installFn({ orgSlug, slug, organizationId }).catch(() => {
+          ensuredRef.current.delete(ensureKey);
+        });
       }
     }
   }, [fileIntegrations, credentials, installFn, organizationId, orgSlug]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/routes/dashboard/`$id/settings/integrations.tsx around
lines 118 - 133, ensuredRef currently is a global Set across orgs and is updated
before calling installFn, causing other orgs to skip identical slugs and
preventing retries on failure; change ensuredRef to be per-organization (e.g.,
useRef(new Map<string, Set<string>>()) or prefix keys with orgSlug like
`${orgSlug}:${slug}`) and do NOT add the slug to the set until installFn
successfully completes (await the promise and only then add to the org-scoped
set); additionally, clear or reset the org-specific entry when orgSlug changes
to avoid stale state and ensure retries after failures (update the useEffect
logic that iterates fileIntegrations and where ensuredRef is read/updated).
services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx (2)

836-865: ⚠️ Potential issue | 🟡 Minor

Hardcoded cost labels — use i18n.

The input labels "Input cost (USD / 1M tokens)" and "Output cost (USD / 1M tokens)" are hardcoded. Use translation keys for consistency.

As per coding guidelines: "Do NOT hardcode text, use the translation hooks/functions instead for user-facing UI".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/routes/dashboard/`$id/settings/providers/$providerName.tsx
around lines 836 - 865, Replace the hardcoded label strings on the two Input
components (the ones bound to form.inputCostPerMillion and
form.outputCostPerMillion and updated via setForm) with translated strings using
the app's translation hook/function (e.g., call t('...') or i18n.t('...'));
update both the label and any user-facing placeholders ("Input cost (USD / 1M
tokens)", "Output cost (USD / 1M tokens)", and placeholder examples) to use the
appropriate translation keys (create keys such as "providers.inputCostLabel" and
"providers.outputCostLabel" if missing) so the Input components receive labels
and placeholders from t(...) instead of hardcoded text.

648-649: ⚠️ Potential issue | 🟡 Minor

Hardcoded table header — use i18n.

"Cost / 1M tokens" should use a translation key.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/routes/dashboard/`$id/settings/providers/$providerName.tsx
around lines 648 - 649, Replace the hardcoded header text "Cost / 1M tokens" in
the TableHead element with a translation lookup (i18n) so the label uses your
translation keys; update the TableHead (the element with className "w-[140px]
text-right") to call the app's i18n helper (e.g.,
t('dashboard.providers.costPer_million_tokens') or a matching key used
elsewhere) or use <Trans> as appropriate and add the new key to the translations
files so the label is localized.
services/platform/convex/agent_tools/files/helpers/analyze_image.ts (1)

187-196: ⚠️ Potential issue | 🔴 Critical

Cross-tenant cache key collision: orgSlug missing from cache key.

The analyzeImageCached function receives orgSlug via params (used in analyzeImage for org-specific provider resolution), but the cache key only includes { fileId, question, fileName }. Different organizations with different configured providers/models will receive incorrect cached results from other organizations.

If Org A analyzes image X with question Y using Model A, the result is cached under { fileId, question, fileName }. When Org B analyzes the same image and question with Model B, they receive Org A's cached result instead of their own analysis—a multi-tenancy isolation breach.

Include orgSlug in the cache key:

Fix
 export async function analyzeImageCached(
   ctx: ActionCtx,
   params: AnalyzeImageParams,
 ): Promise<AnalyzeImageResult> {
   return await imageAnalysisCache.fetch(ctx, {
     fileId: params.fileId,
     question: params.question,
     fileName: params.fileName,
+    orgSlug: params.orgSlug,
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/agent_tools/files/helpers/analyze_image.ts` around
lines 187 - 196, The cache key used in analyzeImageCached (function
analyzeImageCached) omits orgSlug causing cross-tenant collisions; update the
imageAnalysisCache.fetch call to include params.orgSlug (e.g., add orgSlug:
params.orgSlug to the key object) so cached results are scoped per organization
(matching how analyzeImage resolves org-specific providers/models).
services/platform/app/routes/dashboard/index.tsx (1)

147-149: ⚠️ Potential issue | 🟡 Minor

Label the loading spinner.

This route only renders a spinner, but Line 149 does not give it an accessible name. Pass a translated label so assistive tech can announce the loading state.

As per coding guidelines, "USE role=\"status\" with aria-label for spinners."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/routes/dashboard/index.tsx` around lines 147 - 149, The
loading Spinner rendered inside FullPageCenter lacks an accessible label; update
the JSX that returns <Spinner /> in this route to include role="status" and an
aria-label set to a translated string (use the app's existing translation helper
such as t or formatMessage) so assistive tech can announce the loading state;
ensure you import/use the same translation hook used elsewhere in this file and
pass aria-label={t('loading')}/or equivalent to the Spinner component.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/features/organization/components/organization-form.tsx`:
- Around line 46-52: The regex validation message for the organization "name"
field is hardcoded; update the z.string().regex call inside the organization
form schema to use the i18n translation hook (t) instead of the literal English
string—replace the second argument of the regex in the name schema with
t('organization.companyNamePattern') (or an appropriate i18n key) and ensure the
translation key is added to locale files; keep the validation regex itself
unchanged and import/use the same translation function used elsewhere in this
component.
- Around line 155-157: Replace the hardcoded "Identifier:" prefix in the
description prop of the organization form with a translated string using the
app's i18n hook; locate the description expression that checks slugPreview in
organization-form.tsx (the description={ slugPreview ? `Identifier:
${slugPreview}` : undefined }), import/use the project's translation function
(e.g., useTranslation()/t or intl.formatMessage) and build the string with the
translation key (e.g., t('organization.identifier') or formatMessage({ id:
'organization.identifier' }) ) combined with slugPreview so the UI text is not
hardcoded.

In `@services/platform/app/features/organization/hooks/queries.ts`:
- Around line 29-42: The new hook useUserOrganizationsWithDetails uses
useConvexAuth and calls api.members.queries.getUserOrganizationsWithDetails,
which the tests currently don't mock; update the test mocks used by
app/components/__tests__/user-button.test.tsx to stub the useConvexAuth export
(returning isAuthenticated, isLoading/isAuthLoading booleans) and to mock the
convex query path (api.members.queries.getUserOrganizationsWithDetails) so that
useUserOrganizationsWithDetails can import and run without throwing; ensure the
mock returns a predictable data shape for organizations and toggles for
loading/auth states to cover the test scenarios.

In
`@services/platform/app/features/settings/organization/components/organization-settings.tsx`:
- Around line 343-346: The displayed role is rendered verbatim as org.role;
change this to use the translation hook by mapping the role enum to a
translation key and rendering tSettings(...) instead of org.role. In the
OrganizationSettings component replace the direct org.role usage (and any
similar places) with a translated lookup like tSettings('organization.roles.' +
org.role) (or use a small helper map from org.role -> translation key) and
provide a sensible fallback when org.role is missing/unknown so no raw enum
leaks to the UI. Ensure you call tSettings (the existing translation function
used in this file) rather than hardcoding text.

In `@services/platform/app/routes/dashboard/`$id.tsx:
- Around line 59-89: The effect currently returns early when
activeOrganizationId is falsy, so a null/unset session org never gets synced to
the URL org; change the guards in the useEffect that references
activeOrganizationId/orgSyncRef/organizationId/memberContext?.status so it does
NOT return when activeOrganizationId is null/undefined but only when
activeOrganizationId === organizationId (keep the memberContext status guard and
the orgSyncRef duplicate-run guard), so the effect will call
authClient.organization.setActive({ organizationId }), invalidate the
['auth','session'] query via queryClient.invalidateQueries, and call
recordOrgSwitch when activeOrganizationId is unset; apply the same change to the
second identical block (the one around lines 109–117) so both places sync a null
activeOrganizationId to organizationId.

In `@services/platform/app/routes/dashboard/create-organization.tsx`:
- Around line 25-41: The form can flash because navigation happens in useEffect
while the component already returns <OrganizationForm />; to fix, keep rendering
the loading placeholder until the redirect completes by treating unauthenticated
state as part of the loading condition: update the render conditional so that if
isAuthLoading OR isOrgsLoading OR !isAuthenticated you return the
<FullPageCenter><Spinner/></FullPageCenter> fallback (while preserving the
existing useEffect that calls navigate), ensuring OrganizationForm is only
returned when isAuthenticated is true and both loading flags are false.

In `@services/platform/app/routes/dashboard/index.tsx`:
- Around line 62-109: The current logic awaits authClient.getSession() outside
the try/catch so if getSession() throws the route stays locked
(resolvedRef.current remains true); update the flow so the session lookup is
handled inside the existing try block (or add a dedicated try/catch around
authClient.getSession()) and ensure resolvedRef.current is set to false in any
failure path. Specifically, modify the async IIFE to include
authClient.getSession() within the try that also calls
authClient.organization.setActive, or add a catch that sets resolvedRef.current
= false when authClient.getSession() rejects; keep references to resolvedRef,
authClient.getSession, authClient.organization.setActive, recordOrgSwitch and
navigate intact.

In `@services/platform/app/routes/dashboard/switching.tsx`:
- Around line 71-104: The current org-switch IIFE masks failures: if
authClient.organization.setActive or the session refresh fails (inside the
try/catch around setActive/queryClient.invalidateQueries/refetchQueries) the
code only logs and then continues to navigate via router.history.push or
navigate, causing an active-org mismatch; update the logic in the anonymous
async function so that upon any failure of authClient.organization.setActive,
queryClient.invalidateQueries/refetchQueries, or recordOrgSwitch you return
early (do not reach router.history.push or navigate), and instead surface an
error state/toast (e.g., call the existing toast/error handler) so the user is
not navigated to the new org when the switch did not succeed; keep references to
authClient.organization.setActive, queryClient.invalidateQueries/refetchQueries,
recordOrgSwitch, router.history.push and navigate so reviewers can find the
change.
- Around line 93-96: The code calls
router.history.push(`/dashboard/${targetOrgId}/${subpath}`, { replace: true })
in switching.tsx which incorrectly places { replace: true } into history state
instead of replacing the entry; change this to call
router.history.replace(`/dashboard/${targetOrgId}/${subpath}`) when you want to
replace the current history entry (keep the same route string and any existing
state handling but use replace instead of push). Ensure the logic around the
conditional that checks subpath (the block containing router.history.push) is
updated to call router.history.replace(...) so the back button no longer returns
to /dashboard/switching.

In
`@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`:
- Line 16: The failing tests are due to the new resolveOrgSlug execution path
requiring an API mock shape that includes a components field; update the test
setup for vi.mock('../../../_generated/api', ...) (or alternatively add a
dedicated mock for resolveOrgSlug) so the mocked module returns the new shape
expected by create_bound_workflow_tool.ts — specifically include a components
object (with whatever nested stubs the code reads) or stub resolveOrgSlug to
return a stable slug; update the mock in the test file where
vi.mock('../../../_generated/api', ...) is declared to mirror the new structure
so the branch around the resolveOrgSlug call (lines ~202-205) can run in tests.

In `@services/platform/convex/auth.ts`:
- Around line 520-539: The current afterCreateOrganization handler
(afterCreateOrganization) swallows errors from runCtx.scheduler.runAfter
scheduling of internal.organizations.scaffold.scaffoldNewOrganization; change
the catch block to persist a recoverable signal (e.g., call an organization
update/mutation such as internal.organizations.update or insert a durable retry
record via an existing scheduler/enqueue API) that marks the org (slug) as
pendingScaffold or creates a scaffoldRetry row with slug and error details so a
sweeper can retry; include the error message when saving the record and return
without throwing so org creation still succeeds but scaffold failures are
durable and discoverable.

In `@services/platform/convex/members/queries.ts`:
- Around line 267-285: The org enrichment currently runs Promise.all over orgs
and lets any rejected ctx.runQuery (components.betterAuth.adapter.findOne) abort
the whole operation; wrap the per-org async mapper in a try/catch so each lookup
failure is isolated: inside the map for orgs (the async function building
enriched), catch errors from ctx.runQuery and treat the result as undefined
(i.e., set record = undefined) so name falls back to o.organizationId and slug
to undefined; optionally emit a debug/warn log with the caught error to aid
debugging but do not rethrow so other orgs still resolve.

In `@services/platform/convex/organizations/scaffold.ts`:
- Around line 186-205: The scaffold loop writes to domain.resolve(args.orgSlug)
(targetDir) without ensuring it stays inside the org examples tree; guard the
write path by calling verifyPathWithinBase(targetDir, <base>) before any file
operations (the same protection used by cleanupOrgFilesystem). If
verifyPathWithinBase returns false, log an error/warning referencing domain.name
and args.orgSlug and skip the domain (do not call copyTree). Ensure you use the
same base/path semantics as cleanupOrgFilesystem and apply this check
immediately after computing targetDir and before dirHasFiles or copyTree.

In `@services/platform/convex/providers/file_actions.ts`:
- Around line 97-103: The catch around readdir(dir) in file_actions.ts is
currently mapping every filesystem error to NoProviderAvailableError with reason
'no_providers'; change it to inspect the caught error (e.g., err.code) and if
err.code === 'ENOENT' throw NoProviderAvailableError(FRIENDLY_NO_PROVIDER,
'no_providers', [`Provider directory missing: ${dir}`]), otherwise throw
NoProviderAvailableError(FRIENDLY_NO_PROVIDER, 'load_failed', [`Failed to read
provider directory ${dir}: ${err.message || err}`]) so permission/I/O errors
preserve the original error details; reference the existing readdir call, the
entries variable, and the NoProviderAvailableError/FRIENDLY_NO_PROVIDER symbols
when making the change.
- Around line 89-90: FRIENDLY_NO_PROVIDER currently contains hardcoded English
UI text; change it to a stable reason/i18n key (e.g. 'error.missing_provider' or
'missing_provider') instead of user-facing copy, keep the constant name
FRIENDLY_NO_PROVIDER, and update callers to perform translation/localization
(use the app's translation hook or function) before sending to the chat UI so UI
text is localized per locale.

In `@services/platform/convex/threads/list_threads.ts`:
- Around line 45-58: The organization filter in list_threads.ts currently uses
q.eq(q.field('organizationId'), args.organizationId) which excludes legacy
threads missing organizationId; update the predicate so it matches either the
provided args.organizationId OR rows where q.field('organizationId') is
null/absent (e.g., wrap the existing eq in a q.or with a check for null/missing)
when building expr (the variable holding the combined predicate) so legacy rows
remain visible while filtering by organizationId when present.

In `@services/platform/convex/threads/queries.ts`:
- Around line 15-19: The public thread query arg schema currently makes
organizationId optional which allows callers to omit it and fall back to
unscoped helpers; update the args object used by the public thread queries so
organizationId is required (change v.optional(v.string()) to v.string()) or
alternatively remove the arg and derive organizationId server-side from the
active session, and apply the same change to the other query arg blocks
referenced (the args definitions at the other locations noted).

In `@services/platform/convex/users/get_last_active_org.ts`:
- Around line 21-24: The auth lookup in get_last_active_org.ts currently lets
authComponent.getAuthUser(ctx) exceptions propagate; wrap the call to
authComponent.getAuthUser(ctx) in a try/catch (or handle promise rejection) and
treat any thrown error as an unauthenticated state by returning null/empty data
instead of rethrowing; update the authUser check in the getLastActiveOrg handler
so that on catch it behaves the same as when authUser is falsy (return null) to
match other Convex queries' behavior.

In
`@services/platform/convex/workflow_engine/helpers/scheduler/scan_and_trigger.ts`:
- Around line 79-87: The code calls resolveOrgSlug(ctx, organizationId) for
every workflow iteration which repeats I/O when several schedules share the same
organization; modify scan_and_trigger.ts to cache orgSlug lookups for the
duration of the scan run (e.g., use a Map keyed by organizationId) and, before
calling resolveOrgSlug, check the cache and reuse the stored orgSlug; update the
call site that passes orgSlug into
internal.workflow_engine.helpers.engine.start_workflow_from_file.startWorkflowFromFile
to use the cached value.

In `@services/platform/messages/de.json`:
- Line 640: The JSON value for the key deleteDialogDescription uses a German
opening quote „ but a mismatched ASCII closing quote ("), so update the string
for deleteDialogDescription to use matching German quotation marks around {name}
(i.e., change „{name}" to „{name}“), ensuring the rest of the text remains
unchanged.

In `@services/platform/messages/fr.json`:
- Around line 633-634: Update the French locale entries for
yourOrganizationsTitle and yourOrganizationsDescription to match the informal
tone used elsewhere (tu/ton/tes); replace "Vos organisations" with "Tes
organisations" and replace "Toutes les organisations auxquelles vous appartenez.
Changez d'organisation ou créez-en une nouvelle." with "Toutes les organisations
auxquelles tu appartiens. Change d'organisation ou crée-en une nouvelle." so the
org switcher copy is consistent with the rest of fr.json.

---

Outside diff comments:
In `@services/platform/app/routes/dashboard/`$id/settings/integrations.tsx:
- Around line 118-133: ensuredRef currently is a global Set across orgs and is
updated before calling installFn, causing other orgs to skip identical slugs and
preventing retries on failure; change ensuredRef to be per-organization (e.g.,
useRef(new Map<string, Set<string>>()) or prefix keys with orgSlug like
`${orgSlug}:${slug}`) and do NOT add the slug to the set until installFn
successfully completes (await the promise and only then add to the org-scoped
set); additionally, clear or reset the org-specific entry when orgSlug changes
to avoid stale state and ensure retries after failures (update the useEffect
logic that iterates fileIntegrations and where ensuredRef is read/updated).

In
`@services/platform/app/routes/dashboard/`$id/settings/providers/$providerName.tsx:
- Around line 836-865: Replace the hardcoded label strings on the two Input
components (the ones bound to form.inputCostPerMillion and
form.outputCostPerMillion and updated via setForm) with translated strings using
the app's translation hook/function (e.g., call t('...') or i18n.t('...'));
update both the label and any user-facing placeholders ("Input cost (USD / 1M
tokens)", "Output cost (USD / 1M tokens)", and placeholder examples) to use the
appropriate translation keys (create keys such as "providers.inputCostLabel" and
"providers.outputCostLabel" if missing) so the Input components receive labels
and placeholders from t(...) instead of hardcoded text.
- Around line 648-649: Replace the hardcoded header text "Cost / 1M tokens" in
the TableHead element with a translation lookup (i18n) so the label uses your
translation keys; update the TableHead (the element with className "w-[140px]
text-right") to call the app's i18n helper (e.g.,
t('dashboard.providers.costPer_million_tokens') or a matching key used
elsewhere) or use <Trans> as appropriate and add the new key to the translations
files so the label is localized.

In `@services/platform/app/routes/dashboard/index.tsx`:
- Around line 147-149: The loading Spinner rendered inside FullPageCenter lacks
an accessible label; update the JSX that returns <Spinner /> in this route to
include role="status" and an aria-label set to a translated string (use the
app's existing translation helper such as t or formatMessage) so assistive tech
can announce the loading state; ensure you import/use the same translation hook
used elsewhere in this file and pass aria-label={t('loading')}/or equivalent to
the Spinner component.

In `@services/platform/convex/agent_tools/files/helpers/analyze_image.ts`:
- Around line 187-196: The cache key used in analyzeImageCached (function
analyzeImageCached) omits orgSlug causing cross-tenant collisions; update the
imageAnalysisCache.fetch call to include params.orgSlug (e.g., add orgSlug:
params.orgSlug to the key object) so cached results are scoped per organization
(matching how analyzeImage resolves org-specific providers/models).

In `@services/platform/convex/integrations/test_connection.ts`:
- Around line 244-252: The calls to resolveOrgSlug and
internal.integrations.load_integration.loadIntegration happen before the try
block so any exception escapes the standardized failure path and skips the
credential error-status update; move the await resolveOrgSlug(ctx,
credential.organizationId) and the
ctx.runAction(internal.integrations.load_integration.loadIntegration, {...})
invocation inside the existing try (or wrap them in their own try/catch that
delegates to the same failure handler) so that any errors are caught and the
function returns the canonical { success: false } response and executes the
credential error-status update logic for the same error-handling flow.

In `@services/platform/convex/lib/agent_chat/start_agent_chat.ts`:
- Around line 170-173: After loading the thread via
ctx.runQuery(components.agent.threads.getThread) (the local variable thread),
immediately validate that the caller-supplied organizationId matches
thread.organizationId and abort (throw or return an authorization error) if they
differ; perform this check before any downstream calls that save/patch/schedule
work (including the internal action that receives organizationId) so no
cross-organization writes occur. Ensure the check is placed in
start_agent_chat.ts right after the getThread call and before any further
operations that forward organizationId.

In `@services/platform/convex/lib/summarization/internal_actions.ts`:
- Around line 11-27: The handler currently treats organizationId as optional
which allows silent fallback to a global provider; change the input schema to
require organizationId (replace v.optional(v.string()) with v.string()) and
update the handler in the same action to validate that args.organizationId is
present before calling resolveOrgSlug and resolveLanguageModelWithFallback (or
throw a clear error), so resolveOrgSlug and resolveLanguageModelWithFallback are
always invoked with a tenant-scoped orgSlug derived from the provided
organizationId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d244152f-2054-47cc-9fff-2be28ae9aabc

📥 Commits

Reviewing files that changed from the base of the PR and between a6cda60 and d69512d.

⛔ Files ignored due to path filters (2)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
  • services/platform/convex/betterAuth/_generated/component.ts is excluded by !**/_generated/**
📒 Files selected for processing (68)
  • services/platform/app/components/user-button.tsx
  • services/platform/app/features/chat/components/chat-history-sidebar.tsx
  • services/platform/app/features/chat/components/chat-search-dialog.tsx
  • services/platform/app/features/chat/hooks/queries.ts
  • services/platform/app/features/chat/utils/sanitize-chat-error.ts
  • services/platform/app/features/organization/components/organization-form.tsx
  • services/platform/app/features/organization/hooks/queries.ts
  • services/platform/app/features/settings/organization/components/organization-settings.tsx
  • services/platform/app/features/settings/providers/components/providers-table.tsx
  • services/platform/app/routeTree.gen.ts
  • services/platform/app/routes/dashboard/$id.tsx
  • services/platform/app/routes/dashboard/$id/settings/integrations.tsx
  • services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
  • services/platform/app/routes/dashboard/create-organization.tsx
  • services/platform/app/routes/dashboard/index.tsx
  • services/platform/app/routes/dashboard/switching.tsx
  • services/platform/convex/agent_tools/files/helpers/analyze_image.ts
  • services/platform/convex/agent_tools/files/image_tool.ts
  • services/platform/convex/agent_tools/human_input/actions.ts
  • services/platform/convex/agent_tools/integrations/fetch_operations_summary.ts
  • services/platform/convex/agent_tools/integrations/integration_introspect_tool.ts
  • services/platform/convex/agent_tools/integrations/internal_actions.ts
  • services/platform/convex/agent_tools/integrations/trigger_completion_action.ts
  • services/platform/convex/agent_tools/location/actions.ts
  • services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts
  • services/platform/convex/agent_tools/workflows/helpers/read_all_workflows.ts
  • services/platform/convex/agent_tools/workflows/helpers/read_workflow_structure.ts
  • services/platform/convex/agent_tools/workflows/internal_actions.ts
  • services/platform/convex/agent_tools/workflows/run_workflow_tool.ts
  • services/platform/convex/agent_tools/workflows/save_workflow_definition_tool.ts
  • services/platform/convex/agent_tools/workflows/trigger_completion_action.ts
  • services/platform/convex/agent_tools/workflows/update_workflow_step_tool.ts
  • services/platform/convex/agent_tools/workflows/workflow_read_tool.ts
  • services/platform/convex/auth.ts
  • services/platform/convex/betterAuth/generated_schema.ts
  • services/platform/convex/conversations/internal_actions.ts
  • services/platform/convex/governance/internal_queries.ts
  • services/platform/convex/integrations/generate_oauth2_auth_url.ts
  • services/platform/convex/integrations/oauth2_token_exchange.ts
  • services/platform/convex/integrations/test_connection.ts
  • services/platform/convex/lib/agent_chat/internal_actions.ts
  • services/platform/convex/lib/agent_chat/start_agent_chat.ts
  • services/platform/convex/lib/summarization/internal_actions.ts
  • services/platform/convex/members/queries.ts
  • services/platform/convex/openai_compat/internal_actions.ts
  • services/platform/convex/organizations/delete_cleanup.ts
  • services/platform/convex/organizations/record_org_switch.ts
  • services/platform/convex/organizations/resolve_org_slug.ts
  • services/platform/convex/organizations/scaffold.ts
  • services/platform/convex/providers/failover.ts
  • services/platform/convex/providers/file_actions.ts
  • services/platform/convex/providers/resolve_model.ts
  • services/platform/convex/threads/generate_thread_title.ts
  • services/platform/convex/threads/list_archived_threads.ts
  • services/platform/convex/threads/list_threads.ts
  • services/platform/convex/threads/queries.ts
  • services/platform/convex/users/get_last_active_org.ts
  • services/platform/convex/wf_executions/actions.ts
  • services/platform/convex/workflow_engine/action_defs/integration/integration_action.ts
  • services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts
  • services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
  • services/platform/convex/workflow_engine/helpers/scheduler/scan_and_trigger.ts
  • services/platform/convex/workflows/triggers/api_http.ts
  • services/platform/convex/workflows/triggers/http_actions.ts
  • services/platform/convex/workflows/triggers/process_event.ts
  • services/platform/messages/de.json
  • services/platform/messages/en.json
  • services/platform/messages/fr.json

Comment on lines +46 to +52
name: z
.string()
.min(1, t('organization.companyNameRequired'))
.regex(
/^[A-Za-z0-9][A-Za-z0-9 _-]*$/,
'Use letters, digits, spaces, hyphens, and underscores only, starting with a letter or digit.',
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded validation message — use i18n.

The regex validation message is hardcoded in English. Per coding guidelines, all user-facing text must use translation hooks.

🌐 Proposed fix
         name: z
           .string()
           .min(1, t('organization.companyNameRequired'))
           .regex(
             /^[A-Za-z0-9][A-Za-z0-9 _-]*$/,
-            'Use letters, digits, spaces, hyphens, and underscores only, starting with a letter or digit.',
+            t('organization.nameFormatError'),
           ),

As per coding guidelines: "Do NOT hardcode text, use the translation hooks/functions instead for user-facing UI".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name: z
.string()
.min(1, t('organization.companyNameRequired'))
.regex(
/^[A-Za-z0-9][A-Za-z0-9 _-]*$/,
'Use letters, digits, spaces, hyphens, and underscores only, starting with a letter or digit.',
),
name: z
.string()
.min(1, t('organization.companyNameRequired'))
.regex(
/^[A-Za-z0-9][A-Za-z0-9 _-]*$/,
t('organization.nameFormatError'),
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/organization/components/organization-form.tsx`
around lines 46 - 52, The regex validation message for the organization "name"
field is hardcoded; update the z.string().regex call inside the organization
form schema to use the i18n translation hook (t) instead of the literal English
string—replace the second argument of the regex in the name schema with
t('organization.companyNamePattern') (or an appropriate i18n key) and ensure the
translation key is added to locale files; keep the validation regex itself
unchanged and import/use the same translation function used elsewhere in this
component.

Comment on lines +155 to +157
description={
slugPreview ? `Identifier: ${slugPreview}` : undefined
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded label — use i18n.

The "Identifier:" prefix should use a translation key.

🌐 Proposed fix
               description={
-                slugPreview ? `Identifier: ${slugPreview}` : undefined
+                slugPreview ? t('organization.identifierPreview', { slug: slugPreview }) : undefined
               }

As per coding guidelines: "Do NOT hardcode text, use the translation hooks/functions instead for user-facing UI".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
description={
slugPreview ? `Identifier: ${slugPreview}` : undefined
}
description={
slugPreview ? t('organization.identifierPreview', { slug: slugPreview }) : undefined
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/organization/components/organization-form.tsx`
around lines 155 - 157, Replace the hardcoded "Identifier:" prefix in the
description prop of the organization form with a translated string using the
app's i18n hook; locate the description expression that checks slugPreview in
organization-form.tsx (the description={ slugPreview ? `Identifier:
${slugPreview}` : undefined }), import/use the project's translation function
(e.g., useTranslation()/t or intl.formatMessage) and build the string with the
translation key (e.g., t('organization.identifier') or formatMessage({ id:
'organization.identifier' }) ) combined with slugPreview so the UI text is not
hardcoded.

Comment on lines +29 to +42
export function useUserOrganizationsWithDetails() {
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();

const { data, isLoading } = useConvexQuery(
api.members.queries.getUserOrganizationsWithDetails,
);

return {
organizations: data,
isLoading: isAuthLoading || isLoading,
isAuthenticated,
isAuthLoading,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update the dependent test mocks with this new hook.

This addition is currently breaking app/components/__tests__/user-button.test.tsx: the existing mocks don't provide the useConvexAuth path exercised by useUserOrganizationsWithDetails(), so the test suite crashes before assertions run. Please update those mocks in the same change so the branch goes green.

🧰 Tools
🪛 GitHub Check: Test UI

[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders without crashing when team filter context is unavailable
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > does not render tooltip wrapper when label is provided
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders dropdown trigger with organizationId from route params
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders dropdown trigger when auth is loading
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders user icon
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > shows custom tooltip text
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > shows tooltip text
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders with a label for mobile navigation
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22


[failure] 30-30: app/components/tests/user-button.test.tsx > UserButton > renders without crashing
Error: [vitest] No "useConvexAuth" export is defined on the "@/app/hooks/use-convex-auth" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "importOriginal" helper inside:

vi.mock(import("@/app/hooks/use-convex-auth"), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
}
})

❯ useUserOrganizationsWithDetails app/features/organization/hooks/queries.ts:30:57
❯ UserButton app/components/user-button.tsx:74:39
❯ Object.react_stack_bottom_frame ../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20
❯ renderWithHooks ../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22
❯ updateFunctionComponent ../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19
❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18
❯ runWithFiberInDEV ../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13
❯ performUnitOfWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/organization/hooks/queries.ts` around lines 29
- 42, The new hook useUserOrganizationsWithDetails uses useConvexAuth and calls
api.members.queries.getUserOrganizationsWithDetails, which the tests currently
don't mock; update the test mocks used by
app/components/__tests__/user-button.test.tsx to stub the useConvexAuth export
(returning isAuthenticated, isLoading/isAuthLoading booleans) and to mock the
convex query path (api.members.queries.getUserOrganizationsWithDetails) so that
useUserOrganizationsWithDetails can import and run without throwing; ensure the
mock returns a predictable data shape for organizations and toggles for
loading/auth states to cover the test scenarios.

Comment on lines +343 to +346
<span className="text-muted-foreground text-xs">
{org.slug ? `@${org.slug} · ` : ''}
{tSettings('organization.roleLabel')}: {org.role}
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Translate the rendered role value, not just its prefix.

Line 345 still renders org.role verbatim, so this list will show English enum values in non-English locales. Map the role value to a translation key before rendering.

As per coding guidelines, "Do NOT hardcode text, use the translation hooks/functions instead for user-facing UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/settings/organization/components/organization-settings.tsx`
around lines 343 - 346, The displayed role is rendered verbatim as org.role;
change this to use the translation hook by mapping the role enum to a
translation key and rendering tSettings(...) instead of org.role. In the
OrganizationSettings component replace the direct org.role usage (and any
similar places) with a translated lookup like tSettings('organization.roles.' +
org.role) (or use a small helper map from org.role -> translation key) and
provide a sensible fallback when org.role is missing/unknown so no raw enum
leaks to the UI. Ensure you call tSettings (the existing translation function
used in this file) rather than hardcoding text.

Comment on lines +59 to +89
const { data: session } = useQuery({
queryKey: ['auth', 'session'],
queryFn: () => authClient.getSession(),
staleTime: 5 * 60 * 1000,
});
const activeOrganizationId = session?.data?.session?.activeOrganizationId;
const orgSyncRef = useRef<string | null>(null);
useEffect(() => {
if (memberContext?.status !== 'ok') return;
if (!activeOrganizationId) return;
if (activeOrganizationId === organizationId) return;
// Prevent re-running for the same mismatch after a completed sync.
if (orgSyncRef.current === organizationId) return;
orgSyncRef.current = organizationId;
void (async () => {
try {
await authClient.organization.setActive({ organizationId });
await queryClient.invalidateQueries({ queryKey: ['auth', 'session'] });
await recordOrgSwitch({ organizationId });
} catch (err) {
console.warn('Failed to sync active organization:', err);
orgSyncRef.current = null;
}
})();
}, [
activeOrganizationId,
organizationId,
memberContext?.status,
queryClient,
recordOrgSwitch,
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

null active org never gets synced.

The early if (!activeOrganizationId) return; treats an unset session org as a no-op, but this route is supposed to align the Better Auth session with the URL. Deep links after login/logout will therefore render the target org while leaving session.activeOrganizationId unset.

Suggested fix
-  const { data: session } = useQuery({
+  const { data: session, isPending: isSessionLoading } = useQuery({
     queryKey: ['auth', 'session'],
     queryFn: () => authClient.getSession(),
     staleTime: 5 * 60 * 1000,
   });
   const activeOrganizationId = session?.data?.session?.activeOrganizationId;
   const orgSyncRef = useRef<string | null>(null);
   useEffect(() => {
+    if (isSessionLoading) return;
     if (memberContext?.status !== 'ok') return;
-    if (!activeOrganizationId) return;
     if (activeOrganizationId === organizationId) return;
     // Prevent re-running for the same mismatch after a completed sync.
     if (orgSyncRef.current === organizationId) return;
@@
   }, [
+    isSessionLoading,
     activeOrganizationId,
     organizationId,
     memberContext?.status,
     queryClient,
     recordOrgSwitch,
   ]);
@@
   const isSwitching =
     !isLoading &&
+    !isSessionLoading &&
     memberContext?.status === 'ok' &&
-    !!activeOrganizationId &&
     activeOrganizationId !== organizationId;

Also applies to: 109-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/routes/dashboard/`$id.tsx around lines 59 - 89, The
effect currently returns early when activeOrganizationId is falsy, so a
null/unset session org never gets synced to the URL org; change the guards in
the useEffect that references
activeOrganizationId/orgSyncRef/organizationId/memberContext?.status so it does
NOT return when activeOrganizationId is null/undefined but only when
activeOrganizationId === organizationId (keep the memberContext status guard and
the orgSyncRef duplicate-run guard), so the effect will call
authClient.organization.setActive({ organizationId }), invalidate the
['auth','session'] query via queryClient.invalidateQueries, and call
recordOrgSwitch when activeOrganizationId is unset; apply the same change to the
second identical block (the one around lines 109–117) so both places sync a null
activeOrganizationId to organizationId.

Comment on lines +21 to +24
const authUser = await authComponent.getAuthUser(ctx);
if (!authUser) {
return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Swallow auth lookup failures in this query.

Other Convex queries in this repo treat authComponent.getAuthUser(ctx) failures as an unauthenticated state and return null/empty data. Letting that exception escape here turns a transient auth lookup failure into a hard dashboard error.

Suggested fix
   handler: async (ctx) => {
-    const authUser = await authComponent.getAuthUser(ctx);
+    let authUser = null;
+    try {
+      authUser = await authComponent.getAuthUser(ctx);
+    } catch {}
+
     if (!authUser) {
       return null;
     }

Based on learnings: "In Convex query handlers, failures from authComponent.getAuthUser(ctx) are intentionally swallowed (empty catch) and treated as an expected unauthenticated state. Return empty data or null instead."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const authUser = await authComponent.getAuthUser(ctx);
if (!authUser) {
return null;
}
let authUser = null;
try {
authUser = await authComponent.getAuthUser(ctx);
} catch {}
if (!authUser) {
return null;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/users/get_last_active_org.ts` around lines 21 - 24,
The auth lookup in get_last_active_org.ts currently lets
authComponent.getAuthUser(ctx) exceptions propagate; wrap the call to
authComponent.getAuthUser(ctx) in a try/catch (or handle promise rejection) and
treat any thrown error as an unauthenticated state by returning null/empty data
instead of rethrowing; update the authUser check in the getLastActiveOrg handler
so that on catch it behaves the same as when authUser is falsy (return null) to
match other Convex queries' behavior.

Comment on lines +103 to +115
const costCents = estimateCostCents(model, inputTokens, outputTokens);
try {
await ctx.runMutation(
internal.governance.internal_mutations.incrementUsageLedger,
{
organizationId,
userId,
inputTokens,
outputTokens,
costEstimateCents: costCents,
timestamp: Date.now(),
},
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The budget gate is still racy under parallel workflow runs.

Lines 159-165 do a read-only budget check, and Lines 105-115 record usage later in a separate mutation after the provider call. Multiple executions can all pass the check before any of them increments the ledger, so the org can exceed its cap under load. This needs an atomic check-and-reserve mutation before the LLM call, with a finalize/update step after completion.

Also applies to: 156-172

Comment on lines +79 to 87
const orgSlug = await resolveOrgSlug(ctx, organizationId);

await ctx.runAction(
internal.workflow_engine.helpers.engine.start_workflow_from_file
.startWorkflowFromFile,
{
organizationId,
orgSlug: DEFAULT_ORG_SLUG,
orgSlug,
workflowSlug,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Cache org slug lookups per scan run to avoid repeated I/O.

Line 79 resolves orgSlug on every workflow iteration. If multiple schedules belong to the same org, this repeats the same lookup unnecessarily.

♻️ Suggested refactor
 export async function scanAndTrigger(ctx: ActionCtx): Promise<void> {
   try {
+    const orgSlugCache = new Map<string, string>();
     debugLog('Starting scheduled workflow scan...');
@@
-          const orgSlug = await resolveOrgSlug(ctx, organizationId);
+          const orgSlug =
+            orgSlugCache.get(organizationId) ??
+            (await resolveOrgSlug(ctx, organizationId));
+          orgSlugCache.set(organizationId, orgSlug);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/convex/workflow_engine/helpers/scheduler/scan_and_trigger.ts`
around lines 79 - 87, The code calls resolveOrgSlug(ctx, organizationId) for
every workflow iteration which repeats I/O when several schedules share the same
organization; modify scan_and_trigger.ts to cache orgSlug lookups for the
duration of the scan run (e.g., use a Map keyed by organizationId) and, before
calling resolveOrgSlug, check the cache and reuse the stored orgSlug; update the
call site that passes orgSlug into
internal.workflow_engine.helpers.engine.start_workflow_from_file.startWorkflowFromFile
to use the cached value.

"roleLabel": "Rolle",
"deleteAriaLabel": "{name} löschen",
"deleteDialogTitle": "Organisation löschen",
"deleteDialogDescription": "„{name}\" löschen? Damit werden Agents, Workflows, Anbieter und Integrationen entfernt. Mitglieder verlieren den Zugriff. Dies kann nicht rückgängig gemacht werden.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix mismatched quotation marks in the delete dialog text.

Line [640] opens with but closes with ", which renders inconsistent UI copy in German.

💡 Suggested fix
-      "deleteDialogDescription": "„{name}\" löschen? Damit werden Agents, Workflows, Anbieter und Integrationen entfernt. Mitglieder verlieren den Zugriff. Dies kann nicht rückgängig gemacht werden.",
+      "deleteDialogDescription": "„{name}“ löschen? Damit werden Agents, Workflows, Anbieter und Integrationen entfernt. Mitglieder verlieren den Zugriff. Dies kann nicht rückgängig gemacht werden.",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/messages/de.json` at line 640, The JSON value for the key
deleteDialogDescription uses a German opening quote „ but a mismatched ASCII
closing quote ("), so update the string for deleteDialogDescription to use
matching German quotation marks around {name} (i.e., change „{name}" to
„{name}“), ensuring the rest of the text remains unchanged.

Comment on lines +633 to +634
"yourOrganizationsTitle": "Vos organisations",
"yourOrganizationsDescription": "Toutes les organisations auxquelles vous appartenez. Changez d'organisation ou créez-en une nouvelle.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the French locale on the existing informal tone.

These two strings switch to vos/vous, while the rest of fr.json consistently uses tu/ton/tes. That makes the org switcher copy feel out of place.

Suggested wording:

  • Tes organisations
  • Toutes les organisations auxquelles tu appartiens. Change d'organisation ou crée-en une nouvelle.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/messages/fr.json` around lines 633 - 634, Update the French
locale entries for yourOrganizationsTitle and yourOrganizationsDescription to
match the informal tone used elsewhere (tu/ton/tes); replace "Vos organisations"
with "Tes organisations" and replace "Toutes les organisations auxquelles vous
appartenez. Changez d'organisation ou créez-en une nouvelle." with "Toutes les
organisations auxquelles tu appartiens. Change d'organisation ou crée-en une
nouvelle." so the org switcher copy is consistent with the rest of fr.json.

@larryro larryro merged commit e5aacf5 into main Apr 19, 2026
26 checks passed
@larryro larryro deleted the feat/multi-tenancy-isolation branch April 19, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant