Skip to content

fix: make the active org visible and org orphaning observable#390

Merged
duyet merged 3 commits into
mainfrom
fix/org-visibility-387-388
Jul 17, 2026
Merged

fix: make the active org visible and org orphaning observable#390
duyet merged 3 commits into
mainfrom
fix/org-visibility-387-388

Conversation

@duyet

@duyet duyet commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Fixes the two tractable parts of the empty-dashboard investigation.

Context: a production account showed 0 projects while D1 held 9. Root cause was #276 changing org-id derivation from a "default" sentinel to personal:\${clerkUserId} without a data migration, orphaning every existing project under clerk_org_id = 'default'. The org row has been re-pointed by hand; these changes stop it recurring silently.

#387 — sidebar org switcher

app-shell.tsx had no active-org indicator and no switcher. Since the org id drives every project-scoped read but was invisible and unselectable, a mismatch looked like an empty account with no diagnosis or recovery path.

Adds SidebarOrgScope, mirroring the existing SidebarProjectScope and reusing the existing useOrganizationsList hook (which already exposed setActive). Hand-rolled select rather than Clerk's prebuilt <OrganizationSwitcher> to match the design system — consistent with this repo having removed shadcn/Kumo in favour of its own tokens.

#388 — observable org orphaning

listProjects returned [] for a missing org row. That stays a 200 + empty list — org rows are created lazily by getOrCreateOrg, so a missing row is legitimate for an org that hasn't created its first project, and erroring would break the new-user empty state. Instead it now warns with the org id shape (clerk_org vs personal) so an orphaned tenant is distinguishable from a brand-new one.

Not included

#389 (org rows are derived, not authoritative) is the durable structural fix and needs a design decision between three mutually-exclusive options — left for discussion rather than guessed at.

Verification

  • bunx tsc --noEmit -p packages/api/tsconfig.json — clean
  • bunx biome check on both changed files — clean
  • bunx vitest run — 417 passed / 29 files
  • bun run build (dashboard) — 16 pages built

Co-Authored-By: Duyet Le me@duyet.net
Co-Authored-By: duyetbot bot@duyet.net

Summary by CodeRabbit

  • New Features

    • Added an organization selector to the desktop sidebar and mobile navigation.
    • Users can switch the active organization and automatically refresh project-scoped data.
    • The selector displays loading and switching states to provide clear feedback.
  • Bug Fixes

    • Improved handling when no organization is available by preserving an empty project list instead of failing.
    • Added user-facing error feedback if switching organizations cannot be completed.

duyet and others added 2 commits July 17, 2026 20:01
The sidebar had no active-org indicator and no switcher — the only org
surface was a settings link. But the org id is load-bearing for every
project-scoped read, so a mismatch presented as an empty account with no
way to see or correct it.

Adds SidebarOrgScope above the project scope, mirroring SidebarProjectScope
and reusing the existing useOrganizationsList hook. Switching orgs mints a
new session token, so reload once to let every project-scoped consumer
re-read under the new org.

Closes #387

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
listProjects returns [] for a missing org row, which is correct for an org
that has not created its first project (rows are created lazily by
getOrCreateOrg) but is also exactly how an orphaned tenant looks. The two
were indistinguishable from the response and neither was observable.

Keep the 200 + empty list, and warn with the org id shape so an orphaned
tenant can be told apart from a brand-new one.

Closes #388

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @duyet, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@duyet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 12b08e4d-3977-4d11-8872-910b80ef0901

📥 Commits

Reviewing files that changed from the base of the PR and between aad8b6d and 268b263.

📒 Files selected for processing (1)
  • packages/dashboard/src/components/app-shell.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/org-visibility-387-388

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces warning logs in the projects service to help diagnose orphaned tenants and adds a new SidebarOrgScope component to the dashboard's sidebar for switching between Clerk organizations. Feedback on the organization switcher suggests handling empty string values to allow switching back to a personal account, always rendering a "Personal Account" option in the dropdown, and ensuring both the organizations list and active organization state are fully loaded before rendering to prevent layout shifts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +282 to +292
const handleChange = async (orgId: string) => {
if (!setActive || orgId === activeOrg?.id) return;
setSwitching(true);
try {
await setActive({ organization: orgId });
window.location.reload();
} catch {
setSwitching(false);
toast.error("Could not switch organization. Please try again.");
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To allow users to switch back to their personal account/workspace (where activeOrg is null), we should handle the empty string value ("") by passing null to setActive.

  const handleChange = async (orgId: string) => {
    const targetOrgId = orgId === "" ? null : orgId;
    const currentOrgId = activeOrg?.id ?? null;
    if (!setActive || targetOrgId === currentOrgId) return;
    setSwitching(true);
    try {
      await setActive({ organization: targetOrgId });
      window.location.reload();
    } catch {
      setSwitching(false);
      toast.error("Could not switch organization. Please try again.");
    }
  };

Comment on lines +313 to +321
{/* A session with no active org still has to render a selected value,
otherwise the select silently shows the first org as if it were
active while the API is scoped to `personal:<userId>` (#387). */}
{!activeOrg && <option value="">Select organization…</option>}
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Currently, if activeOrg is defined, the "Select organization…" option is hidden, meaning there is no option in the dropdown to switch back to the Personal Account. We should always render a "Personal Account" option so users can switch back and forth between their organizations and their personal workspace.

          <option value="">Personal Account</option>
          {organizations.map((org) => (
            <option key={org.id} value={org.id}>
              {org.name}
            </option>
          ))}

Comment on lines +267 to +278
const { organizations, isLoaded, setActive } = useOrganizationsList();
const { organization: activeOrg } = useOrganization();
const [switching, setSwitching] = useState(false);

if (!isLoaded) {
return (
<div className="border-b border-edge-soft px-3 py-2.5" aria-live="polite">
<div className="h-9 animate-pulse rounded-[var(--radius)] bg-panel2" aria-hidden="true" />
<span className="sr-only">Loading organizations…</span>
</div>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

We should ensure that both the organizations list and the active organization state from Clerk are fully loaded before rendering the switcher. Otherwise, if useOrganizationsList loads first but useOrganization is still loading, activeOrg will temporarily be undefined, causing the dropdown to flash the unselected/loading state and trigger a layout shift.

Suggested change
const { organizations, isLoaded, setActive } = useOrganizationsList();
const { organization: activeOrg } = useOrganization();
const [switching, setSwitching] = useState(false);
if (!isLoaded) {
return (
<div className="border-b border-edge-soft px-3 py-2.5" aria-live="polite">
<div className="h-9 animate-pulse rounded-[var(--radius)] bg-panel2" aria-hidden="true" />
<span className="sr-only">Loading organizations…</span>
</div>
);
}
const { organizations, isLoaded: orgsLoaded, setActive } = useOrganizationsList();
const { organization: activeOrg, isLoaded: activeOrgLoaded } = useOrganization();
const [switching, setSwitching] = useState(false);
if (!orgsLoaded || !activeOrgLoaded) {
return (
<div className="border-b border-edge-soft px-3 py-2.5" aria-live="polite">
<div className="h-9 animate-pulse rounded-[var(--radius)] bg-panel2" aria-hidden="true" />
<span className="sr-only">Loading organizations…</span>
</div>
);
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/api/src/services/projects.ts`:
- Around line 243-248: Update the org_not_found logging in the project lookup
flow to avoid emitting the raw personal user ID in clerk_org_id when clerkOrgId
starts with "personal:". Preserve kind, and replace the personal identifier with
a safe redacted or pseudonymized correlation value while retaining the existing
Clerk organization value behavior.

In `@packages/dashboard/src/components/app-shell.tsx`:
- Around line 296-306: Make the organization selector IDs unique across the
desktop and mobile instances in the app shell. Update the relevant
selector/rendering logic around the sidebar organization scope, including the
instances near the labeled select elements, and ensure each label’s htmlFor
matches its corresponding select ID without leaving duplicate sidebar-org-scope
values.
🪄 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: 24743c13-4345-487f-af88-061235f963ce

📥 Commits

Reviewing files that changed from the base of the PR and between 935527c and aad8b6d.

📒 Files selected for processing (2)
  • packages/api/src/services/projects.ts
  • packages/dashboard/src/components/app-shell.tsx

Comment on lines +243 to +248
// apart. The value is a Clerk org id or `personal:<userId>`, not a secret.
console.warn(
JSON.stringify({
event: "org_not_found",
clerk_org_id: clerkOrgId,
kind: clerkOrgId.startsWith("personal:") ? "personal" : "clerk_org",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log the raw personal Clerk user ID.

For personal:<userId>, clerk_org_id places a persistent user identifier in operational logs. Being non-secret does not make it non-PII; redact or pseudonymize the identifier while retaining kind and a safe correlation value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/projects.ts` around lines 243 - 248, Update the
org_not_found logging in the project lookup flow to avoid emitting the raw
personal user ID in clerk_org_id when clerkOrgId starts with "personal:".
Preserve kind, and replace the personal identifier with a safe redacted or
pseudonymized correlation value while retaining the existing Clerk organization
value behavior.

Comment thread packages/dashboard/src/components/app-shell.tsx Outdated
The shell mounts SidebarOrgScope twice (desktop sidebar + mobile drawer),
so the literal id collided and both labels resolved to the first select,
leaving the mobile label unassociated. Derive the id with useId().

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
@duyet

duyet commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Addressed the CodeRabbit review:

Fixed — duplicate select id (268b263). Valid catch. SidebarOrgScope mounts twice (desktop sidebar + mobile drawer), so the literal sidebar-org-scope id collided and both labels resolved to the first select, leaving the mobile label unassociated. Now derived via useId().

Declined — redact the personal user id in the org_not_found log. The purpose of this log is to locate an orphaned tenant so its org row can be re-pointed, which is impossible without the identifier. kind alone tells you that a tenant is orphaned but not which, which is exactly the dead-end that made the original incident require a manual D1 dig to diagnose. The value is a pseudonymous Clerk id in Workers logs, not a credential, and it is already the same class of identifier the session carries. If we later want this tightened, it belongs with #389's decision on whether personal:* identities should exist at all.

Pre-existing, not touched: SidebarProjectScope has the identical duplicate-id defect (sidebar-project-scope rendered in both the desktop and mobile trees). Out of scope for this PR — flagging rather than silently widening the diff.

@duyet
duyet merged commit 5ff291a into main Jul 17, 2026
6 checks passed
@duyet
duyet deleted the fix/org-visibility-387-388 branch July 17, 2026 13:17
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