Skip to content

Add hosts settings page for managing host access#3768

Merged
saddlepaddle merged 8 commits into
mainfrom
host-access-settings-desi
Apr 26, 2026
Merged

Add hosts settings page for managing host access#3768
saddlepaddle merged 8 commits into
mainfrom
host-access-settings-desi

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Apr 26, 2026

Summary

  • Adds a Hosts entry to the Organization group in desktop settings, alongside Projects, Integrations, Billing, API Keys.
  • Each Hosts entry uses the same two-pane layout as Projects: sub-sidebar with name filter and online/offline grouping; detail page shows the host's members with owner-only add / remove / role-change controls.
  • New v2Host tRPC router (addMember, removeMember, setMemberRole) with owner-role gating and "must keep at least one owner" / "can't demote yourself" rails.
  • Clicking Projects or Hosts in the sidebar redirects to the first item (online host first, alphabetical) instead of an empty placeholder.
  • Adds a name filter to the existing ProjectsSettingsSidebar for parity.

Test plan

  • Open settings → Hosts visible under Organization group, even with zero hosts.
  • Click Hosts → redirects to first online host alphabetically; sub-sidebar lists Online above Offline; filter input narrows the list live.
  • As a host owner: invite an org member, change a role, remove a member.
  • As a non-owner: members table renders read-only; controls hidden/disabled.
  • Owner safety rails: can't remove self; can't demote sole owner.
  • Settings search "members" / "host" / "access" surfaces the new section with a count badge.
  • Existing flows unchanged: /settings/projects/$projectId (v1 + v2), /settings/security, /settings/account, /settings/git.
  • Projects sub-sidebar filter works on v1 + v2 projects.

🤖 Generated with Claude Code


Summary by cubic

Adds a Hosts settings page with a two-pane layout to manage per-host access, plus optimistic membership updates with server-confirmed toasts. The v2Host API now performs atomic, serialized writes with clear errors.

  • New Features

    • UI/Navigation: new Hosts section with sidebar (online/offline groups, name filter with aria-label) and detail view; Projects/Hosts routes auto-jump to the first item (hosts prefer online); clear empty/no-match states.
    • Host access: owners can add/remove members and change roles; self-removal/demotion is allowed unless it would leave zero owners; the host creator cannot be removed or demoted; optimistic updates with rollback and success toast after persistence.
    • API: v2Host (addMember, removeMember, setMemberRole) requires active org and owner-only; atomic writes with row locks and last-owner checks; protects the creator; returns txid; surfaces CONFLICT/NOT_FOUND; wired through optimistic v2UsersHosts.
  • Refactors

    • Moved membership success toasts into HostSettings via a small notify-on-persist helper.

Written for commit dc26e72. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Hosts section in Settings: hosts list with online/offline status, host detail pages (machine ID shown), quick sidebar navigation, and automatic selection of a host on open.
    • Host member management: add/remove members and assign roles via UI controls with immediate UI updates and success toasts.
    • Projects sidebar: live search/filter input and clearer empty vs filtered messaging.
  • Bug Fixes / Improvements

    • Improved settings search, navigation, and sidebar active-state detection for hosts and projects.

Adds a Hosts entry to the Organization group in settings, mirroring the
existing Projects two-pane layout: sub-sidebar lists the org's hosts,
detail page shows members with owner-only add/remove/role-change. New
v2Host tRPC router handles the membership mutations with owner-role
enforcement.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Hosts settings section: sidebar listing and index/detail routes, host detail UI for membership management (members table, add/remove/set-role), client optimistic collection sync to server, and new server-side v2Host tRPC mutations for membership operations.

Changes

Cohort / File(s) Summary
Settings types & sidebar
apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx, apps/desktop/src/renderer/stores/settings-state.ts
Extended settings routes/types to include /settings/hosts, added Hosts & Projects sidebar entries, and switched sidebar active detection to fuzzy matching.
Sidebar orchestration
apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx, apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx
Removed ProjectsSettings component usage; simplified search/match-count computation to depend on normalized query (no separate isSearchActive flag).
Hosts routing & layout
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx, apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx, apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/page.tsx, apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx
Added hosts layout, hosts index route (auto-navigates to initial host), and host-detail page; updated parent settings layout to include hosts section and skip search-redirects when in hosts.
Hosts sidebar component
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/...
New filterable HostsSettingsSidebar: live-queries hosts, filters by name, sorts, partitions online/offline, renders HostLinks and selected styling.
Host detail UI
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/*, .../HostSettings/index.ts
New HostSettings component: live queries for host, members, users; computes members/candidates, renders HostHeader, MembersTable, conditional AddMemberDropdown, and invokes optimistic collection actions for membership mutations.
Member UI building blocks
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/{HostHeader,MembersTable,MemberRow,AddMemberDropdown}/...
Added HostHeader, MembersTable, MemberRow, AddMemberDropdown components and index barrel files. UI supports role selection, removal, and candidate picking.
Projects sidebar & index tweaks
apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx, apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
Projects sidebar: added local filter input, improved filtered/unfiltered empty states; projects index: selects initial project preferring v2 then v1 (deduped) and navigates accordingly.
Settings search items
apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts
Added host-related searchable items (HOST_MEMBERS, HOST_INVITE_MEMBER, HOST_MEMBER_ROLE) assigned to the hosts section.
Client optimistic actions
apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts
Extended optimistic actions with v2UsersHosts namespace: addMember, removeMember, setMemberRole that perform local Electric ops and emit errors via existing runner.
Collection → remote sync
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
v2UsersHosts collection now maps local Electric inserts/updates/deletes to tRPC calls (v2Host.addMember, v2Host.setMemberRole, v2Host.removeMember) and returns txids for Electric transaction resolution.
Server API
packages/trpc/src/router/v2-host/v2-host.ts, packages/trpc/src/router/v2-host/index.ts, packages/trpc/src/root.ts
New v2Host tRPC router with protected mutations: addMember, removeMember, setMemberRole (enforces ownership, prevents unsafe demotions/removals, returns txids); barrel export and registration in root router.
Deleted component
apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx
Removed ProjectsSettings component and its exported props/type (file deleted).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as HostSettings UI
    participant Hook as useOptimisticCollectionActions
    participant Coll as v2UsersHosts Collection
    participant tRPC as v2Host Router
    participant Server as Backend
    participant DB as Database

    User->>UI: Click "Add Member"
    UI->>Hook: v2UsersHosts.addMember({hostId,userId,organizationId,role?})
    Hook->>Coll: insert({id,hostId,userId,role,createdAt,updatedAt}) -- optimistic
    Coll->>UI: render optimistic member
    Coll->>tRPC: v2Host.addMember({id,hostId,userId,role})
    tRPC->>Server: validate caller & org membership
    Server->>DB: check existing membership / insert
    alt membership exists
        Server-->>tRPC: CONFLICT
        tRPC-->>Coll: error (resolve tx)
        Coll-->>UI: revert + show error
    else inserted
        DB-->>Server: inserted row + txid
        Server-->>tRPC: {row,txid}
        tRPC-->>Coll: {row,txid}
        Coll->>Coll: resolve Electric tx with txid
        Coll->>UI: show success toast
    end
    UI-->>User: member confirmed
Loading
sequenceDiagram
    participant User
    participant UI as HostSettings UI
    participant Hook as useOptimisticCollectionActions
    participant Coll as v2UsersHosts Collection
    participant tRPC as v2Host Router
    participant Server as Backend
    participant DB as Database

    User->>UI: Change member role
    UI->>Hook: v2UsersHosts.setMemberRole(rowId, role)
    Hook->>Coll: update(rowId,{role}) -- optimistic
    Coll->>UI: show optimistic role change
    Coll->>tRPC: v2Host.setMemberRole({hostId,userId,role})
    tRPC->>Server: validate ownership & constraints
    Server->>DB: check membership and owner counts
    alt invalid demotion/removal
        Server-->>tRPC: BAD_REQUEST
        tRPC-->>Coll: error
        Coll-->>UI: revert + show error
    else updated
        DB-->>Server: success + txid
        Server-->>tRPC: {success:true,txid}
        tRPC-->>Coll: {success:true,txid}
        Coll->>Coll: resolve Electric tx with txid
        Coll->>UI: show success toast
    end
    UI-->>User: role change confirmed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hopping through hosts with a cheerful hop,

Members appear before the server's full stop,
Sidebars light up, online dots glow,
Roles and removals — optimistic show,
A rabbit squeaks, "Now manage and grow!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary feature: adding a hosts settings page for managing host access, which aligns with the main objective of the changeset.
Description check ✅ Passed The description includes a comprehensive summary of changes, test plan with checkboxes, type of change indicators, and additional context about the implementation. It covers all required template sections adequately.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 host-access-settings-desi

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 and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

This PR adds a Hosts settings page (sub-sidebar, detail view, member management) alongside a matching v2Host tRPC router, and retrofits the Projects index page with the same auto-redirect-to-first-item behaviour. The ProjectsSettings sidebar component is consolidated into GeneralSettings, and the Projects sidebar gains a name filter for parity.

All four findings are P2: the last-owner guards in removeMember and setMemberRole are non-atomic (TOCTOU gap between the count query and the write), setMemberRole returns { success: true } silently when the target user has no v2UsersHosts row, both index pages briefly flash "No … yet." on first render before their live queries hydrate, and orgUsers in HostSettings is fetched without an org-scope filter.

Confidence Score: 5/5

Safe to merge — all findings are P2 style/hardening suggestions with no blocking correctness issues on the happy path.

All four findings are P2: the TOCTOU race in the last-owner guards requires concurrent mutation requests to manifest (unlikely in a settings UI), the silent no-op in setMemberRole has no user-visible impact in normal flows, the loading flash is a minor UX glitch, and the unscoped orgUsers fetch doesn't expose wrong data due to downstream filters. No P0/P1 defects were identified.

packages/trpc/src/router/v2-host/v2-host.ts — TOCTOU in removeMember/setMemberRole and silent no-op in setMemberRole worth addressing in a follow-up.

Important Files Changed

Filename Overview
packages/trpc/src/router/v2-host/v2-host.ts New tRPC router with addMember/removeMember/setMemberRole; owner-guard and safety rails are logically correct but the last-owner checks are non-atomic (TOCTOU), and setMemberRole silently succeeds when the target user isn't on the host.
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx New host-members detail page; owner-only controls are correctly gated both on client (disabled state) and server; orgUsers query lacks an org-scope filter, and orgMembers depends on host?.organizationId which starts undefined.
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx New index page that auto-redirects to the first (online-first) host; briefly renders "No hosts yet." on initial load before the live query hydrates.
apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx Adds auto-redirect to first project; isEmpty check fires while both queries default to [] on first render, causing a transient "No projects yet." flash.
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx New sidebar with live filter, online/offline grouping, and active-item highlighting; mirrors ProjectsSettingsSidebar pattern cleanly.
apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx Adds Projects and Hosts entries to the Organization group; fixes active-route detection with fuzzy matching; removes now-redundant ProjectsSettings component.
apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts Adds three new HOST_* search items under the "hosts" section with sensible keywords; no issues found.
apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx Registers "hosts" in SECTION_ORDER, getSectionFromPath, getPathFromSection, and the skip-navigation guard; looks correct.
apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx Adds a live name filter to the existing sidebar; noMatches guard correctly excludes the filter-is-empty case.

Sequence Diagram

sequenceDiagram
    participant UI as HostSettings (UI)
    participant tRPC as v2Host tRPC Router
    participant DB as Database

    UI->>tRPC: addMember({ hostId, userId, role })
    tRPC->>DB: requireHostOwner (check v2Hosts + v2UsersHosts)
    tRPC->>DB: requireOrgMember (check members table)
    tRPC->>DB: INSERT v2UsersHosts (onConflictDoNothing)
    tRPC-->>UI: { success: true }

    UI->>tRPC: removeMember({ hostId, userId })
    tRPC->>DB: requireHostOwner
    tRPC->>DB: SELECT role from v2UsersHosts (target)
    tRPC->>DB: countOwners (if target is owner)
    Note over tRPC,DB: TOCTOU - count and delete are not atomic
    tRPC->>DB: DELETE from v2UsersHosts
    tRPC-->>UI: { success: true }

    UI->>tRPC: setMemberRole({ hostId, userId, role })
    tRPC->>DB: requireHostOwner
    tRPC->>DB: COUNT owners WHERE userId != target (if role=member)
    Note over tRPC,DB: TOCTOU - count and update are not atomic
    tRPC->>DB: UPDATE v2UsersHosts SET role
    Note over tRPC,DB: Silent no-op if userId not on host
    tRPC-->>UI: { success: true }
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/v2-host/v2-host.ts
Line: 103-118

Comment:
**Non-atomic last-owner guard (TOCTOU)**

`countOwners` is read and then the `delete` (or role `update`) runs as separate queries. Two concurrent requests arriving simultaneously — e.g., two owners each removing the other — can both pass the `countOwners > 1` check before either delete commits, leaving the host with zero owners. Using a database transaction (or a CTE-based conditional delete) would close this gap.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/trpc/src/router/v2-host/v2-host.ts
Line: 189-197

Comment:
**`setMemberRole` silently succeeds for non-existent memberships**

If `input.userId` has no entry in `v2UsersHosts` (i.e., the user isn't on the host), the `UPDATE` matches zero rows but the procedure still returns `{ success: true }`. Callers get no indication that nothing changed. A quick existence check before the update (or checking `rowsAffected`) would surface this.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
Line: 54-62

Comment:
**False "No hosts yet" flash on initial load**

`hosts` defaults to `[]` while the live query is still hydrating, so `hosts.length === 0` is `true` on the very first render and the "No hosts yet." message briefly appears before real data arrives. The same pattern applies to `ProjectsIndexPage`. Adding an explicit loading/initializing state before trusting an empty result would prevent this flash.

The same issue appears in `apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx` where `isEmpty = v2Projects.length === 0 && groups.length === 0` evaluates `true` on first render before either query resolves.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
Line: 78-85

Comment:
**`orgUsers` query has no organization scope**

The `orgUsers` live query fetches from `collections.users` without any `organizationId` filter, pulling all users in the local cache. While the downstream `candidates` list is correctly scoped through `orgMembers`, the `userMap` built from all users could resolve names/emails for users outside the current org if they appear in `hostUserRows`. Adding a filter analogous to the one in the `orgMembers` query would be safer and more efficient.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "add hosts settings page for managing hos..." | Re-trigger Greptile

Comment on lines +103 to +118
});

return { success: true };
}),

removeMember: protectedProcedure
.input(
z.object({
hostId: z.string().uuid(),
userId: z.string().uuid(),
}),
)
.mutation(async ({ ctx, input }) => {
const organizationId = requireActiveOrgId(ctx);
await requireHostOwner(ctx.session.user.id, input.hostId, organizationId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Non-atomic last-owner guard (TOCTOU)

countOwners is read and then the delete (or role update) runs as separate queries. Two concurrent requests arriving simultaneously — e.g., two owners each removing the other — can both pass the countOwners > 1 check before either delete commits, leaving the host with zero owners. Using a database transaction (or a CTE-based conditional delete) would close this gap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/v2-host/v2-host.ts
Line: 103-118

Comment:
**Non-atomic last-owner guard (TOCTOU)**

`countOwners` is read and then the `delete` (or role `update`) runs as separate queries. Two concurrent requests arriving simultaneously — e.g., two owners each removing the other — can both pass the `countOwners > 1` check before either delete commits, leaving the host with zero owners. Using a database transaction (or a CTE-based conditional delete) would close this gap.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +189 to +197
);
if (ownerCount.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A host must have at least one owner.",
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 setMemberRole silently succeeds for non-existent memberships

If input.userId has no entry in v2UsersHosts (i.e., the user isn't on the host), the UPDATE matches zero rows but the procedure still returns { success: true }. Callers get no indication that nothing changed. A quick existence check before the update (or checking rowsAffected) would surface this.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/v2-host/v2-host.ts
Line: 189-197

Comment:
**`setMemberRole` silently succeeds for non-existent memberships**

If `input.userId` has no entry in `v2UsersHosts` (i.e., the user isn't on the host), the `UPDATE` matches zero rows but the procedure still returns `{ success: true }`. Callers get no indication that nothing changed. A quick existence check before the update (or checking `rowsAffected`) would surface this.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +54 to +62
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center h-full p-6 text-sm text-muted-foreground">
No hosts yet.
</div>
);
}

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.

P2 False "No hosts yet" flash on initial load

hosts defaults to [] while the live query is still hydrating, so hosts.length === 0 is true on the very first render and the "No hosts yet." message briefly appears before real data arrives. The same pattern applies to ProjectsIndexPage. Adding an explicit loading/initializing state before trusting an empty result would prevent this flash.

The same issue appears in apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx where isEmpty = v2Projects.length === 0 && groups.length === 0 evaluates true on first render before either query resolves.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
Line: 54-62

Comment:
**False "No hosts yet" flash on initial load**

`hosts` defaults to `[]` while the live query is still hydrating, so `hosts.length === 0` is `true` on the very first render and the "No hosts yet." message briefly appears before real data arrives. The same pattern applies to `ProjectsIndexPage`. Adding an explicit loading/initializing state before trusting an empty result would prevent this flash.

The same issue appears in `apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx` where `isEmpty = v2Projects.length === 0 && groups.length === 0` evaluates `true` on first render before either query resolves.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +78 to +85
q.from({ users: collections.users }).select(({ users }) => ({
id: users.id,
name: users.name,
email: users.email,
})),
[collections],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 orgUsers query has no organization scope

The orgUsers live query fetches from collections.users without any organizationId filter, pulling all users in the local cache. While the downstream candidates list is correctly scoped through orgMembers, the userMap built from all users could resolve names/emails for users outside the current org if they appear in hostUserRows. Adding a filter analogous to the one in the orgMembers query would be safer and more efficient.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
Line: 78-85

Comment:
**`orgUsers` query has no organization scope**

The `orgUsers` live query fetches from `collections.users` without any `organizationId` filter, pulling all users in the local cache. While the downstream `candidates` list is correctly scoped through `orgMembers`, the `userMap` built from all users could resolve names/emails for users outside the current org if they appear in `hostUserRows`. Adding a filter analogous to the one in the `orgMembers` query would be safer and more efficient.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 26, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@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: 9

🧹 Nitpick comments (5)
apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx (1)

81-83: Optional: align noMatches check with the trimmed filter.

matches() uses the trimmed/lowercased value, but noMatches checks filter !== "". The current behavior is correct (whitespace-only input keeps v2Rows/v1Rows populated, so noMatches stays false), but using the trimmed value here makes intent clearer and avoids relying on that indirect interaction.

♻️ Suggested tweak
-	const isEmpty = totalUnfiltered === 0;
-	const noMatches =
-		!isEmpty && v2Rows.length === 0 && v1Rows.length === 0 && filter !== "";
+	const trimmedFilter = filter.trim();
+	const isEmpty = totalUnfiltered === 0;
+	const noMatches =
+		!isEmpty &&
+		v2Rows.length === 0 &&
+		v1Rows.length === 0 &&
+		trimmedFilter !== "";

And update the “No matches” message body to use trimmedFilter (or keep filter if you prefer to echo back the user's literal input).

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx`
around lines 81 - 83, Update the noMatches computation to use the same
trimmed/lowercased input that matches() uses: replace the filter check in the
noMatches expression to compare against trimmedFilter (or its normalized form)
instead of raw filter so whitespace-only input is handled consistently; also
update the “No matches” message rendering to display trimmedFilter (or keep raw
filter if you intentionally want to echo literal input) so the displayed text
matches the value used in the noMatches logic.
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx (1)

162-174: Consider a confirmation step before removing a host member.

handleRemoveMember immediately calls the mutation on click. For a destructive action that revokes a teammate's access to a host, a confirm dialog (or undo toast) reduces the impact of accidental clicks. The current self-remove guard (disabled={busy || isSelf}) doesn't help against fat-fingering another member.

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx
around lines 162 - 174, Add a user confirmation step before performing the
destructive remove action: in handleRemoveMember, prompt the user (using the
app's modal/confirm utility or window.confirm) and only call
apiTrpcClient.v2Host.removeMember.mutate({ hostId, userId }) if the user
confirms; setBusy(true) after the confirmation (and reset in finally), and
optionally replace or augment the confirmation with an undo-capable toast if
available so the mutation can be reversed within a short window. Ensure you
update any UI callers of handleRemoveMember to preserve the same signature and
maintain the existing isSelf/busy guards.
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx (1)

9-9: Replace the as cast with direct destructuring for better type safety.

TanStack Router's useParams() API returns a union type of all possible params from parent routes when strict: false is used. Instead of casting, directly destructure the params to get simpler, type-checked access:

♻️ Suggested approach
-	const params = useParams({ strict: false }) as { hostId?: string };
+	const { hostId } = useParams({ strict: false });

This lets TypeScript infer the available params from your route structure while keeping the code cleaner and more maintainable.

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

In `@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx` at
line 9, Replace the unsafe type cast on the useParams call by destructuring the
returned params directly: remove "const params = useParams({ strict: false }) as
{ hostId?: string }" and instead call useParams({ strict: false }) and extract
hostId via const { hostId } = useParams({ strict: false }); this lets TypeScript
infer the union of possible params rather than forcing an incorrect type, so
update any subsequent uses of params.hostId to use hostId directly and adjust
null/undefined checks accordingly.
packages/trpc/src/router/v2-host/v2-host.ts (2)

89-103: Clarify addMember semantics when the user is already a host member.

onConflictDoNothing means that if the target user already has any role on this host, the call is silently a no-op — including when the caller passed an explicit role intending to change it. That's defensible (callers should use setMemberRole to change roles), but currently indistinguishable from a successful add. Consider either:

  • Documenting this in a comment so future readers / API consumers understand addMember will not update an existing role, or
  • Returning a discriminator (e.g. { success: true, added: boolean }) so the UI can react (toast "already a member" vs "added").
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/router/v2-host/v2-host.ts` around lines 89 - 103, The
addMember DB insert uses onConflictDoNothing
(v2UsersHosts.insert(...).onConflictDoNothing) which silently no-ops when the
user already has any role, making it indistinguishable from a successful add;
update the addMember handler to return a discriminator like { success: true,
added: boolean } instead of a blind success so callers can tell if a new
membership was created or it already existed—implement this by detecting whether
the insert affected any rows (or by doing a lightweight existence check against
v2UsersHosts before/after the insert) and keep the current behavior that role
changes must go through setMemberRole, or alternatively add a clear inline
comment next to the onConflictDoNothing call documenting that addMember will not
update an existing role.

65-73: Use SQL count() instead of materializing rows for better efficiency.

countOwners fetches all owner rows just to read .length. The count() aggregate is more efficient—it performs the count at the database level without transferring row data.

However, the proposed diff has implementation errors. The codebase pattern (seen in packages/auth/src/server.ts) uses { count: count() } as the property name and accesses the first result array element. Correct it to:

-import { and, eq, ne } from "drizzle-orm";
+import { and, count, eq, ne } from "drizzle-orm";
@@
 async function countOwners(hostId: string) {
-	const owners = await db
-		.select({ id: v2UsersHosts.id })
-		.from(v2UsersHosts)
-		.where(
-			and(eq(v2UsersHosts.hostId, hostId), eq(v2UsersHosts.role, "owner")),
-		);
-	return owners.length;
+	const result = await db
+		.select({ count: count() })
+		.from(v2UsersHosts)
+		.where(
+			and(eq(v2UsersHosts.hostId, hostId), eq(v2UsersHosts.role, "owner")),
+		);
+	return result[0]?.count ?? 0;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/router/v2-host/v2-host.ts` around lines 65 - 73,
countOwners currently materializes rows to compute length; change it to perform
a DB-side count by selecting { count: count() } from v2UsersHosts with the same
where clause (use db.select({ count: count()
}).from(v2UsersHosts).where(and(eq(v2UsersHosts.hostId, hostId),
eq(v2UsersHosts.role, "owner")))), then read the numeric count from the first
result (e.g., result[0].count) and return it as a number. Ensure you import/use
the same count() helper as the codebase pattern and keep the function name
countOwners and conditions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx:
- Around line 150-186: The three handlers (handleAddMember, handleRemoveMember,
handleSetRole) share a single busy flag which can be flipped false by a
later-resolving request; replace the boolean with a pending counter or
per-action pending map and derive busy from it. Concretely: introduce a
pendingCount state/ref (or a Record<string, number> for per-action keys),
increment it before the try in each handler and decrement it in finally
(guarding against negative), and setBusy(pendingCount > 0) or compute the
disabled state from the map (e.g., Object.values(map).some(v => v > 0)); update
the handlers to use increment/decrement instead of setBusy(true)/setBusy(false)
so the UI only becomes enabled when all in-flight mutations complete.
- Around line 303-341: The AddMemberDropdown component (function
AddMemberDropdown with props candidates: CandidateRow[], disabled, onPick) must
be extracted into its own foldered component per convention: create
HostSettings/AddMemberDropdown/AddMemberDropdown.tsx containing the component,
export it via HostSettings/AddMemberDropdown/index.ts, and update this file to
import AddMemberDropdown instead of declaring it inline; ensure the new file
preserves the same props shape (CandidateRow type import) and behavior (returns
Button when candidates.length === 0, DropdownMenu otherwise) and update any
local references to use the imported component.
- Around line 103-119: The code currently force-casts row.role to "owner" |
"member" inside the members useMemo, which can hide invalid DB values; update
the members mapping (the useMemo that consumes hostUserRows and userMap) to
validate row.role at runtime (e.g., check row.role === "owner" || row.role ===
"member") and then set role to that validated value, otherwise fall back to a
safe default like "member" (or skip the row if preferred) instead of using a
blind cast; keep the rest of the mapping (usersHostsId, userId, name, email)
unchanged and preserve the existing sort logic.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx`:
- Around line 70-76: The filter input inside HostsSettingsSidebar (the <input>
with placeholder "Filter hosts...") lacks an accessible name; update the element
in HostsSettingsSidebar.tsx to provide one by adding an aria-label (e.g.,
aria-label="Filter hosts") or by adding a visually hidden <label> linked via id
to the input, ensuring the onChange/value behavior remains unchanged so screen
readers announce the control correctly.
- Around line 115-155: Extract the Section and HostLink components into their
own component folders and barrel exports: create
HostsSettingsSidebar/Section/Section.tsx exporting the Section component and
HostsSettingsSidebar/Section/index.ts exporting it as default (preserving its
props: title: string | null, children: React.ReactNode), and create
HostsSettingsSidebar/HostLink/HostLink.tsx plus index.ts for HostLink
(preserving props: row: HostRow, isActive: boolean and its use of cn, Link, and
HostRow type). In the original HostsSettingsSidebar file, replace the inline
definitions with imports from the new barrels and ensure any used
utilities/types (cn, Link, HostRow) are imported where needed; run type checks
and update relative import paths accordingly.

In `@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx`:
- Around line 44-52: The effect currently calls navigate whenever firstHostId
changes, causing unwanted redirects; modify the effect (useEffect) to only call
navigate when firstHostId is truthy AND different from the currently
selectedHostId (compare to selectedHostId) and only once on initial mount by
tracking a ref (e.g., hasRedirected via useRef<boolean>) so you skip redirects
after the first successful redirect; implement the check order: if
(!firstHostId) return; if (selectedHostId && firstHostId === selectedHostId)
return; if (hasRedirected.current) return; perform navigate({ to:
"/settings/hosts/$hostId", params: { hostId: firstHostId }, replace: true }) and
set hasRedirected.current = true.

In `@apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx`:
- Around line 24-39: The empty-state flash happens because
electronTrpc.workspaces.getAllGrouped.useQuery and the useLiveQuery for
collections.v2Projects both default to [] so isEmpty is true on first render;
update the component to wait for the queries to settle before showing "No
projects yet." by checking the loading/ready flags (e.g., useQuery's
isLoading/isFetched or status) for
electronTrpc.workspaces.getAllGrouped.useQuery and the loading state exposed by
useLiveQuery for v2Projects (confirm whether it's isLoading/isPending/status)
and only evaluate isEmpty or render the empty message once at least one of those
sources reports settled/fetched; reference the variables
electronTrpc.workspaces.getAllGrouped.useQuery, v2Projects (from useLiveQuery)
and the isEmpty check to locate and gate the empty-state rendering.

In `@packages/trpc/src/router/v2-host/v2-host.ts`:
- Around line 179-206: In setMemberRole: prevent the TOCTOU by wrapping the
owner-count check and the subsequent update in a single transaction (use
db.transaction or the project's transaction helper) and perform the count with
row locking (SELECT ... FOR UPDATE) on v2UsersHosts like removeMember does, then
only proceed to update; also verify the target membership exists before
returning success — after locking, query the specific (hostId, userId) row and
if missing return/throw a NOT_FOUND (or error) instead of silently succeeding;
refer to symbols setMemberRole, v2UsersHosts, db, and the removeMember
implementation for the exact transaction/locking pattern.
- Around line 138-155: The owner-count check in v2-host.ts (the call to
countOwners and the subsequent db.delete on v2UsersHosts) is non-atomic and
vulnerable to TOCTOU; wrap the read-and-delete in a single database transaction
(use db.transaction or your DB client's transaction helper) and perform the
owner lookup with a row lock (SELECT ... FOR UPDATE or your ORM's
forUpdate/select for update) inside that transaction, re-check owner count there
and only then run the delete of v2UsersHosts; ensure the functions/queries
involved (countOwners logic or its equivalent select, and the delete against
v2UsersHosts) are executed inside the same transaction scope so concurrent
removeMember calls cannot both pass the check.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx:
- Around line 162-174: Add a user confirmation step before performing the
destructive remove action: in handleRemoveMember, prompt the user (using the
app's modal/confirm utility or window.confirm) and only call
apiTrpcClient.v2Host.removeMember.mutate({ hostId, userId }) if the user
confirms; setBusy(true) after the confirmation (and reset in finally), and
optionally replace or augment the confirmation with an undo-capable toast if
available so the mutation can be reversed within a short window. Ensure you
update any UI callers of handleRemoveMember to preserve the same signature and
maintain the existing isSelf/busy guards.

In `@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx`:
- Line 9: Replace the unsafe type cast on the useParams call by destructuring
the returned params directly: remove "const params = useParams({ strict: false
}) as { hostId?: string }" and instead call useParams({ strict: false }) and
extract hostId via const { hostId } = useParams({ strict: false }); this lets
TypeScript infer the union of possible params rather than forcing an incorrect
type, so update any subsequent uses of params.hostId to use hostId directly and
adjust null/undefined checks accordingly.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx`:
- Around line 81-83: Update the noMatches computation to use the same
trimmed/lowercased input that matches() uses: replace the filter check in the
noMatches expression to compare against trimmedFilter (or its normalized form)
instead of raw filter so whitespace-only input is handled consistently; also
update the “No matches” message rendering to display trimmedFilter (or keep raw
filter if you intentionally want to echo literal input) so the displayed text
matches the value used in the noMatches logic.

In `@packages/trpc/src/router/v2-host/v2-host.ts`:
- Around line 89-103: The addMember DB insert uses onConflictDoNothing
(v2UsersHosts.insert(...).onConflictDoNothing) which silently no-ops when the
user already has any role, making it indistinguishable from a successful add;
update the addMember handler to return a discriminator like { success: true,
added: boolean } instead of a blind success so callers can tell if a new
membership was created or it already existed—implement this by detecting whether
the insert affected any rows (or by doing a lightweight existence check against
v2UsersHosts before/after the insert) and keep the current behavior that role
changes must go through setMemberRole, or alternatively add a clear inline
comment next to the onConflictDoNothing call documenting that addMember will not
update an existing role.
- Around line 65-73: countOwners currently materializes rows to compute length;
change it to perform a DB-side count by selecting { count: count() } from
v2UsersHosts with the same where clause (use db.select({ count: count()
}).from(v2UsersHosts).where(and(eq(v2UsersHosts.hostId, hostId),
eq(v2UsersHosts.role, "owner")))), then read the numeric count from the first
result (e.g., result[0].count) and return it as a number. Ensure you import/use
the same count() helper as the codebase pattern and keep the function name
countOwners and conditions unchanged.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2181d09d-1c22-41e1-b51c-e569811b3502

📥 Commits

Reviewing files that changed from the base of the PR and between 74d74bf and 69496a2.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts
  • apps/desktop/src/renderer/stores/settings-state.ts
  • packages/trpc/src/root.ts
  • packages/trpc/src/router/v2-host/index.ts
  • packages/trpc/src/router/v2-host/v2-host.ts
💤 Files with no reviewable changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx

Comment on lines +103 to +119
const members: MemberRow[] = useMemo(() => {
return hostUserRows
.map((row) => {
const u = userMap.get(row.userId);
return {
usersHostsId: row.id,
userId: row.userId,
role: row.role as "owner" | "member",
name: u?.name ?? "Unknown user",
email: u?.email ?? "",
};
})
.sort((a, b) => {
if (a.role !== b.role) return a.role === "owner" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [hostUserRows, userMap]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Narrow the role cast or validate it.

row.role as "owner" | "member" silently trusts the DB value. If the schema ever grows a third role (or returns null), this cast hides the bug and isOwner checks downstream will be wrong. A small runtime check (row.role === "owner" || row.role === "member") and falling back to "member" (or skipping the row) would be safer.

♻️ Suggested fix
-		return hostUserRows
-			.map((row) => {
-				const u = userMap.get(row.userId);
-				return {
-					usersHostsId: row.id,
-					userId: row.userId,
-					role: row.role as "owner" | "member",
-					name: u?.name ?? "Unknown user",
-					email: u?.email ?? "",
-				};
-			})
+		return hostUserRows
+			.map((row): MemberRow => {
+				const u = userMap.get(row.userId);
+				const role: "owner" | "member" =
+					row.role === "owner" ? "owner" : "member";
+				return {
+					usersHostsId: row.id,
+					userId: row.userId,
+					role,
+					name: u?.name ?? "Unknown user",
+					email: u?.email ?? "",
+				};
+			})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx
around lines 103 - 119, The code currently force-casts row.role to "owner" |
"member" inside the members useMemo, which can hide invalid DB values; update
the members mapping (the useMemo that consumes hostUserRows and userMap) to
validate row.role at runtime (e.g., check row.role === "owner" || row.role ===
"member") and then set role to that validated value, otherwise fall back to a
safe default like "member" (or skip the row if preferred) instead of using a
blind cast; keep the rest of the mapping (usersHostsId, userId, name, email)
unchanged and preserve the existing sort logic.

Comment on lines +303 to +341
function AddMemberDropdown({
candidates,
disabled,
onPick,
}: {
candidates: CandidateRow[];
disabled: boolean;
onPick: (userId: string) => void;
}) {
if (candidates.length === 0) {
return (
<Button size="sm" variant="outline" disabled>
<HiOutlinePlus className="h-4 w-4 mr-1" />
Add member
</Button>
);
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={disabled}>
<HiOutlinePlus className="h-4 w-4 mr-1" />
Add member
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{candidates.map((c) => (
<DropdownMenuItem key={c.userId} onSelect={() => onPick(c.userId)}>
<div className="flex flex-col">
<span className="text-sm">{c.name}</span>
<span className="text-xs text-muted-foreground">{c.email}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Split AddMemberDropdown into its own file.

Per repo convention, each component lives in its own ComponentName/ComponentName.tsx folder. Move AddMemberDropdown to a sibling component file (e.g. HostSettings/AddMemberDropdown/AddMemberDropdown.tsx) and import it here.

As per coding guidelines: "Do not create multi-component files; use one component per file." and "Use one folder per component with structure: ComponentName/ComponentName.tsx + index.ts for barrel export."

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx
around lines 303 - 341, The AddMemberDropdown component (function
AddMemberDropdown with props candidates: CandidateRow[], disabled, onPick) must
be extracted into its own foldered component per convention: create
HostSettings/AddMemberDropdown/AddMemberDropdown.tsx containing the component,
export it via HostSettings/AddMemberDropdown/index.ts, and update this file to
import AddMemberDropdown instead of declaring it inline; ensure the new file
preserves the same props shape (CandidateRow type import) and behavior (returns
Button when candidates.length === 0, DropdownMenu otherwise) and update any
local references to use the imported component.

Comment on lines +115 to +155
function Section({
title,
children,
}: {
title: string | null;
children: React.ReactNode;
}) {
return (
<div>
{title && (
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-2">
{title}
</h2>
)}
<nav className="flex flex-col gap-0.5">{children}</nav>
</div>
);
}

function HostLink({ row, isActive }: { row: HostRow; isActive: boolean }) {
return (
<Link
to="/settings/hosts/$hostId"
params={{ hostId: row.id }}
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full shrink-0",
row.isOnline ? "bg-emerald-500" : "bg-muted-foreground/40",
)}
/>
<span className="truncate flex-1">{row.name}</span>
</Link>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Split Section and HostLink into their own files.

This file defines three components (HostsSettingsSidebar, Section, HostLink). Per repository convention, each component should live in its own ComponentName/ComponentName.tsx folder with an index.ts barrel, so these helpers should be extracted to siblings (e.g. HostsSettingsSidebar/Section/Section.tsx and HostsSettingsSidebar/HostLink/HostLink.tsx) and imported here.

As per coding guidelines: "Do not create multi-component files; use one component per file." and "Use one folder per component with structure: ComponentName/ComponentName.tsx + index.ts for barrel export."

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx`
around lines 115 - 155, Extract the Section and HostLink components into their
own component folders and barrel exports: create
HostsSettingsSidebar/Section/Section.tsx exporting the Section component and
HostsSettingsSidebar/Section/index.ts exporting it as default (preserving its
props: title: string | null, children: React.ReactNode), and create
HostsSettingsSidebar/HostLink/HostLink.tsx plus index.ts for HostLink
(preserving props: row: HostRow, isActive: boolean and its use of cn, Link, and
HostRow type). In the original HostsSettingsSidebar file, replace the inline
definitions with imports from the new barrels and ensure any used
utilities/types (cn, Link, HostRow) are imported where needed; run type checks
and update relative import paths accordingly.

Comment on lines +44 to +52
useEffect(() => {
if (firstHostId) {
navigate({
to: "/settings/hosts/$hostId",
params: { hostId: firstHostId },
replace: true,
});
}
}, [firstHostId, navigate]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Effect runs navigate even when firstHostId doesn't actually change.

useEffect reruns whenever firstHostId flips between null and an id (e.g. when the live query refreshes while user is on a child route). When user is already on /settings/hosts/$hostId, this index isn't mounted, so this is mostly fine. But if the live query briefly returns [] then repopulates after this index re-mounts, you can get a redirect loop to the "first" host even if the user manually navigated to a different one prior. Consider also guarding navigation against the case where firstHostId === selectedHostId already, or only redirecting once on initial mount via a ref.

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

In `@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx`
around lines 44 - 52, The effect currently calls navigate whenever firstHostId
changes, causing unwanted redirects; modify the effect (useEffect) to only call
navigate when firstHostId is truthy AND different from the currently
selectedHostId (compare to selectedHostId) and only once on initial mount by
tracking a ref (e.g., hasRedirected via useRef<boolean>) so you skip redirects
after the first successful redirect; implement the check order: if
(!firstHostId) return; if (selectedHostId && firstHostId === selectedHostId)
return; if (hasRedirected.current) return; perform navigate({ to:
"/settings/hosts/$hostId", params: { hostId: firstHostId }, replace: true }) and
set hasRedirected.current = true.

Comment on lines +24 to 39
const { data: groups = [] } =
electronTrpc.workspaces.getAllGrouped.useQuery();

const { data: v2Projects = [] } = useLiveQuery(
(q) =>
q
.from({ projects: collections.v2Projects })
.where(({ projects }) =>
eq(projects.organizationId, activeOrganizationId ?? ""),
)
.select(({ projects }) => ({
id: projects.id,
name: projects.name,
})),
[collections, activeOrganizationId],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: avoid “No projects yet.” flash while queries load.

Both useQuery and useLiveQuery default to [] before they settle, so on the first render isEmpty is true and users with projects briefly see "No projects yet." before the redirect fires. Gate the empty state on a loaded/settled signal (or simply render null until at least one source has resolved).

♻️ Suggested fix
-	const { data: groups = [] } =
-		electronTrpc.workspaces.getAllGrouped.useQuery();
+	const { data: groups = [], isPending: groupsPending } =
+		electronTrpc.workspaces.getAllGrouped.useQuery();

-	const { data: v2Projects = [] } = useLiveQuery(
+	const { data: v2Projects = [], isLoading: v2Loading } = useLiveQuery(
 		(q) =>
 			q
 				.from({ projects: collections.v2Projects })
 				.where(({ projects }) =>
 					eq(projects.organizationId, activeOrganizationId ?? ""),
 				)
 				.select(({ projects }) => ({
 					id: projects.id,
 					name: projects.name,
 				})),
 		[collections, activeOrganizationId],
 	);
@@
-	const isEmpty = v2Projects.length === 0 && groups.length === 0;
-	if (isEmpty) {
+	const isLoading = groupsPending || v2Loading;
+	const isEmpty =
+		!isLoading && v2Projects.length === 0 && groups.length === 0;
+	if (isEmpty) {
 		return (
 			<div className="flex items-center justify-center h-full p-6 text-sm text-muted-foreground">
 				No projects yet.
 			</div>
 		);
 	}

Verify the exact loading-state field exposed by useLiveQuery from @tanstack/react-db (e.g., isLoading, status, isPending).

#!/bin/bash
# Inspect the useLiveQuery hook surface to confirm available loading-state fields
fd -t f 'react-db' node_modules 2>/dev/null
rg -nP --type=ts -C2 '\buseLiveQuery\b' -g '!**/node_modules/**' | head -100
rg -nP --type=ts -C3 'useLiveQuery\(' -g '!**/node_modules/**' | head -80

Also applies to: 68-75

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

In `@apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx`
around lines 24 - 39, The empty-state flash happens because
electronTrpc.workspaces.getAllGrouped.useQuery and the useLiveQuery for
collections.v2Projects both default to [] so isEmpty is true on first render;
update the component to wait for the queries to settle before showing "No
projects yet." by checking the loading/ready flags (e.g., useQuery's
isLoading/isFetched or status) for
electronTrpc.workspaces.getAllGrouped.useQuery and the loading state exposed by
useLiveQuery for v2Projects (confirm whether it's isLoading/isPending/status)
and only evaluate isEmpty or render the empty message once at least one of those
sources reports settled/fetched; reference the variables
electronTrpc.workspaces.getAllGrouped.useQuery, v2Projects (from useLiveQuery)
and the isEmpty check to locate and gate the empty-state rendering.

Comment on lines +138 to +155
if (target.role === "owner") {
const ownerCount = await countOwners(input.hostId);
if (ownerCount <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A host must have at least one owner.",
});
}
}

await db
.delete(v2UsersHosts)
.where(
and(
eq(v2UsersHosts.hostId, input.hostId),
eq(v2UsersHosts.userId, input.userId),
),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

drizzle-orm transaction with select for update postgres

💡 Result:

Yes, Drizzle ORM supports PostgreSQL transactions with SELECT FOR UPDATE using the .for('update') method on select queries within a transaction callback. This locks selected rows against concurrent modifications until the transaction commits. Example for a typical read-modify-write pattern (e.g., incrementing a counter safely): await db.transaction(async (tx) => { const counter = await tx .select .from(counters) .where(eq(counters.id, 1)) .for('update'); // Locks the row(s) if (counter.length > 0) { const newCount = counter[0].count + 1; await tx .update(counters) .set({ count: newCount }) .where(eq(counters.id, 1)); } }); Key points: - Use within db.transaction(async (tx) => { ... }) - the tx instance supports all query builders. - .for('update') adds FOR UPDATE clause; options like { skipLocked: true }, { noWait: true }, or { of: tableName } (for joins) are supported. - Works with PostgreSQL drivers like node-postgres (Pool recommended over Client for concurrency). - Official docs lack dedicated section (feature request open since 2024), but API is implemented in pg-core and confirmed in GitHub issues, Discord, and API references. - Alternative: Raw SQL with tx.execute(sqlSELECT ... FOR UPDATE); for complex cases. This prevents race conditions in concurrent scenarios.

Citations:


🏁 Script executed:

# First, check if the file exists and locate it
find . -name "v2-host.ts" -type f | head -20

Repository: superset-sh/superset

Length of output: 110


🏁 Script executed:

# Also search for the file using git ls-files
git ls-files | grep -i "v2-host"

Repository: superset-sh/superset

Length of output: 335


🏁 Script executed:

# Read the v2-host.ts file to examine the implementation
wc -l ./packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 114


🏁 Script executed:

# Read the relevant section around lines 138-155
sed -n '120,170p' ./packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 1245


🏁 Script executed:

# Also search for countOwners function definition
rg -n "countOwners" ./packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 172


🏁 Script executed:

# Read the countOwners function definition
sed -n '60,75p' ./packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 404


🏁 Script executed:

# Read a wider range around removeMember to see if there's transaction wrapping
sed -n '90,160p' ./packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 1627


TOCTOU: owner-count check is not atomic with the delete.

The owner count read at line 139 and the DELETE at lines 148–155 are separate statements with no transaction wrapping. Two concurrent removeMember calls can both observe ownerCount > 1, then both succeed, leaving the host with zero owners and defeating the safety check.

Wrap the check and delete in a single transaction with SELECT FOR UPDATE locking to ensure the invariant holds under concurrency:

🔒 Sketch of a transactional fix
-			const target = await db.query.v2UsersHosts.findFirst({
-				where: and(
-					eq(v2UsersHosts.hostId, input.hostId),
-					eq(v2UsersHosts.userId, input.userId),
-				),
-				columns: { role: true },
-			});
-
-			if (!target) {
-				return { success: true };
-			}
-
-			if (target.role === "owner") {
-				const ownerCount = await countOwners(input.hostId);
-				if (ownerCount <= 1) {
-					throw new TRPCError({
-						code: "BAD_REQUEST",
-						message: "A host must have at least one owner.",
-					});
-				}
-			}
-
-			await db
-				.delete(v2UsersHosts)
-				.where(
-					and(
-						eq(v2UsersHosts.hostId, input.hostId),
-						eq(v2UsersHosts.userId, input.userId),
-					),
-				);
+			await db.transaction(async (tx) => {
+				const target = await tx.query.v2UsersHosts.findFirst({
+					where: and(
+						eq(v2UsersHosts.hostId, input.hostId),
+						eq(v2UsersHosts.userId, input.userId),
+					),
+					columns: { role: true },
+				});
+				if (!target) return;
+
+				if (target.role === "owner") {
+					const [{ value: ownerCount } = { value: 0 }] = await tx
+						.select({ value: count() })
+						.from(v2UsersHosts)
+						.where(
+							and(
+								eq(v2UsersHosts.hostId, input.hostId),
+								eq(v2UsersHosts.role, "owner"),
+							),
+						)
+						.for("update");
+					if (ownerCount <= 1) {
+						throw new TRPCError({
+							code: "BAD_REQUEST",
+							message: "A host must have at least one owner.",
+						});
+					}
+				}
+
+				await tx
+					.delete(v2UsersHosts)
+					.where(
+						and(
+							eq(v2UsersHosts.hostId, input.hostId),
+							eq(v2UsersHosts.userId, input.userId),
+						),
+					);
+			});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/router/v2-host/v2-host.ts` around lines 138 - 155, The
owner-count check in v2-host.ts (the call to countOwners and the subsequent
db.delete on v2UsersHosts) is non-atomic and vulnerable to TOCTOU; wrap the
read-and-delete in a single database transaction (use db.transaction or your DB
client's transaction helper) and perform the owner lookup with a row lock
(SELECT ... FOR UPDATE or your ORM's forUpdate/select for update) inside that
transaction, re-check owner count there and only then run the delete of
v2UsersHosts; ensure the functions/queries involved (countOwners logic or its
equivalent select, and the delete against v2UsersHosts) are executed inside the
same transaction scope so concurrent removeMember calls cannot both pass the
check.

Comment thread packages/trpc/src/router/v2-host/v2-host.ts Outdated
Move addMember/removeMember/setMemberRole to the same Electric pattern
as v2Projects/v2Workspaces: procedures wrap writes in dbWs.transaction
and return txid, the v2UsersHosts collection forwards mutations through
onInsert/onUpdate/onDelete, and HostSettings calls into
useOptimisticCollectionActions.v2UsersHosts. Failures roll the local
state back and surface a toast; success is the optimistic update.

Also breaks HostSettings into co-located subcomponents
(HostHeader, MembersTable, MemberRow, AddMemberDropdown) per AGENTS.md.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

5 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/trpc/src/router/v2-host/v2-host.ts">

<violation number="1" location="packages/trpc/src/router/v2-host/v2-host.ts:89">
P2: `setMemberRole` silently returns `{ success: true }` when the target user has no membership row (zero rows updated). Unlike `removeMember`, which checks for the target first, this mutation masks invalid calls. Verify the row exists before updating, or check the affected row count and return an appropriate error.</violation>

<violation number="2" location="packages/trpc/src/router/v2-host/v2-host.ts:138">
P1: TOCTOU race condition: the owner-count check and the `DELETE` are not wrapped in a transaction. Two concurrent `removeMember` calls for different owners can both read `ownerCount > 1`, then both delete, leaving zero owners. Wrap the read + delete in `db.transaction(async (tx) => { ... })` and use `.for('update')` on the owner-count query to serialize concurrent mutations.</violation>

<violation number="3" location="packages/trpc/src/router/v2-host/v2-host.ts:179">
P1: Same TOCTOU race as `removeMember`: the owner-count query and the `UPDATE` are not in a transaction. Two concurrent role demotions can both pass the guard and leave the host with zero owners. Wrap in `db.transaction()` with a `FOR UPDATE` lock on the owner-count select.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx:68">
P2: False "No projects yet." flash on initial load. Both `groups` and `v2Projects` default to `[]` before their queries settle, so `isEmpty` is `true` on the first render. Gate the empty state on the loading/pending status of the queries to prevent this flash.</violation>
</file>

<file name="apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx:54">
P2: False "No hosts yet." flash on initial load. `hosts` defaults to `[]` while the live query hydrates, so the empty check passes on the first render before data arrives. Gate the empty state on a loading/settled signal (or return `null` until the query has resolved) to prevent the flash.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/trpc/src/router/v2-host/v2-host.ts Outdated
Comment thread packages/trpc/src/router/v2-host/v2-host.ts Outdated
Comment thread packages/trpc/src/router/v2-host/v2-host.ts Outdated
}
}, [firstProjectId, navigate]);

const isEmpty = v2Projects.length === 0 && groups.length === 0;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 26, 2026

Choose a reason for hiding this comment

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

P2: False "No projects yet." flash on initial load. Both groups and v2Projects default to [] before their queries settle, so isEmpty is true on the first render. Gate the empty state on the loading/pending status of the queries to prevent this flash.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx, line 68:

<comment>False "No projects yet." flash on initial load. Both `groups` and `v2Projects` default to `[]` before their queries settle, so `isEmpty` is `true` on the first render. Gate the empty state on the loading/pending status of the queries to prevent this flash.</comment>

<file context>
@@ -1,13 +1,78 @@
+		}
+	}, [firstProjectId, navigate]);
+
+	const isEmpty = v2Projects.length === 0 && groups.length === 0;
+	if (isEmpty) {
+		return (
</file context>
Fix with Cubic

}
}, [firstHostId, navigate]);

if (hosts.length === 0) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 26, 2026

Choose a reason for hiding this comment

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

P2: False "No hosts yet." flash on initial load. hosts defaults to [] while the live query hydrates, so the empty check passes on the first render before data arrives. Gate the empty state on a loading/settled signal (or return null until the query has resolved) to prevent the flash.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx, line 54:

<comment>False "No hosts yet." flash on initial load. `hosts` defaults to `[]` while the live query hydrates, so the empty check passes on the first render before data arrives. Gate the empty state on a loading/settled signal (or return `null` until the query has resolved) to prevent the flash.</comment>

<file context>
@@ -0,0 +1,63 @@
+		}
+	}, [firstHostId, navigate]);
+
+	if (hosts.length === 0) {
+		return (
+			<div className="flex items-center justify-center h-full p-6 text-sm text-muted-foreground">
</file context>
Fix with Cubic

Optimistic updates make changes appear instantly, but a successful
remove can feel like the click did nothing. Show "Member added /
removed / Role updated" once the server confirms persistence; failures
still rollback and surface the existing error toast (no double-fire).
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/trpc/src/router/v2-host/v2-host.ts">

<violation number="1" location="packages/trpc/src/router/v2-host/v2-host.ts:81">
P1: `addMember` uses a non-atomic read-then-insert flow, so concurrent requests can bypass the pre-check and fail with an unhandled unique-constraint error instead of a deterministic conflict response.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/trpc/src/router/v2-host/v2-host.ts Outdated
Pull last-owner guards inside the same dbWs.transaction as the write
and lock the other-owners rows with FOR UPDATE so concurrent removals
or demotions serialize. Switch addMember to a single ON CONFLICT DO
NOTHING insert that returns CONFLICT cleanly when the row already
exists. setMemberRole now errors with NOT_FOUND if the target has no
membership row instead of silently returning success.

Also block removing the host's createdByUser: their host service will
auto re-add them via ensureV2Host on next heartbeat, so the removal is
a confusing transient. Demoting them to member is still allowed
(ensureV2Host does onConflictDoNothing).

aria-label on the host filter input.
Copy link
Copy Markdown
Contributor

@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.

♻️ Duplicate comments (1)
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx (1)

116-156: 🛠️ Refactor suggestion | 🟠 Major

Split Section and HostLink into separate component files.

This file still defines three components. Per repo convention, each should live in its own ComponentName/ComponentName.tsx folder with an index.ts barrel (e.g., HostsSettingsSidebar/Section/Section.tsx, HostsSettingsSidebar/HostLink/HostLink.tsx).

As per coding guidelines: "Do not create multi-component files; use one component per file." and "Use one folder per component with structure: ComponentName/ComponentName.tsx + index.ts for barrel export."

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx`
around lines 116 - 156, Split the inline components into separate component
folders: create HostsSettingsSidebar/Section/Section.tsx (export default
Section) and HostsSettingsSidebar/HostLink/HostLink.tsx (export default
HostLink) each with a matching index.ts that re-exports the component; move the
JSX and prop typings for Section and HostLink into those files (preserve props
signatures: Section({ title, children }) and HostLink({ row, isActive }) where
HostRow is the same type) and import any dependencies used inside (cn, Link,
HostRow type) in the new files; then update the original HostsSettingsSidebar
file to remove the component definitions and import Section and HostLink from
their new barrel indexes. Ensure exports are default (or named consistent with
callers) and update any relative import paths accordingly so the build passes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx`:
- Around line 116-156: Split the inline components into separate component
folders: create HostsSettingsSidebar/Section/Section.tsx (export default
Section) and HostsSettingsSidebar/HostLink/HostLink.tsx (export default
HostLink) each with a matching index.ts that re-exports the component; move the
JSX and prop typings for Section and HostLink into those files (preserve props
signatures: Section({ title, children }) and HostLink({ row, isActive }) where
HostRow is the same type) and import any dependencies used inside (cn, Link,
HostRow type) in the new files; then update the original HostsSettingsSidebar
file to remove the component definitions and import Section and HostLink from
their new barrel indexes. Ensure exports are default (or named consistent with
callers) and update any relative import paths accordingly so the build passes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 59bd8143-644e-4423-863b-8dfd2e6a8cfc

📥 Commits

Reviewing files that changed from the base of the PR and between eb7cb1f and ee262ef.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx
  • packages/trpc/src/router/v2-host/v2-host.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/trpc/src/router/v2-host/v2-host.ts

The "can't remove yourself" / "can't demote yourself" guards block
valid actions ("step back from admin"). The real invariants are
owner-count (last owner can't leave) and creator (the user whose
machine runs the host service must stay owner) — both already
enforced.

Adds the missing creator-demotion guard in setMemberRole so demoting
the createdByUser to member is refused. Also drops the matching
isSelf disabling in MemberRow so the UI no longer hides actions the
server now allows.
Copy link
Copy Markdown
Contributor

@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: 1

🧹 Nitpick comments (2)
apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx (2)

66-73: organizationId filter falls back to empty string before host loads.

When host is still undefined on first render, this query runs with eq(m.organizationId, ""), returning zero candidates. Once host resolves, the query re-runs and populates correctly. Functionally fine, but if the query is observable/streamed it briefly subscribes to an always-empty filter. Consider gating the query (or memoizing candidates to be [] until host is defined) to skip the empty-string round-trip.

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

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx
around lines 66 - 73, The query currently runs with eq(m.organizationId, "")
when host is undefined (see useLiveQuery, orgMembers, host?.organizationId,
collections.members); change it to skip running until host is defined by gating
the call — e.g. return an empty array or avoid calling useLiveQuery when host is
falsy (or use the hook’s “enabled”/skip option if available) so the query is not
subscribed with an empty-string filter and only executes once
host.organizationId is present.

17-25: Consider a failure toast for membership mutations.

The rejection handler is () => {}, so persistence failures are silent for the user. The optimistic action presumably rolls back state, but the user won't get feedback that their action failed. A brief toast.error("Failed to update members") (or similar) would close that gap.

♻️ Sketch
 function notifyOnPersist(
 	tx: PersistableTransaction | null,
 	successMessage: string,
+	errorMessage = "Something went wrong. Please try again.",
 ) {
 	tx?.isPersisted.promise.then(
 		() => toast.success(successMessage),
-		() => {},
+		() => toast.error(errorMessage),
 	);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx
around lines 17 - 25, notifyOnPersist currently swallows persistence rejections
(tx?.isPersisted.promise.then(..., () => {})), leaving users without feedback on
failures; update the rejection handler in notifyOnPersist to call
toast.error(...) with an appropriate message (e.g., "Failed to update members")
so persistence failures surface to the user, keeping the success handler as
toast.success(successMessage) and using the existing tx and successMessage
parameters.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/trpc/src/router/v2-host/v2-host.ts`:
- Around line 149-167: The target row is selected without a FOR UPDATE lock
which allows two concurrent removeMember transactions to each see an owner and
both proceed; update the SELECT that fetches `target` (the query that selects
v2UsersHosts.role for the given hostId and userId) to use .for("update") so the
target row is locked in the transaction before the other-owners check, and apply
the identical change to the analogous target selection in `setMemberRole` so
concurrent role changes/ removals serialize correctly.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/hosts/`$hostId/components/HostSettings/HostSettings.tsx:
- Around line 66-73: The query currently runs with eq(m.organizationId, "") when
host is undefined (see useLiveQuery, orgMembers, host?.organizationId,
collections.members); change it to skip running until host is defined by gating
the call — e.g. return an empty array or avoid calling useLiveQuery when host is
falsy (or use the hook’s “enabled”/skip option if available) so the query is not
subscribed with an empty-string filter and only executes once
host.organizationId is present.
- Around line 17-25: notifyOnPersist currently swallows persistence rejections
(tx?.isPersisted.promise.then(..., () => {})), leaving users without feedback on
failures; update the rejection handler in notifyOnPersist to call
toast.error(...) with an appropriate message (e.g., "Failed to update members")
so persistence failures surface to the user, keeping the success handler as
toast.success(successMessage) and using the existing tx and successMessage
parameters.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81620c3c-813b-4cc1-b947-e57c31db27f2

📥 Commits

Reviewing files that changed from the base of the PR and between ee262ef and dc26e72.

📒 Files selected for processing (4)
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/MembersTable.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/MemberRow.tsx
  • packages/trpc/src/router/v2-host/v2-host.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/MemberRow.tsx

Comment on lines +149 to +167
if (target.role === "owner") {
const otherOwners = await tx
.select({ id: v2UsersHosts.id })
.from(v2UsersHosts)
.where(
and(
eq(v2UsersHosts.hostId, input.hostId),
eq(v2UsersHosts.role, "owner"),
ne(v2UsersHosts.userId, input.userId),
),
)
.for("update");
if (otherOwners.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A host must have at least one owner.",
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

PostgreSQL SELECT FOR UPDATE row locking with disjoint WHERE predicates concurrent transactions

💡 Result:

In PostgreSQL, SELECT FOR UPDATE acquires row-level locks only on the specific rows that match the WHERE predicate and are returned by the query. Locks are taken progressively as rows are identified and fetched during query execution. For concurrent transactions with disjoint (non-overlapping) WHERE predicates: - No blocking occurs. Each transaction locks only its own matching rows independently. - Transaction isolation (default READ COMMITTED) ensures each sees a consistent snapshot at query start, but since predicates don't overlap, no shared rows mean no lock conflicts. - Row locks do not affect data querying (plain SELECTs); they only block writers and conflicting lockers on the same row. Example: - T1: SELECT * FROM table WHERE id = 1 FOR UPDATE; -- locks row 1 - T2: SELECT * FROM table WHERE id = 2 FOR UPDATE; -- locks row 2, no wait If predicates do overlap on a row, the second transaction waits until the first commits/rolls back, then re-evaluates the condition on the updated row (if applicable). Use SKIP LOCKED to avoid waiting (skips locked rows), or NOWAIT to error immediately. For best concurrency with non-key updates, prefer FOR NO KEY UPDATE over FOR UPDATE. To prevent deadlocks in multi-row locks, always ORDER BY a consistent key (e.g., primary key). Higher isolation like REPEATABLE READ prevents anomalies but may cause serialization failures requiring retries.

Citations:


🏁 Script executed:

# Check file exists and get lines 113-181 (removeMember) and 183-256 (setMemberRole)
wc -l packages/trpc/src/router/v2-host/v2-host.ts

Repository: superset-sh/superset

Length of output: 112


🏁 Script executed:

# Read the removeMember and setMemberRole functions to verify the race condition
sed -n '113,181p' packages/trpc/src/router/v2-host/v2-host.ts | cat -n

Repository: superset-sh/superset

Length of output: 2184


🏁 Script executed:

# Read setMemberRole function
sed -n '183,256p' packages/trpc/src/router/v2-host/v2-host.ts | cat -n

Repository: superset-sh/superset

Length of output: 2426


🏁 Script executed:

# Check the transaction handling - look for transaction start and isolation level
sed -n '1,120p' packages/trpc/src/router/v2-host/v2-host.ts | grep -A 5 -B 5 "transaction\|readOnly"

Repository: superset-sh/superset

Length of output: 464


Race condition confirmed: concurrent removal of different owners both pass the check.

With exactly two owners (e.g., A and B), simultaneous removeMember calls targeting different users can defeat the invariant:

  • Tx1 (remove A): locks owner rows where userId != A (locks B). Finds 1 other owner. Passes check.
  • Tx2 (remove B): locks owner rows where userId != B (locks A). Finds 1 other owner. Passes check.
  • Both deletes commit → zero owners.

The issue: target is fetched without for("update"), so the two transactions don't conflict on the row being removed—only on disjoint sets of "other owners," which don't block each other.

Lock the target row in the transaction so concurrent operations serialize:

const [target] = await tx
    .select({ role: v2UsersHosts.role })
    .from(v2UsersHosts)
    .where(and(
        eq(v2UsersHosts.hostId, input.hostId),
        eq(v2UsersHosts.userId, input.userId),
    ))
    .for("update");  // ← Add this

The same fix applies to setMemberRole (line 225).

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

In `@packages/trpc/src/router/v2-host/v2-host.ts` around lines 149 - 167, The
target row is selected without a FOR UPDATE lock which allows two concurrent
removeMember transactions to each see an owner and both proceed; update the
SELECT that fetches `target` (the query that selects v2UsersHosts.role for the
given hostId and userId) to use .for("update") so the target row is locked in
the transaction before the other-owners check, and apply the identical change to
the analogous target selection in `setMemberRole` so concurrent role changes/
removals serialize correctly.

@saddlepaddle saddlepaddle merged commit 211af80 into main Apr 26, 2026
14 checks passed
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