Skip to content
Closed
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
44 changes: 20 additions & 24 deletions client/dashboard/src/components/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ export const AppLayout = () => {

return (
<SidebarProvider
// Sidebar runs full height (top: 0). The TopHeader now lives inside
// the content column, not above the sidebar — so --header-offset is
// zero by default, and only the impersonation banner shifts the
// sidebar down when present.
style={
{
"--sidebar-width": "14rem",
...(isImpersonating
? {
"--header-offset": "5.75rem",
"--banner-offset": "2.25rem",
}
: undefined),
"--header-offset": isImpersonating ? "2.25rem" : "0rem",
...(isImpersonating ? { "--banner-offset": "2.25rem" } : undefined),
} as React.CSSProperties
}
>
Expand Down Expand Up @@ -97,12 +97,12 @@ const AppLayoutContent = ({
isImpersonating: boolean;
}) => {
return (
<div className="flex h-screen w-full flex-col">
{isImpersonating && <ImpersonationBanner />}
<TopHeader />
<BrandGradientLine />
<div className="flex w-full flex-1 overflow-hidden pt-2">
<AppSidebar variant="inset" />
<div className="flex h-screen w-full">
<AppSidebar variant="inset" />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{isImpersonating && <ImpersonationBanner />}
<TopHeader />
<BrandGradientLine />
<SidebarInset>
<GlobalInsightsWrapper>
<MembershipSyncGuard>
Expand Down Expand Up @@ -208,22 +208,18 @@ export const OrgLayout = () => {
style={
{
"--sidebar-width": "14rem",
...(isImpersonating
? {
"--header-offset": "5.75rem",
"--banner-offset": "2.25rem",
}
: undefined),
"--header-offset": isImpersonating ? "2.25rem" : "0rem",
...(isImpersonating ? { "--banner-offset": "2.25rem" } : undefined),
} as React.CSSProperties
}
>
<ModalProvider>
<div className="flex h-screen w-full flex-col">
{isImpersonating && <ImpersonationBanner />}
<TopHeader />
<BrandGradientLine />
<div className="flex w-full flex-1 overflow-hidden pt-2">
<OrgSidebar variant="inset" />
<div className="flex h-screen w-full">
<OrgSidebar variant="inset" />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{isImpersonating && <ImpersonationBanner />}
<TopHeader />
<BrandGradientLine />
<SidebarInset>
<MembershipSyncGuard>
<Outlet />
Expand Down
15 changes: 12 additions & 3 deletions client/dashboard/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { SidebarBrand } from "@/components/sidebar-brand";
import { SidebarUserMenu } from "@/components/sidebar-user-menu";
import { useSlugs } from "@/contexts/Sdk";
import { useTelemetry } from "@/contexts/Telemetry";
import { Scope } from "@/hooks/useRBAC";
Expand Down Expand Up @@ -131,7 +134,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {

return (
<Sidebar collapsible="icon" {...props}>
<SidebarContent className="pt-5">
<SidebarHeader>
<SidebarBrand />
</SidebarHeader>
<SidebarContent className="pt-2">
<NavGroupProvider
activeGroup={activeGroup}
defaultOpenGroups={!activeGroup ? ["Connect", "Build"] : undefined}
Expand Down Expand Up @@ -239,8 +245,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Link>
</div>
</SidebarContent>
<SidebarFooter className="group-data-[collapsible=icon]:hidden">
<FreeTierExceededNotification />
<SidebarFooter className="gap-1">
<div className="group-data-[collapsible=icon]:hidden">
<FreeTierExceededNotification />
</div>
<SidebarUserMenu />
</SidebarFooter>
<FeatureRequestModal
isOpen={isUpgradeModalOpen}
Expand Down
12 changes: 11 additions & 1 deletion client/dashboard/src/components/org-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { RequireScope } from "@/components/require-scope";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { SidebarBrand } from "@/components/sidebar-brand";
import { SidebarUserMenu } from "@/components/sidebar-user-menu";
import { useIsAdmin } from "@/contexts/Auth";
import { Scope, useRBAC } from "@/hooks/useRBAC";
import { AppRoute, useOrgRoutes } from "@/routes";
Expand Down Expand Up @@ -97,7 +101,10 @@ export function OrgSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {

return (
<Sidebar collapsible="icon" {...props}>
<SidebarContent className="pt-5">
<SidebarHeader>
<SidebarBrand />
</SidebarHeader>
<SidebarContent className="pt-2">
<NavGroupProvider
activeGroup={activeGroup}
defaultOpenGroups={["Settings", "Secure"]}
Expand Down Expand Up @@ -172,6 +179,9 @@ export function OrgSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</NavGroupProvider>
</SidebarContent>
<SidebarFooter>
<SidebarUserMenu />
</SidebarFooter>
</Sidebar>
);
}
31 changes: 31 additions & 0 deletions client/dashboard/src/components/sidebar-brand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Link } from "react-router";

import { useOrganization } from "@/contexts/Auth";
import { useRBAC } from "@/hooks/useRBAC";

import { GramLogo } from "./gram-logo";

/**
* Top-of-sidebar brand strip: Gram logo, then a hairline divider with the
* current org slug below. Mounts inside <SidebarHeader>. When the sidebar
* collapses to icons we hide the wordmark.
*/
export function SidebarBrand() {
const organization = useOrganization();
const { hasAnyScope } = useRBAC();
const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]);

return (
<div className="flex flex-col gap-2 px-2 pt-2 pb-1">
<Link
to={canAccessOrgRoutes ? `/${organization.slug}` : "#"}
className="flex items-center gap-2 hover:no-underline"
>
<GramLogo className="w-24 group-data-[collapsible=icon]:w-6" />
</Link>
<div className="text-muted-foreground truncate px-1 text-xs font-medium group-data-[collapsible=icon]:hidden">
{organization.slug}
</div>
</div>
);
}
165 changes: 165 additions & 0 deletions client/dashboard/src/components/sidebar-user-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useCallback, useState } from "react";

import { useIsAdmin, useUser } from "@/contexts/Auth";
import { useSdkClient, useSlugs } from "@/contexts/Sdk";
import { useRBAC } from "@/hooks/useRBAC";
import { useOrgRoutes, useRoutes } from "@/routes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
ThemeSwitcher,
} from "@speakeasy-api/moonshine";
import {
ArrowRightLeftIcon,
BugIcon,
CreditCardIcon,
LogOutIcon,
MailIcon,
MessageCircleIcon,
SettingsIcon,
} from "lucide-react";

import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";

export function SidebarUserMenu() {
const routes = useRoutes();
const orgRoutes = useOrgRoutes();
const user = useUser();
const isAdmin = useIsAdmin();
const client = useSdkClient();
const { projectSlug } = useSlugs();
const { hasAnyScope } = useRBAC();
const canAccessOrgRoutes = hasAnyScope(["org:read", "org:admin"]);
const [pylonOpen, setPylonOpen] = useState(false);

const togglePylon = useCallback(() => {
if (pylonOpen) {
window.Pylon?.("hide");
} else {
window.Pylon?.("show");
}
setPylonOpen((prev) => !prev);
}, [pylonOpen]);

const userInitials =
user.displayName
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) ||
user.email?.slice(0, 2).toUpperCase() ||
"?";

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="hover:bg-sidebar-accent flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors group-data-[collapsible=icon]:justify-center"
>
<Avatar className="size-7 shrink-0">
<AvatarImage
src={user.photoUrl}
alt={user.displayName || user.email}
/>
<AvatarFallback className="text-[10px]">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className="text-foreground truncate text-sm font-medium">
{user.displayName || "User"}
</div>
<div className="text-muted-foreground truncate text-xs">
{user.email}
</div>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
className="w-56"
sideOffset={8}
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">
{user.displayName || "User"}
</p>
<p className="text-muted-foreground text-xs leading-none">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{projectSlug && (
<DropdownMenuItem onClick={() => routes.settings.goTo()}>
<SettingsIcon className="mr-2 h-4 w-4" />
Project Settings
</DropdownMenuItem>
)}
{canAccessOrgRoutes && (
<DropdownMenuItem onClick={() => orgRoutes.billing.goTo()}>
<CreditCardIcon className="mr-2 h-4 w-4" />
Billing
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => orgRoutes.adminSettings.goTo()}>
<ArrowRightLeftIcon className="mr-2 h-4 w-4" />
Organization Override
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{"Pylon" in window && (
<DropdownMenuItem onClick={togglePylon}>
<MessageCircleIcon className="mr-2 h-4 w-4" />
{pylonOpen ? "Close Support" : "Get Support"}
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<a href="mailto:gram@speakeasy.com">
<MailIcon className="mr-2 h-4 w-4" />
Email Team
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="https://github.com/speakeasy-api/gram/issues/new"
target="_blank"
rel="noopener noreferrer"
>
<BugIcon className="mr-2 h-4 w-4" />
Bug or Feature Request
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-muted-foreground text-xs">Theme</span>
<ThemeSwitcher />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
await client.auth.logout();
window.location.href = "/login";
}}
>
<LogOutIcon className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading
Loading