Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion apps/web/src/app/(app)/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

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';
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})`));
Expand Down Expand Up @@ -44,11 +50,36 @@ function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId:
}

export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
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<boolean | null>(null);
const currentSidebarOpen = useRef(open);
const sidebarActions = useRef({ setOpenMobile, setOpenTransient });
Expand Down Expand Up @@ -122,6 +153,21 @@ export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>)
return <OrganizationAppSidebar organizationId={currentOrgId} {...props} />;
}

// 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 <OrganizationAppSidebar organizationId={defaultOrganizationId} {...props} />;
}
// 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 <Sidebar {...props} />;
}
}

// Otherwise render personal sidebar
return <PersonalAppSidebar {...props} />;
}
41 changes: 25 additions & 16 deletions apps/web/src/app/(app)/components/OrganizationSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@ type OrganizationSwitcherViewProps = {
organizationId?: string | null;
organizations?: OrganizationSwitcherOrganization[];
isPending?: boolean;
showPersonalOption?: boolean;
onOrganizationSwitch: (organizationId: string | null) => void;
};

Expand All @@ -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(
Expand All @@ -76,6 +79,7 @@ export default function OrganizationSwitcher({ organizationId = null }: Organiza
organizationId={organizationId}
organizations={organizations}
isPending={isPending}
showPersonalOption={!user?.personal_account_disabled}
onOrganizationSwitch={handleOrganizationSwitch}
/>
);
Expand All @@ -85,6 +89,7 @@ export function OrganizationSwitcherView({
organizationId = null,
organizations = [],
isPending = false,
showPersonalOption = true,
onOrganizationSwitch,
}: OrganizationSwitcherViewProps) {
// Get role display label
Expand Down Expand Up @@ -165,22 +170,26 @@ export function OrganizationSwitcherView({
</DropdownMenuItem>
))}

{/* Separator */}
<DropdownMenuSeparator />

{/* Personal Option */}
<DropdownMenuItem
onClick={() => onOrganizationSwitch(null)}
className={cn(menuItemClassName, !organizationId && selectedMenuItemClassName)}
>
<div className={switcherRowClassName}>
<div className={switcherTextClassName}>
<div className={switcherTitleClassName}>Personal</div>
<div className={switcherSubtitleClassName}>Personal Workspace</div>
</div>
{!organizationId && <Check className={selectedIconClassName} />}
</div>
</DropdownMenuItem>
{showPersonalOption && (
<>
{/* Separator */}
<DropdownMenuSeparator />

{/* Personal Option */}
<DropdownMenuItem
onClick={() => onOrganizationSwitch(null)}
className={cn(menuItemClassName, !organizationId && selectedMenuItemClassName)}
>
<div className={switcherRowClassName}>
<div className={switcherTextClassName}>
<div className={switcherTitleClassName}>Personal</div>
<div className={switcherSubtitleClassName}>Personal Workspace</div>
</div>
{!organizationId && <Check className={selectedIconClassName} />}
</div>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
29 changes: 0 additions & 29 deletions apps/web/src/app/(app)/components/PersonalAppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
Coins,
Receipt,
User,
UserCog,
Building2,
Plus,
Rocket,
Expand All @@ -20,8 +19,6 @@ import {
List,
Shield,
ListChecks,
Download,
BookOpen,
Key,
Wrench,
Webhook,
Expand Down Expand Up @@ -275,37 +272,13 @@ export default function PersonalAppSidebar(props: React.ComponentProps<typeof Si
icon: Coins,
url: '/credits',
},
{
title: 'Connected Accounts',
icon: UserCog,
url: '/connected-accounts',
},
{
title: 'Bring Your Own Key (BYOK)',
icon: Key,
url: '/byok',
},
];

// Start group
const startItems: Array<{
title: string;
icon: React.ElementType;
url: string;
className?: string;
}> = [
{
title: 'Install',
icon: Download,
url: '/install',
},
{
title: 'Learn',
icon: BookOpen,
url: '/learn',
},
];

const kiloClawBaseUrl = '/claw';
const kiloClawInstanceState = kiloClawNavStateQuery.isSuccess
? kiloClawNavStateQuery.data.hasActiveInstance
Expand Down Expand Up @@ -369,7 +342,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps<typeof Si
...kiloClawItems,
...cloudItems,
...accountItems,
...startItems,
].map(i => (typeof i === 'string' ? i : i.url));

return (
Expand Down Expand Up @@ -399,7 +371,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps<typeof Si
<SidebarMenuList label="Cloud" items={cloudItems} allUrls={allUrls} />
)}
<SidebarMenuList label="Account" items={accountItems} allUrls={allUrls} />
<SidebarMenuList label="Start" items={startItems} allUrls={allUrls} />
</>
)}
</SidebarContent>
Expand Down
97 changes: 62 additions & 35 deletions apps/web/src/app/(app)/components/SidebarUserFooter.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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' });
Expand All @@ -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 (
<SidebarFooter className="p-4">
{isLoading ? (
Expand All @@ -48,31 +57,49 @@ export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooter
<Skeleton className="h-8 w-8" />
</div>
) : user ? (
<div className="flex items-center gap-3 p-2">
<Avatar className="bg-surface-overlay h-8 w-8 overflow-hidden rounded-full border border-border text-foreground">
<AvatarImage
src={user.google_user_image_url}
alt={user.google_user_name}
className="h-full w-full object-cover"
/>
<AvatarFallback className="bg-surface-overlay flex h-full w-full items-center justify-center text-sm font-medium">
{getUserInitials(user.google_user_name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.google_user_name}</p>
<p className="text-muted-foreground truncate text-xs">{user.google_user_email}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleLogout}
title="Sign out"
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex w-full items-center gap-3 rounded-md p-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="bg-surface-overlay h-8 w-8 overflow-hidden rounded-full border border-border text-foreground">
<AvatarImage
src={user.google_user_image_url}
alt={user.google_user_name}
className="h-full w-full object-cover"
/>
<AvatarFallback className="bg-surface-overlay flex h-full w-full items-center justify-center text-sm font-medium">
{getUserInitials(user.google_user_name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.google_user_name}</p>
<p className="text-muted-foreground truncate text-xs">{user.google_user_email}</p>
</div>
<ChevronsUpDown className="text-muted-foreground h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56"
align="start"
side="top"
sideOffset={4}
>
<LogOut className="h-4 w-4" />
</Button>
</div>
<DropdownMenuItem onClick={() => router.push('/connected-accounts')}>
<UserCog className="h-4 w-4" />
Connected Accounts
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/install')}>
<Download className="h-4 w-4" />
Install
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/learn')}>
<BookOpen className="h-4 w-4" />
Learn
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</SidebarFooter>
);
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/user/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading