diff --git a/apps/web/src/app/(app)/components/AppSidebar.tsx b/apps/web/src/app/(app)/components/AppSidebar.tsx index 6fc5decd41..f86beb8b4c 100644 --- a/apps/web/src/app/(app)/components/AppSidebar.tsx +++ b/apps/web/src/app/(app)/components/AppSidebar.tsx @@ -2,8 +2,11 @@ import { useEffect, useRef } from 'react'; import { usePathname, useSearchParams } from 'next/navigation'; -import { useSidebar, type Sidebar } from '@/components/ui/sidebar'; +import { useQuery } from '@tanstack/react-query'; +import { Sidebar, useSidebar } from '@/components/ui/sidebar'; import { useUrlOrganizationId } from '@/hooks/useUrlOrganizationId'; +import { useUser } from '@/hooks/useUser'; +import { useTRPC } from '@/lib/trpc/utils'; import PersonalAppSidebar from './PersonalAppSidebar'; import OrganizationAppSidebar from './OrganizationAppSidebar'; import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar'; @@ -11,6 +14,9 @@ import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar'; const UUID = '[0-9a-f-]{36}'; +// Routes linked from the footer user menu (see SidebarUserFooter). Keep in sync. +const FOOTER_MENU_ROUTES = ['/connected-accounts', '/install', '/learn']; + /** Extract the townId from a /gastown/[townId] pathname, or null. */ function extractGastownTownId(pathname: string): string | null { const match = pathname.match(new RegExp(`^/gastown/(${UUID})`)); @@ -44,11 +50,36 @@ function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId: } export default function AppSidebar(props: React.ComponentProps) { + const trpc = useTRPC(); const currentOrgId = useUrlOrganizationId(); + const { data: user } = useUser(); const pathname = usePathname(); const searchParams = useSearchParams(); const setupStep = searchParams.get('step'); const { open, setOpenMobile, setOpenTransient } = useSidebar(); + const personalAccountDisabled = Boolean(user?.personal_account_disabled); + // Routes we still link to for these users via the footer user menu. On these we + // keep them in their org sidebar instead of switching to the personal one; other + // personal routes are not linked but remain accessible with the personal sidebar + // if reached directly. + const isFooterMenuRoute = FOOTER_MENU_ROUTES.some( + route => pathname === route || pathname.startsWith(route + '/') + ); + const useOrgSidebarForFooterRoute = personalAccountDisabled && !currentOrgId && isFooterMenuRoute; + const { data: organizations, isPending: isOrganizationsPending } = useQuery( + trpc.organizations.list.queryOptions(undefined, { + enabled: useOrgSidebarForFooterRoute, + trpc: { context: { skipBatch: true } }, + }) + ); + // Match the server-side default (oldest org) so the sidebar org is consistent + // with getProfileRedirectPath. + const defaultOrganizationId = organizations?.length + ? [...organizations].sort((a, b) => { + const byCreatedAt = a.created_at.localeCompare(b.created_at); + return byCreatedAt !== 0 ? byCreatedAt : a.organizationId.localeCompare(b.organizationId); + })[0].organizationId + : null; const previousSidebarOpen = useRef(null); const currentSidebarOpen = useRef(open); const sidebarActions = useRef({ setOpenMobile, setOpenTransient }); @@ -122,6 +153,21 @@ export default function AppSidebar(props: React.ComponentProps) return ; } + // On routes we link to from the footer user menu, keep users with a disabled + // personal account in their default organization's sidebar rather than the + // personal one. Any other personal route falls through to the personal sidebar. + if (useOrgSidebarForFooterRoute) { + if (defaultOrganizationId) { + return ; + } + // Avoid flashing the personal sidebar while resolving their default org. + // Only fall through for the rare case of a user with a disabled personal + // account who belongs to no organizations. + if (isOrganizationsPending) { + return ; + } + } + // Otherwise render personal sidebar return ; } diff --git a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx index ca3150b6b3..dbf592b4e9 100644 --- a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx +++ b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'; import { Check, ChevronDown } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { useUser } from '@/hooks/useUser'; type OrganizationSwitcherProps = { organizationId?: string | null; @@ -29,6 +30,7 @@ type OrganizationSwitcherViewProps = { organizationId?: string | null; organizations?: OrganizationSwitcherOrganization[]; isPending?: boolean; + showPersonalOption?: boolean; onOrganizationSwitch: (organizationId: string | null) => void; }; @@ -51,6 +53,7 @@ const selectedIconClassName = 'text-primary h-4 w-4 shrink-0'; export default function OrganizationSwitcher({ organizationId = null }: OrganizationSwitcherProps) { const trpc = useTRPC(); const router = useRouter(); + const { data: user } = useUser(); // Fetch user organizations const { data: organizations, isPending } = useQuery( @@ -76,6 +79,7 @@ export default function OrganizationSwitcher({ organizationId = null }: Organiza organizationId={organizationId} organizations={organizations} isPending={isPending} + showPersonalOption={!user?.personal_account_disabled} onOrganizationSwitch={handleOrganizationSwitch} /> ); @@ -85,6 +89,7 @@ export function OrganizationSwitcherView({ organizationId = null, organizations = [], isPending = false, + showPersonalOption = true, onOrganizationSwitch, }: OrganizationSwitcherViewProps) { // Get role display label @@ -165,22 +170,26 @@ export function OrganizationSwitcherView({ ))} - {/* Separator */} - - - {/* Personal Option */} - onOrganizationSwitch(null)} - className={cn(menuItemClassName, !organizationId && selectedMenuItemClassName)} - > -
-
-
Personal
-
Personal Workspace
-
- {!organizationId && } -
-
+ {showPersonalOption && ( + <> + {/* Separator */} + + + {/* Personal Option */} + onOrganizationSwitch(null)} + className={cn(menuItemClassName, !organizationId && selectedMenuItemClassName)} + > +
+
+
Personal
+
Personal Workspace
+
+ {!organizationId && } +
+
+ + )} diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index cddea8f210..201c63b77b 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -9,7 +9,6 @@ import { Coins, Receipt, User, - UserCog, Building2, Plus, Rocket, @@ -20,8 +19,6 @@ import { List, Shield, ListChecks, - Download, - BookOpen, Key, Wrench, Webhook, @@ -275,11 +272,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps = [ - { - title: 'Install', - icon: Download, - url: '/install', - }, - { - title: 'Learn', - icon: BookOpen, - url: '/learn', - }, - ]; - const kiloClawBaseUrl = '/claw'; const kiloClawInstanceState = kiloClawNavStateQuery.isSuccess ? kiloClawNavStateQuery.data.hasActiveInstance @@ -369,7 +342,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps (typeof i === 'string' ? i : i.url)); return ( @@ -399,7 +371,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps )} - )} diff --git a/apps/web/src/app/(app)/components/SidebarUserFooter.tsx b/apps/web/src/app/(app)/components/SidebarUserFooter.tsx index 4cb184f4a1..b00b3a3016 100644 --- a/apps/web/src/app/(app)/components/SidebarUserFooter.tsx +++ b/apps/web/src/app/(app)/components/SidebarUserFooter.tsx @@ -1,11 +1,18 @@ 'use client'; -import { Button } from '@/components/ui/button'; import { SidebarFooter } from '@/components/ui/sidebar'; import { Skeleton } from '@/components/ui/skeleton'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar'; -import { LogOut } from 'lucide-react'; +import { BookOpen, ChevronsUpDown, Download, LogOut, UserCog } from 'lucide-react'; import { signOut } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; type User = { google_user_name: string; @@ -18,7 +25,18 @@ type SidebarUserFooterProps = { isLoading: boolean; }; +// Get user initials for avatar fallback +function getUserInitials(name: string) { + const parts = name.split(' '); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooterProps) { + const router = useRouter(); + const handleLogout = async () => { try { await fetch('/api/auth/revoke-web-session', { method: 'POST' }); @@ -27,15 +45,6 @@ export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooter } }; - // Get user initials for avatar fallback - const getUserInitials = (name: string) => { - const parts = name.split(' '); - if (parts.length >= 2) { - return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); - }; - return ( {isLoading ? ( @@ -48,31 +57,49 @@ export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooter ) : user ? ( -
- - - - {getUserInitials(user.google_user_name)} - - -
-

{user.google_user_name}

-

{user.google_user_email}

-
- -
+ router.push('/connected-accounts')}> + + Connected Accounts + + router.push('/install')}> + + Install + + router.push('/learn')}> + + Learn + + + + + Sign out + + + ) : null}
); diff --git a/apps/web/src/lib/user/server.test.ts b/apps/web/src/lib/user/server.test.ts index d412df0216..be2c1e8d94 100644 --- a/apps/web/src/lib/user/server.test.ts +++ b/apps/web/src/lib/user/server.test.ts @@ -562,4 +562,28 @@ describe('getProfileRedirectPath', () => { `/organizations/${pastDueOrganization.id}` ); }); + + describe('users with personal account disabled', () => { + test('redirects multi-organization users to one of their organizations', async () => { + const invitedUser = await insertTestUser({ + google_user_name: 'Invited Multi Org User', + personal_account_disabled: true, + }); + const orgA = await createTestOrganization('Invited Org A', invitedUser.id, 100_000); + const orgB = await createTestOrganization('Invited Org B', invitedUser.id, 100_000); + + await expect(getProfileRedirectPath(invitedUser)).resolves.toMatch( + new RegExp(`^/organizations/(${orgA.id}|${orgB.id})$`) + ); + }); + + test('falls back to connected accounts when the user has no organizations', async () => { + const orphanUser = await insertTestUser({ + google_user_name: 'Invited Orphan User', + personal_account_disabled: true, + }); + + await expect(getProfileRedirectPath(orphanUser)).resolves.toBe('/connected-accounts'); + }); + }); }); diff --git a/apps/web/src/lib/user/server.ts b/apps/web/src/lib/user/server.ts index d5253c8946..67c25ec2ab 100644 --- a/apps/web/src/lib/user/server.ts +++ b/apps/web/src/lib/user/server.ts @@ -45,7 +45,11 @@ import type { Organization, User } from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; import PostHogClient from '@/lib/posthog'; import { captureException } from '@sentry/nextjs'; -import { getSingleUserOrganization, isOrganizationMember } from '@/lib/organizations/organizations'; +import { + getSingleUserOrganization, + getUserOrganizationsWithSeats, + isOrganizationMember, +} from '@/lib/organizations/organizations'; import { resolveSsoAuthorityForDomain } from '@/lib/organizations/organization-sso-policy'; import type { AccountLinkingSession } from '@/lib/account-linking-session'; import { getAccountLinkingSession } from '@/lib/account-linking-session'; @@ -1058,6 +1062,20 @@ export async function getUserFromAuthOrRedirect( return user; } +// Resolve where a user whose personal account is disabled should land by default. +// Prefers their oldest organization (stable across requests); falls back to an +// allowed personal route when they somehow belong to no organizations. +// Note: this only affects where we send them by default (e.g. after login); we +// do not block direct navigation to personal routes. +async function resolvePersonalAccountDisabledLandingPath(userId: User['id']): Promise { + const orgs = await getUserOrganizationsWithSeats(userId); + const firstOrg = [...orgs].sort((a, b) => { + const byCreatedAt = a.created_at.localeCompare(b.created_at); + return byCreatedAt !== 0 ? byCreatedAt : a.organizationId.localeCompare(b.organizationId); + })[0]; + return firstOrg ? `/organizations/${firstOrg.organizationId}` : '/connected-accounts'; +} + export async function signInUrlWithCallbackPath(): Promise { return appendCallbackPath('/users/sign_in'); } @@ -1168,6 +1186,12 @@ export function getUserUUID(user: User): string { // the org page will be redirected to if the user is a member of exactly one organization // or if the org is SSO org export async function getProfileRedirectPath(user: User) { + // Users whose personal account is disabled have no personal surface; + // always send them into an organization regardless of org count. + if (user.personal_account_disabled) { + return resolvePersonalAccountDisabledLandingPath(user.id); + } + // Check if user is a member of exactly one organization (skip redirect if multiple) const singleOrg = await getSingleUserOrganization(user.id); if (singleOrg) {