From 6a790246b74d0c790e562e6110503d58917c0ccb Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 16:40:47 -0700 Subject: [PATCH 01/33] feat(nav): organizations, products & admin as account-dropdown submenus Convert the flat Organizations / Products / Admin sections of the account dropdown into hover submenus: - Organizations: first 5 orgs (by name) then "Add Organization" (when permitted) - Products: first 5 owned products then "All Products" then "Create Product" (when permitted) - Admin: the admin tools (admins only) Resolve org names and owned products server-side in AuthButtons (memberships carry only ids) and pass them to the client dropdown. Add a reusable DropdownSubmenu component sharing a DropdownItems renderer with DropdownSection. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 46 +++++-- src/components/layout/AuthButtons.tsx | 33 ++++- src/components/layout/DropdownSection.tsx | 114 ++++++++++++------ .../layout/__tests__/DropdownSubmenu.test.tsx | 78 ++++++++++++ 4 files changed, 225 insertions(+), 46 deletions(-) create mode 100644 src/components/layout/__tests__/DropdownSubmenu.test.tsx diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 517314c7..3dfeb2db 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -10,8 +10,10 @@ import { editAccountProfileUrl, newOrganizationUrl, newProductUrl, + productUrl, + productListUrl, } from "@/lib"; -import { DropdownSection } from "./DropdownSection"; +import { DropdownSection, DropdownSubmenu } from "./DropdownSection"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; import { ADMIN_TOOLS } from "@/components/features/admin/tools"; import { Actions, UserSession } from "@/types"; @@ -29,7 +31,26 @@ export function AccountDropdownSkeleton() { ); } -export function AccountDropdown({ session }: { session: UserSession }) { +export interface DropdownOrganization { + account_id: string; + name: string; +} + +export interface DropdownProduct { + account_id: string; + product_id: string; + title: string; +} + +export function AccountDropdown({ + session, + organizations = [], + products = [], +}: { + session: UserSession; + organizations?: DropdownOrganization[]; + products?: DropdownProduct[]; +}) { const [isOpen, setIsOpen] = useState(false); const handleLogout = async () => { @@ -83,19 +104,28 @@ export function AccountDropdown({ session }: { session: UserSession }) { }, ]} /> - ({ + href: accountUrl(org.account_id), + children: org.name, + }))} + actions={[ { href: newOrganizationUrl(session.account!.account_id), - children: "Create Organization", + children: "Add Organization", condition: isAuthorized(session, "*", Actions.CreateAccount), }, ]} /> - ({ + href: productUrl(product.account_id, product.product_id), + children: product.title, + }))} + actions={[ + { href: productListUrl(), children: "All Products" }, { href: newProductUrl(), children: "Create Product", @@ -103,7 +133,7 @@ export function AccountDropdown({ session }: { session: UserSession }) { }, ]} /> - ({ diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 4aa87f0b..85de1823 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -1,5 +1,10 @@ import { AccountDropdown } from "./AccountDropdown"; import { getPageSession } from "@/lib/api/utils"; +import { + accountsTable, + isOrganizationalAccount, + productsTable, +} from "@/lib/clients/database"; import { Button, Callout, Link } from "@radix-ui/themes"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { loginUrl, onboardingUrl } from "@/lib/urls"; @@ -9,7 +14,33 @@ export async function AuthButtons() { const session = await getPageSession(); if (session?.account) { - return ; + // Organizations the user belongs to (memberships carry only ids → resolve + // the org accounts for their display names) and the products they own. + const [organizations, { products }] = await Promise.all([ + accountsTable + .fetchManyByIds( + (session.memberships ?? []).map((m) => m.membership_account_id) + ) + .then((accounts) => + accounts.filter(isOrganizationalAccount).map((a) => ({ + account_id: a.account_id, + name: a.name, + })) + ), + productsTable.listByAccount(session.account.account_id, 5), + ]); + + return ( + ({ + account_id: p.account_id, + product_id: p.product_id, + title: p.title, + }))} + /> + ); } if (session && !session.account) { diff --git a/src/components/layout/DropdownSection.tsx b/src/components/layout/DropdownSection.tsx index e16314a1..1f7f2728 100644 --- a/src/components/layout/DropdownSection.tsx +++ b/src/components/layout/DropdownSection.tsx @@ -24,6 +24,48 @@ export interface DropdownItem { condition?: boolean; } +// Render a list of menu items, honoring each item's `condition`. Shared by the +// flat DropdownSection and the DropdownSubmenu so links/anchors behave the same +// in both. +function DropdownItems({ items }: { items: DropdownItem[] }) { + return items + // Keep the original array index so the React key is stable when an + // item's `condition` toggles — filtering on its own would renumber + // surviving items and let React reconcile the wrong node. + .map((item, index) => ({ item, index })) + .filter(({ item: { condition = true } }) => condition) + .map(({ item, index }) => + item.href ? ( + // `asChild` makes the itself the menu item: one real anchor + // handles left-click (Next client-side nav), keyboard activation + // (Radix triggers the anchor), and open-in-new-tab — a single + // navigation with no duplicate history entry. + + + {item.children} + + + ) : ( + + {item.children} + + ) + ); +} + +const visibleCount = (items: DropdownItem[]) => + items.filter(({ condition = true }) => condition).length; + export interface DropdownSectionProps { label?: string; items: DropdownItem[]; @@ -38,50 +80,48 @@ export function DropdownSection({ condition = true, }: DropdownSectionProps) { // Only render if condition is true and there are items to show - if ( - !condition || - items.filter(({ condition = true }) => condition).length === 0 - ) { + if (!condition || visibleCount(items) === 0) { return null; } return ( <> {label && {label}} - {items - // Keep the original array index so the React key is stable when an - // item's `condition` toggles — filtering on its own would renumber - // surviving items and let React reconcile the wrong node. - .map((item, index) => ({ item, index })) - .filter(({ item: { condition = true } }) => condition) - .map(({ item, index }) => - item.href ? ( - // `asChild` makes the itself the menu item: one real anchor - // handles left-click (Next client-side nav), keyboard activation - // (Radix triggers the anchor), and open-in-new-tab — a single - // navigation with no duplicate history entry. - - - {item.children} - - - ) : ( - - {item.children} - - ) - )} + {showSeparator && } ); } + +export interface DropdownSubmenuProps { + label: ReactNode; + /** Listed first (e.g. the user's organizations/products). */ + items: DropdownItem[]; + /** Shown after a separator (e.g. "All Products", "Create…"). */ + actions?: DropdownItem[]; + condition?: boolean; +} + +export function DropdownSubmenu({ + label, + items, + actions = [], + condition = true, +}: DropdownSubmenuProps) { + const hasItems = visibleCount(items) > 0; + const hasActions = visibleCount(actions) > 0; + if (!condition || (!hasItems && !hasActions)) { + return null; + } + + return ( + + {label} + + + {hasItems && hasActions && } + + + + ); +} diff --git a/src/components/layout/__tests__/DropdownSubmenu.test.tsx b/src/components/layout/__tests__/DropdownSubmenu.test.tsx new file mode 100644 index 00000000..3fbbb981 --- /dev/null +++ b/src/components/layout/__tests__/DropdownSubmenu.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import { DropdownSubmenu } from "../DropdownSection"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +// Mirror DropdownSection.test.tsx: mock the Radix primitives down to plain DOM +// so we assert structure (which items/actions render, and the separator +// between them) without the real popper/Slot machinery. +jest.mock("@radix-ui/themes", () => ({ + DropdownMenu: { + Sub: ({ children }: { children: React.ReactNode }) =>
{children}
, + SubTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SubContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Item: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => + asChild ? <>{children} :
{children}
, + Separator: () =>
, + }, +})); + +describe("DropdownSubmenu", () => { + it("renders the label, items, then actions", () => { + render( + + ); + expect(screen.getByText("Organizations")).toBeInTheDocument(); + expect(screen.getByText("Acme")).toBeInTheDocument(); + expect(screen.getByText("Add Organization")).toBeInTheDocument(); + // separator only when there are BOTH items and actions + expect(screen.getByTestId("separator")).toBeInTheDocument(); + }); + + it("omits the separator when there are no items (actions only)", () => { + render( + + ); + expect(screen.getByText("All Products")).toBeInTheDocument(); + expect(screen.queryByTestId("separator")).not.toBeInTheDocument(); + }); + + it("renders nothing when condition is false", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when no items or actions are visible", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); +}); From 78a8c72fd32b132c943702be478bb53876f15a8b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 16:55:03 -0700 Subject: [PATCH 02/33] feat(nav): profile submenu, + icons, truncation & logout divider Address dropdown redesign feedback: - Move View/Edit Profile behind a "Profile" submenu - "Create Organization" / "Create Product" now use a consistent verb and a leading + icon - Always show a divider before Logout (drop UploadsSubmenu's now-redundant trailing separator so it isn't doubled when uploads are active) - Truncate long org/product names so one entry can't blow out the menu width Co-Authored-By: Claude Opus 4.8 --- .../features/uploader/UploadsSubmenu.tsx | 2 -- src/components/layout/AccountDropdown.tsx | 36 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/components/features/uploader/UploadsSubmenu.tsx b/src/components/features/uploader/UploadsSubmenu.tsx index 861afd86..1e637525 100644 --- a/src/components/features/uploader/UploadsSubmenu.tsx +++ b/src/components/features/uploader/UploadsSubmenu.tsx @@ -6,7 +6,6 @@ import { DropdownMenu, Text, Badge, - Box, Progress, IconButton, ScrollArea, @@ -152,7 +151,6 @@ export function UploadsSubmenu() { - ); } diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 3dfeb2db..6ebecba6 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; +import { useState, type CSSProperties } from "react"; import { Flex, DropdownMenu, Text, Box } from "@radix-ui/themes"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { ChevronDownIcon, PlusIcon } from "@radix-ui/react-icons"; import styles from "./Navigation.module.css"; import { LOGGER, @@ -31,6 +31,15 @@ export function AccountDropdownSkeleton() { ); } +// Cap long org/product names so a single entry can't blow out the menu width. +const truncateStyle: CSSProperties = { + display: "block", + maxWidth: 220, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + export interface DropdownOrganization { account_id: string; name: string; @@ -91,8 +100,8 @@ export function AccountDropdown({ - ({ href: accountUrl(org.account_id), - children: org.name, + children: {org.name}, }))} actions={[ { href: newOrganizationUrl(session.account!.account_id), - children: "Add Organization", + children: ( + <> + + Create Organization + + ), condition: isAuthorized(session, "*", Actions.CreateAccount), }, ]} @@ -122,13 +136,18 @@ export function AccountDropdown({ label="Products" items={products.slice(0, 5).map((product) => ({ href: productUrl(product.account_id, product.product_id), - children: product.title, + children: {product.title}, }))} actions={[ { href: productListUrl(), children: "All Products" }, { href: newProductUrl(), - children: "Create Product", + children: ( + <> + + Create Product + + ), condition: isAuthorized(session, "*", Actions.CreateRepository), }, ]} @@ -142,6 +161,7 @@ export function AccountDropdown({ }))} /> + Date: Tue, 30 Jun 2026 16:57:24 -0700 Subject: [PATCH 03/33] fix(nav): only list accepted org memberships in the dropdown session.memberships includes Invited records (authorized for GetMembership), so an outstanding invite was surfaced as an org the user already belongs to. Filter to state === Member, matching the established pattern in lookups.getManageableAccounts and products/new. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AuthButtons.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 85de1823..55365ad5 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -7,6 +7,7 @@ import { } from "@/lib/clients/database"; import { Button, Callout, Link } from "@radix-ui/themes"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { MembershipState } from "@/types"; import { loginUrl, onboardingUrl } from "@/lib/urls"; import { getReturnToUrl } from "@/lib/baseUrl"; @@ -16,10 +17,14 @@ export async function AuthButtons() { if (session?.account) { // Organizations the user belongs to (memberships carry only ids → resolve // the org accounts for their display names) and the products they own. + // Only accepted memberships count — an outstanding invite (state Invited) + // must not show as an org the user already belongs to. const [organizations, { products }] = await Promise.all([ accountsTable .fetchManyByIds( - (session.memberships ?? []).map((m) => m.membership_account_id) + (session.memberships ?? []) + .filter((m) => m.state === MembershipState.Member) + .map((m) => m.membership_account_id) ) .then((accounts) => accounts.filter(isOrganizationalAccount).map((a) => ({ From 4577260c6b83524937f66406089ac54ee5fe2ef7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:07:15 -0700 Subject: [PATCH 04/33] feat(nav): pending-invitation notifications + prominent Products link Pending invitations: - Red dot on the account avatar when the user has pending invites - "Invitations" section (dot-marked) at the top of the dropdown listing each invite; clicking navigates to the org/product page whose PendingInvitationBanner lets the user accept/decline - Resolve org names and product titles server-side in AuthButtons; a small pure invitationLink() (unit-tested) picks the org- vs product-page route Products link: - Add a prominent "Products" link in the nav next to the account dropdown - Drop the now-redundant "All Products" entry from the Products submenu Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 49 +++++++++++++- src/components/layout/AuthButtons.tsx | 64 ++++++++++++++----- src/components/layout/DropdownSection.tsx | 2 +- src/components/layout/Navigation.tsx | 14 +++- .../layout/__tests__/accountMenu.test.ts | 30 +++++++++ src/components/layout/accountMenu.ts | 30 +++++++++ 6 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 src/components/layout/__tests__/accountMenu.test.ts create mode 100644 src/components/layout/accountMenu.ts diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 6ebecba6..4a563739 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -11,7 +11,6 @@ import { newOrganizationUrl, newProductUrl, productUrl, - productListUrl, } from "@/lib"; import { DropdownSection, DropdownSubmenu } from "./DropdownSection"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; @@ -40,6 +39,15 @@ const truncateStyle: CSSProperties = { whiteSpace: "nowrap", }; +// Red dot rendered inline next to the "Invitations" label. +const inlineDotStyle: CSSProperties = { + display: "inline-block", + width: 8, + height: 8, + borderRadius: "50%", + backgroundColor: "var(--red-9)", +}; + export interface DropdownOrganization { account_id: string; name: string; @@ -51,16 +59,24 @@ export interface DropdownProduct { title: string; } +export interface DropdownInvitation { + href: string; + label: string; +} + export function AccountDropdown({ session, organizations = [], products = [], + pendingInvitations = [], }: { session: UserSession; organizations?: DropdownOrganization[]; products?: DropdownProduct[]; + pendingInvitations?: DropdownInvitation[]; }) { const [isOpen, setIsOpen] = useState(false); + const hasInvitations = pendingInvitations.length > 0; const handleLogout = async () => { const response = await fetch(CONFIG.auth.routes.logout, { @@ -88,6 +104,21 @@ export function AccountDropdown({ + {hasInvitations && ( + + )} {session.account!.name} @@ -100,6 +131,21 @@ export function AccountDropdown({ + + Invitations + + + } + items={pendingInvitations.slice(0, 5).map((invitation) => ({ + href: invitation.href, + children: {invitation.label}, + }))} + /> {product.title}, }))} actions={[ - { href: productListUrl(), children: "All Products" }, { href: newProductUrl(), children: ( diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 55365ad5..b4e65f7f 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -10,31 +10,60 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { MembershipState } from "@/types"; import { loginUrl, onboardingUrl } from "@/lib/urls"; import { getReturnToUrl } from "@/lib/baseUrl"; +import { invitationLink } from "./accountMenu"; export async function AuthButtons() { const session = await getPageSession(); if (session?.account) { - // Organizations the user belongs to (memberships carry only ids → resolve - // the org accounts for their display names) and the products they own. - // Only accepted memberships count — an outstanding invite (state Invited) - // must not show as an org the user already belongs to. - const [organizations, { products }] = await Promise.all([ - accountsTable - .fetchManyByIds( - (session.memberships ?? []) - .filter((m) => m.state === MembershipState.Member) - .map((m) => m.membership_account_id) - ) - .then((accounts) => - accounts.filter(isOrganizationalAccount).map((a) => ({ - account_id: a.account_id, - name: a.name, - })) - ), + const memberships = session.memberships ?? []; + // Accepted org memberships vs outstanding invites: an invite must not read + // as an org the user already belongs to — it goes in "Invitations" instead. + const memberOrgIds = new Set( + memberships + .filter((m) => m.state === MembershipState.Member) + .map((m) => m.membership_account_id) + ); + const invited = memberships.filter( + (m) => m.state === MembershipState.Invited + ); + + const [accounts, { products }, invitedProducts] = await Promise.all([ + // One batched account read resolves both member-org and invite-org names. + accountsTable.fetchManyByIds([ + ...memberOrgIds, + ...invited.map((m) => m.membership_account_id), + ]), productsTable.listByAccount(session.account.account_id, 5), + // Product invites need the product title; usually there are none. + Promise.all( + invited + .filter((m) => m.repository_id) + .map((m) => + productsTable.fetchById(m.membership_account_id, m.repository_id!) + ) + ), ]); + const accountsById = new Map(accounts.map((a) => [a.account_id, a])); + const productTitleByKey = new Map(); + for (const p of invitedProducts) { + if (p) productTitleByKey.set(`${p.account_id}/${p.product_id}`, p.title); + } + + const organizations = accounts + .filter((a) => memberOrgIds.has(a.account_id) && isOrganizationalAccount(a)) + .map((a) => ({ account_id: a.account_id, name: a.name })); + + const pendingInvitations = invited.map((m) => + invitationLink(m, { + organizationName: accountsById.get(m.membership_account_id)?.name, + productTitle: m.repository_id + ? productTitleByKey.get(`${m.membership_account_id}/${m.repository_id}`) + : undefined, + }) + ); + return ( ); } diff --git a/src/components/layout/DropdownSection.tsx b/src/components/layout/DropdownSection.tsx index 1f7f2728..852b945d 100644 --- a/src/components/layout/DropdownSection.tsx +++ b/src/components/layout/DropdownSection.tsx @@ -67,7 +67,7 @@ const visibleCount = (items: DropdownItem[]) => items.filter(({ condition = true }) => condition).length; export interface DropdownSectionProps { - label?: string; + label?: ReactNode; items: DropdownItem[]; showSeparator?: boolean; condition?: boolean; diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 30860b43..2695e6ec 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -1,9 +1,11 @@ -import { Container, Flex } from "@radix-ui/themes"; +import { Container, Flex, Link } from "@radix-ui/themes"; +import NextLink from "next/link"; import { Logo } from "./Logo"; import styles from "./Navigation.module.css"; import { Suspense } from "react"; import { AuthButtons } from "./AuthButtons"; import { AccountDropdownSkeleton } from "./AccountDropdown"; +import { productListUrl } from "@/lib"; export async function Navigation() { return ( @@ -13,6 +15,16 @@ export async function Navigation() { + + Products + }> diff --git a/src/components/layout/__tests__/accountMenu.test.ts b/src/components/layout/__tests__/accountMenu.test.ts new file mode 100644 index 00000000..d1cf6237 --- /dev/null +++ b/src/components/layout/__tests__/accountMenu.test.ts @@ -0,0 +1,30 @@ +import { invitationLink } from "../accountMenu"; + +describe("invitationLink", () => { + it("routes an org invite to the org page, using the org name", () => { + expect( + invitationLink( + { membership_account_id: "acme", repository_id: undefined }, + { organizationName: "Acme Corp" } + ) + ).toEqual({ href: "/acme", label: "Acme Corp" }); + }); + + it("routes a product invite to the product page, using the product title", () => { + expect( + invitationLink( + { membership_account_id: "acme", repository_id: "roads" }, + { productTitle: "Road Network" } + ) + ).toEqual({ href: "/acme/roads", label: "Road Network" }); + }); + + it("falls back to ids when names are missing", () => { + expect( + invitationLink({ membership_account_id: "acme", repository_id: undefined }, {}) + ).toEqual({ href: "/acme", label: "acme" }); + expect( + invitationLink({ membership_account_id: "acme", repository_id: "roads" }, {}) + ).toEqual({ href: "/acme/roads", label: "acme/roads" }); + }); +}); diff --git a/src/components/layout/accountMenu.ts b/src/components/layout/accountMenu.ts new file mode 100644 index 00000000..5530c270 --- /dev/null +++ b/src/components/layout/accountMenu.ts @@ -0,0 +1,30 @@ +import { accountUrl, productUrl } from "@/lib/urls"; +import type { Membership } from "@/types"; + +export interface InvitationLink { + href: string; + label: string; +} + +/** + * Where a pending invitation should link so the user can accept it: the product + * page for a product invite (repository_id set), otherwise the organization + * page. Both pages render the PendingInvitationBanner. Falls back to ids when a + * display name couldn't be resolved. + */ +export function invitationLink( + membership: Pick, + names: { organizationName?: string; productTitle?: string } +): InvitationLink { + const { membership_account_id, repository_id } = membership; + if (repository_id) { + return { + href: productUrl(membership_account_id, repository_id), + label: names.productTitle ?? `${membership_account_id}/${repository_id}`, + }; + } + return { + href: accountUrl(membership_account_id), + label: names.organizationName ?? membership_account_id, + }; +} From 6e74cd96a0b05e7e735a5c46b2fa8a0780d93a9c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:09:40 -0700 Subject: [PATCH 05/33] feat(nav): order Products above Organizations in the dropdown Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 4a563739..f3c10584 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -160,40 +160,40 @@ export function AccountDropdown({ ]} /> ({ - href: accountUrl(org.account_id), - children: {org.name}, + label="Products" + items={products.slice(0, 5).map((product) => ({ + href: productUrl(product.account_id, product.product_id), + children: {product.title}, }))} actions={[ { - href: newOrganizationUrl(session.account!.account_id), + href: newProductUrl(), children: ( <> - Create Organization + Create Product ), - condition: isAuthorized(session, "*", Actions.CreateAccount), + condition: isAuthorized(session, "*", Actions.CreateRepository), }, ]} /> ({ - href: productUrl(product.account_id, product.product_id), - children: {product.title}, + label="Organizations" + items={organizations.slice(0, 5).map((org) => ({ + href: accountUrl(org.account_id), + children: {org.name}, }))} actions={[ { - href: newProductUrl(), + href: newOrganizationUrl(session.account!.account_id), children: ( <> - Create Product + Create Organization ), - condition: isAuthorized(session, "*", Actions.CreateRepository), + condition: isAuthorized(session, "*", Actions.CreateAccount), }, ]} /> From 17b8737030dd181731316c3a3908f86e99a3fde2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:14:30 -0700 Subject: [PATCH 06/33] feat(nav): make Invitations a submenu Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 2 +- src/components/layout/DropdownSection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index f3c10584..2a050986 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -131,7 +131,7 @@ export function AccountDropdown({ - items.filter(({ condition = true }) => condition).length; export interface DropdownSectionProps { - label?: ReactNode; + label?: string; items: DropdownItem[]; showSeparator?: boolean; condition?: boolean; From 2e70ae5c448a34ff8b5b38832cef4ff0b282432a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:15:59 -0700 Subject: [PATCH 07/33] style(nav): differentiate entity names; make Products link match username - Org/product/invitation names render at medium weight so they read as data, distinct from the menu commands around them - Nav Products link: match the username's size (3) and drop the bold weight for a more prominent, less heavy look Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 13 ++++++++----- src/components/layout/Navigation.tsx | 9 +-------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 2a050986..ac7b54d7 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -30,10 +30,13 @@ export function AccountDropdownSkeleton() { ); } -// Cap long org/product names so a single entry can't blow out the menu width. -const truncateStyle: CSSProperties = { +// Org/product/invitation names are user data, not menu commands — render them +// heavier so they read distinctly from the surrounding options, and cap the +// width so one long name can't blow out the menu. +const entityNameStyle: CSSProperties = { display: "block", maxWidth: 220, + fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -143,7 +146,7 @@ export function AccountDropdown({ } items={pendingInvitations.slice(0, 5).map((invitation) => ({ href: invitation.href, - children: {invitation.label}, + children: {invitation.label}, }))} /> ({ href: productUrl(product.account_id, product.product_id), - children: {product.title}, + children: {product.title}, }))} actions={[ { @@ -182,7 +185,7 @@ export function AccountDropdown({ label="Organizations" items={organizations.slice(0, 5).map((org) => ({ href: accountUrl(org.account_id), - children: {org.name}, + children: {org.name}, }))} actions={[ { diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 2695e6ec..831b1b69 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -15,14 +15,7 @@ export async function Navigation() { - + Products }> From 0c55938167f83fa97c3a170b6802ca06de719063 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:30:13 -0700 Subject: [PATCH 08/33] feat(nav): raise org/product cap to 20 and show names in monospace - Show up to 20 orgs/products in their submenus (was 5); fetch 20 owned products - Render entity names in a monospace face - Cap submenu height so long lists scroll instead of overflowing the viewport Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 10 +++++----- src/components/layout/AuthButtons.tsx | 2 +- src/components/layout/DropdownSection.tsx | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index ac7b54d7..0b547488 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -31,12 +31,12 @@ export function AccountDropdownSkeleton() { } // Org/product/invitation names are user data, not menu commands — render them -// heavier so they read distinctly from the surrounding options, and cap the -// width so one long name can't blow out the menu. +// in a monospace face so they read distinctly from the surrounding options, and +// cap the width so one long name can't blow out the menu. const entityNameStyle: CSSProperties = { display: "block", maxWidth: 220, - fontWeight: 500, + fontFamily: "var(--code-font-family, monospace)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -164,7 +164,7 @@ export function AccountDropdown({ /> ({ + items={products.slice(0, 20).map((product) => ({ href: productUrl(product.account_id, product.product_id), children: {product.title}, }))} @@ -183,7 +183,7 @@ export function AccountDropdown({ /> ({ + items={organizations.slice(0, 20).map((org) => ({ href: accountUrl(org.account_id), children: {org.name}, }))} diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index b4e65f7f..b203a201 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -34,7 +34,7 @@ export async function AuthButtons() { ...memberOrgIds, ...invited.map((m) => m.membership_account_id), ]), - productsTable.listByAccount(session.account.account_id, 5), + productsTable.listByAccount(session.account.account_id, 20), // Product invites need the product title; usually there are none. Promise.all( invited diff --git a/src/components/layout/DropdownSection.tsx b/src/components/layout/DropdownSection.tsx index 1f7f2728..93b5d293 100644 --- a/src/components/layout/DropdownSection.tsx +++ b/src/components/layout/DropdownSection.tsx @@ -117,7 +117,11 @@ export function DropdownSubmenu({ return ( {label} - + {/* Cap height so a long list (e.g. up to 20 orgs/products) scrolls + instead of running off-screen. */} + {hasItems && hasActions && } From 9135d42ef1552f61440ce1382c49afe366f9e061 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:42:50 -0700 Subject: [PATCH 09/33] feat(nav): account-centric menu (browse you + your orgs and their products) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate Profile / Products / Organizations submenus with one list of accounts — yourself plus each org you belong to. Each account is a submenu that links to its page and lists that account's products (up to 20), so you can reach an org's products, not just your own. - AuthButtons resolves products for you and each member org (parallel, cached) - Global "New product" / "New organization" actions (the create form already picks the owner account), permission-gated - Drop "Edit Profile" from the menu (edit from the profile page) Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 101 ++++++++++++---------- src/components/layout/AuthButtons.tsx | 68 ++++++++++----- 2 files changed, 101 insertions(+), 68 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 0b547488..ad01fb04 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -7,7 +7,6 @@ import { LOGGER, CONFIG, accountUrl, - editAccountProfileUrl, newOrganizationUrl, newProductUrl, productUrl, @@ -51,15 +50,17 @@ const inlineDotStyle: CSSProperties = { backgroundColor: "var(--red-9)", }; -export interface DropdownOrganization { - account_id: string; - name: string; +export interface DropdownAccountProduct { + product_id: string; + title: string; } -export interface DropdownProduct { +// An account the user can browse from the menu: themselves or an org they +// belong to, plus that account's products. +export interface DropdownAccount { account_id: string; - product_id: string; - title: string; + isSelf: boolean; + products: DropdownAccountProduct[]; } export interface DropdownInvitation { @@ -69,17 +70,18 @@ export interface DropdownInvitation { export function AccountDropdown({ session, - organizations = [], - products = [], + accounts = [], pendingInvitations = [], }: { session: UserSession; - organizations?: DropdownOrganization[]; - products?: DropdownProduct[]; + accounts?: DropdownAccount[]; pendingInvitations?: DropdownInvitation[]; }) { const [isOpen, setIsOpen] = useState(false); const hasInvitations = pendingInvitations.length > 0; + const canCreateProduct = isAuthorized(session, "*", Actions.CreateRepository); + const canCreateOrg = isAuthorized(session, "*", Actions.CreateAccount); + const canCreate = canCreateProduct || canCreateOrg; const handleLogout = async () => { const response = await fetch(CONFIG.auth.routes.logout, { @@ -144,62 +146,73 @@ export function AccountDropdown({ } - items={pendingInvitations.slice(0, 5).map((invitation) => ({ + items={pendingInvitations.slice(0, 20).map((invitation) => ({ href: invitation.href, children: {invitation.label}, }))} /> - } + + {/* One submenu per account (you + your orgs): a link to the account + page, then that account's products. */} + {accounts.map((account) => ( + + {account.account_id} + {account.isSelf && ( + + you + + )} + + } + items={[ + { + href: accountUrl(account.account_id), + children: account.isSelf + ? "View profile" + : "View organization", + }, + ]} + actions={account.products.slice(0, 20).map((product) => ({ + href: productUrl(account.account_id, product.product_id), + children: {product.title}, + }))} + /> + ))} + + {canCreate && } + - ({ - href: productUrl(product.account_id, product.product_id), - children: {product.title}, - }))} - actions={[ { href: newProductUrl(), children: ( <> - Create Product + New product ), - condition: isAuthorized(session, "*", Actions.CreateRepository), + condition: canCreateProduct, }, - ]} - /> - ({ - href: accountUrl(org.account_id), - children: {org.name}, - }))} - actions={[ { href: newOrganizationUrl(session.account!.account_id), children: ( <> - Create Organization + New organization ), - condition: isAuthorized(session, "*", Actions.CreateAccount), + condition: canCreateOrg, }, ]} /> + + {isAdmin(session) && } m.state === MembershipState.Member) - .map((m) => m.membership_account_id) - ); + // Accepted org memberships (distinct) vs outstanding invites: an invite + // must not read as an org you belong to — it goes in "Invitations" instead. + const memberOrgIds = [ + ...new Set( + memberships + .filter((m) => m.state === MembershipState.Member) + .map((m) => m.membership_account_id) + ), + ]; const invited = memberships.filter( (m) => m.state === MembershipState.Invited ); - const [accounts, { products }, invitedProducts] = await Promise.all([ - // One batched account read resolves both member-org and invite-org names. + // One round trip: org accounts (member + invited, for names), the products + // owned by you and by each org you belong to, and titles for product invites. + const productAccountIds = [self.account_id, ...memberOrgIds]; + const [orgAccounts, productLists, invitedProducts] = await Promise.all([ accountsTable.fetchManyByIds([ ...memberOrgIds, ...invited.map((m) => m.membership_account_id), ]), - productsTable.listByAccount(session.account.account_id, 20), - // Product invites need the product title; usually there are none. + Promise.all( + productAccountIds.map((id) => + productsTable.listByAccount(id, 20).then((r) => r.products) + ) + ), Promise.all( invited .filter((m) => m.repository_id) @@ -45,16 +53,33 @@ export async function AuthButtons() { ), ]); - const accountsById = new Map(accounts.map((a) => [a.account_id, a])); + const accountsById = new Map(orgAccounts.map((a) => [a.account_id, a])); + const productsByAccount = new Map( + productAccountIds.map((id, i) => [id, productLists[i]]) + ); + const toProducts = (id: string) => + (productsByAccount.get(id) ?? []).map((p) => ({ + product_id: p.product_id, + title: p.title, + })); + + // Accounts you can browse: yourself first, then each org you belong to. + const accounts = [ + { account_id: self.account_id, isSelf: true, products: toProducts(self.account_id) }, + ...memberOrgIds + .map((id) => accountsById.get(id)) + .filter((a): a is Account => !!a && isOrganizationalAccount(a)) + .map((a) => ({ + account_id: a.account_id, + isSelf: false, + products: toProducts(a.account_id), + })), + ]; + const productTitleByKey = new Map(); for (const p of invitedProducts) { if (p) productTitleByKey.set(`${p.account_id}/${p.product_id}`, p.title); } - - const organizations = accounts - .filter((a) => memberOrgIds.has(a.account_id) && isOrganizationalAccount(a)) - .map((a) => ({ account_id: a.account_id, name: a.name })); - const pendingInvitations = invited.map((m) => invitationLink(m, { organizationName: accountsById.get(m.membership_account_id)?.name, @@ -67,12 +92,7 @@ export async function AuthButtons() { return ( ({ - account_id: p.account_id, - product_id: p.product_id, - title: p.title, - }))} + accounts={accounts} pendingInvitations={pendingInvitations} /> ); From 4cc75af22987643bdddf2156f9542de5523c6389 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 17:53:12 -0700 Subject: [PATCH 10/33] feat(nav): avatars + names in account menu, empty states, Products back in menu - Drop monospace from account/product/invitation names - Label accounts by display name (not id) with a small avatar to the left - Grey "No products yet" when an account has no products - Invitations always shown; grey "No pending invitations" when there are none (red dot only when invites exist) - Move the "Products" (all products) link back to the top of the menu and remove it from the nav bar Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 86 +++++++++++++++-------- src/components/layout/AuthButtons.tsx | 4 +- src/components/layout/Navigation.tsx | 7 +- 3 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index ad01fb04..c78efbac 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -10,11 +10,12 @@ import { newOrganizationUrl, newProductUrl, productUrl, + productListUrl, } from "@/lib"; import { DropdownSection, DropdownSubmenu } from "./DropdownSection"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; import { ADMIN_TOOLS } from "@/components/features/admin/tools"; -import { Actions, UserSession } from "@/types"; +import { Account, Actions, UserSession } from "@/types"; import { ProfileAvatar } from "@/components/features/profiles/ProfileAvatar"; import { UploadBadge } from "@/components/features/uploader/UploadBadge"; import { UploadsSubmenu } from "@/components/features/uploader/UploadsSubmenu"; @@ -29,13 +30,11 @@ export function AccountDropdownSkeleton() { ); } -// Org/product/invitation names are user data, not menu commands — render them -// in a monospace face so they read distinctly from the surrounding options, and -// cap the width so one long name can't blow out the menu. +// Truncate long account/product/invitation names so one entry can't blow out +// the menu width. const entityNameStyle: CSSProperties = { display: "block", maxWidth: 220, - fontFamily: "var(--code-font-family, monospace)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -56,9 +55,9 @@ export interface DropdownAccountProduct { } // An account the user can browse from the menu: themselves or an org they -// belong to, plus that account's products. +// belong to, plus that account's products. The full account drives the avatar. export interface DropdownAccount { - account_id: string; + account: Account; isSelf: boolean; products: DropdownAccountProduct[]; } @@ -136,52 +135,79 @@ export function AccountDropdown({ + {/* Browse all products */} + + {/* Invitations — always shown so the user can check; a grey empty state + when there are none, a red dot on the label when there are. */} Invitations - + {hasInvitations && } } - items={pendingInvitations.slice(0, 20).map((invitation) => ({ - href: invitation.href, - children: {invitation.label}, - }))} + items={ + hasInvitations + ? pendingInvitations.slice(0, 20).map((invitation) => ({ + href: invitation.href, + children: ( + {invitation.label} + ), + })) + : [ + { + children: "No pending invitations", + color: "gray" as const, + disabled: true, + }, + ] + } /> - {hasInvitations && } + - {/* One submenu per account (you + your orgs): a link to the account - page, then that account's products. */} - {accounts.map((account) => ( + {/* One submenu per account (you + your orgs): an avatar-labelled trigger + linking to the account page, then that account's products. */} + {accounts.map(({ account, isSelf, products }) => ( - {account.account_id} - {account.isSelf && ( + + + {account.name} + {isSelf && ( you )} - + } items={[ { href: accountUrl(account.account_id), - children: account.isSelf - ? "View profile" - : "View organization", + children: isSelf ? "View profile" : "View organization", }, ]} - actions={account.products.slice(0, 20).map((product) => ({ - href: productUrl(account.account_id, product.product_id), - children: {product.title}, - }))} + actions={ + products.length > 0 + ? products.slice(0, 20).map((product) => ({ + href: productUrl(account.account_id, product.product_id), + children: ( + {product.title} + ), + })) + : [ + { + children: "No products yet", + color: "gray" as const, + disabled: true, + }, + ] + } /> ))} diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 802b111b..73b5dc5e 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -65,12 +65,12 @@ export async function AuthButtons() { // Accounts you can browse: yourself first, then each org you belong to. const accounts = [ - { account_id: self.account_id, isSelf: true, products: toProducts(self.account_id) }, + { account: self, isSelf: true, products: toProducts(self.account_id) }, ...memberOrgIds .map((id) => accountsById.get(id)) .filter((a): a is Account => !!a && isOrganizationalAccount(a)) .map((a) => ({ - account_id: a.account_id, + account: a, isSelf: false, products: toProducts(a.account_id), })), diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 831b1b69..30860b43 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -1,11 +1,9 @@ -import { Container, Flex, Link } from "@radix-ui/themes"; -import NextLink from "next/link"; +import { Container, Flex } from "@radix-ui/themes"; import { Logo } from "./Logo"; import styles from "./Navigation.module.css"; import { Suspense } from "react"; import { AuthButtons } from "./AuthButtons"; import { AccountDropdownSkeleton } from "./AccountDropdown"; -import { productListUrl } from "@/lib"; export async function Navigation() { return ( @@ -15,9 +13,6 @@ export async function Navigation() { - - Products - }> From a51649d00057d7473ff83fc09ad568288fba4027 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 18:06:26 -0700 Subject: [PATCH 11/33] feat(nav): move Invitations down next to the Admin section Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 58 +++++++++++------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index c78efbac..d0364b8a 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -140,34 +140,6 @@ export function AccountDropdown({ showSeparator={false} items={[{ href: productListUrl(), children: "Products" }]} /> - {/* Invitations — always shown so the user can check; a grey empty state - when there are none, a red dot on the label when there are. */} - - Invitations - {hasInvitations && } - - } - items={ - hasInvitations - ? pendingInvitations.slice(0, 20).map((invitation) => ({ - href: invitation.href, - children: ( - {invitation.label} - ), - })) - : [ - { - children: "No pending invitations", - color: "gray" as const, - disabled: true, - }, - ] - } - /> {/* One submenu per account (you + your orgs): an avatar-labelled trigger @@ -238,7 +210,35 @@ export function AccountDropdown({ ]} /> - {isAdmin(session) && } + + {/* Invitations — always shown so the user can check; a grey empty state + when there are none, a red dot on the label when there are. */} + + Invitations + {hasInvitations && } + + } + items={ + hasInvitations + ? pendingInvitations.slice(0, 20).map((invitation) => ({ + href: invitation.href, + children: ( + {invitation.label} + ), + })) + : [ + { + children: "No pending invitations", + color: "gray" as const, + disabled: true, + }, + ] + } + /> Date: Tue, 30 Jun 2026 18:33:39 -0700 Subject: [PATCH 12/33] fix(nav): cap menu width so long names truncate instead of widening it Bound the dropdown and submenu content to a max width and let names shrink (min-width: 0) and ellipsize within it, so a long org/product name no longer expands the menu past its default width. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 12 +++++++----- src/components/layout/DropdownSection.tsx | 5 +++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index d0364b8a..72208d42 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -30,11 +30,13 @@ export function AccountDropdownSkeleton() { ); } -// Truncate long account/product/invitation names so one entry can't blow out -// the menu width. +// Cap the menu width so a long name truncates instead of widening the menu. +const MENU_MAX_WIDTH = 256; + +// Truncate long account/product/invitation names: the name shrinks (min-width: +// 0) and ellipsizes within the capped menu width rather than expanding it. const entityNameStyle: CSSProperties = { - display: "block", - maxWidth: 220, + minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -134,7 +136,7 @@ export function AccountDropdown({ - + {/* Browse all products */} {label} {/* Cap height so a long list (e.g. up to 20 orgs/products) scrolls - instead of running off-screen. */} + instead of running off-screen, and width so long names truncate + instead of widening the submenu. */} {hasItems && hasActions && } From e1208d02e2a7e5cd1ec515c96748f826cccf0f66 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 19:08:57 -0700 Subject: [PATCH 13/33] fix(nav): restore ellipsis on truncated names text-overflow: ellipsis needs a definite width; the min-width:0 approach let the row clip hard instead. Give names a fixed max-width (180px) so they ellipsize, and drop the menu/submenu width caps that caused the hard cutoff. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 12 +++++------- src/components/layout/DropdownSection.tsx | 5 ++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 72208d42..aedd64c7 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -30,13 +30,11 @@ export function AccountDropdownSkeleton() { ); } -// Cap the menu width so a long name truncates instead of widening the menu. -const MENU_MAX_WIDTH = 256; - -// Truncate long account/product/invitation names: the name shrinks (min-width: -// 0) and ellipsizes within the capped menu width rather than expanding it. +// Cap an account/product/invitation name at a fixed width so it ellipsizes +// (text-overflow needs a definite width) instead of widening the menu. const entityNameStyle: CSSProperties = { - minWidth: 0, + display: "block", + maxWidth: 180, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -136,7 +134,7 @@ export function AccountDropdown({ - + {/* Browse all products */} {label} {/* Cap height so a long list (e.g. up to 20 orgs/products) scrolls - instead of running off-screen, and width so long names truncate - instead of widening the submenu. */} + instead of running off-screen. */} {hasItems && hasActions && } From 5a7c006a7679e978e0f87201ccb48992ada9b81d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 19:40:12 -0700 Subject: [PATCH 14/33] fix(nav): don't leak restricted org products in the account menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu listed every product from listByAccount for orgs you belong to, so a member could see (and link to) restricted products they can't actually access. Filter each account's products by GetRepository authorization, and count only org-level memberships (repository_id undefined) as orgs you belong to — matching IndividualProfilePage / OrganizationProfilePage. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AuthButtons.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 73b5dc5e..f764f9ac 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -1,5 +1,6 @@ import { AccountDropdown } from "./AccountDropdown"; import { getPageSession } from "@/lib/api/utils"; +import { isAuthorized } from "@/lib/api/authz"; import { accountsTable, isOrganizationalAccount, @@ -7,7 +8,7 @@ import { } from "@/lib/clients/database"; import { Button, Callout, Link } from "@radix-ui/themes"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { Account, MembershipState } from "@/types"; +import { Account, Actions, MembershipState } from "@/types"; import { loginUrl, onboardingUrl } from "@/lib/urls"; import { getReturnToUrl } from "@/lib/baseUrl"; import { invitationLink } from "./accountMenu"; @@ -23,7 +24,13 @@ export async function AuthButtons() { const memberOrgIds = [ ...new Set( memberships - .filter((m) => m.state === MembershipState.Member) + // Org-level memberships only — a product-level membership doesn't make + // you an org member (matches OrganizationProfilePage's isMember). + .filter( + (m) => + m.state === MembershipState.Member && + m.repository_id === undefined + ) .map((m) => m.membership_account_id) ), ]; @@ -57,11 +64,13 @@ export async function AuthButtons() { const productsByAccount = new Map( productAccountIds.map((id, i) => [id, productLists[i]]) ); + // Only surface products the user is authorized to see, so an org member + // isn't shown the org's restricted products they can't access (matches + // IndividualProfilePage's GetRepository filter). const toProducts = (id: string) => - (productsByAccount.get(id) ?? []).map((p) => ({ - product_id: p.product_id, - title: p.title, - })); + (productsByAccount.get(id) ?? []) + .filter((p) => isAuthorized(session, p, Actions.GetRepository)) + .map((p) => ({ product_id: p.product_id, title: p.title })); // Accounts you can browse: yourself first, then each org you belong to. const accounts = [ From 5111e1dd81563abfc8ad4d81f1ceae99775c8a63 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 30 Jun 2026 21:03:02 -0700 Subject: [PATCH 15/33] feat(nav): move Products link to the navbar for all users The all-products link belongs to everyone, not just logged-in users, so move it out of the account dropdown and into the primary nav (grouped with the logo), where it renders regardless of auth state. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/AccountDropdown.tsx | 8 -------- src/components/layout/Navigation.tsx | 14 +++++++++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index aedd64c7..e76bfde2 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -10,7 +10,6 @@ import { newOrganizationUrl, newProductUrl, productUrl, - productListUrl, } from "@/lib"; import { DropdownSection, DropdownSubmenu } from "./DropdownSection"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; @@ -135,13 +134,6 @@ export function AccountDropdown({ - {/* Browse all products */} - - - {/* One submenu per account (you + your orgs): an avatar-labelled trigger linking to the account page, then that account's products. */} {accounts.map(({ account, isSelf, products }) => ( diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 30860b43..621aafce 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -1,16 +1,24 @@ -import { Container, Flex } from "@radix-ui/themes"; +import { Container, Flex, Link } from "@radix-ui/themes"; +import NextLink from "next/link"; import { Logo } from "./Logo"; import styles from "./Navigation.module.css"; import { Suspense } from "react"; import { AuthButtons } from "./AuthButtons"; import { AccountDropdownSkeleton } from "./AccountDropdown"; +import { productListUrl } from "@/lib"; export async function Navigation() { return (