fix: make the active org visible and org orphaning observable#390
Conversation
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>
|
Warning Review limit reached
Next review available in: 48 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| 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."); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.");
}
};
| {/* 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> | ||
| ))} |
There was a problem hiding this comment.
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>
))}
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| 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> | |
| ); | |
| } |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
packages/api/src/services/projects.tspackages/dashboard/src/components/app-shell.tsx
| // 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", |
There was a problem hiding this comment.
🔒 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.
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>
|
Addressed the CodeRabbit review: Fixed — duplicate select id (268b263). Valid catch. 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. Pre-existing, not touched: |
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 topersonal:\${clerkUserId}without a data migration, orphaning every existing project underclerk_org_id = 'default'. The org row has been re-pointed by hand; these changes stop it recurring silently.#387 — sidebar org switcher
app-shell.tsxhad 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 existingSidebarProjectScopeand reusing the existinguseOrganizationsListhook (which already exposedsetActive). 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
listProjectsreturned[]for a missing org row. That stays a 200 + empty list — org rows are created lazily bygetOrCreateOrg, 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_orgvspersonal) 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— cleanbunx biome checkon both changed files — cleanbunx vitest run— 417 passed / 29 filesbun run build(dashboard) — 16 pages builtCo-Authored-By: Duyet Le me@duyet.net
Co-Authored-By: duyetbot bot@duyet.net
Summary by CodeRabbit
New Features
Bug Fixes