Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a79024
feat(nav): organizations, products & admin as account-dropdown submenus
alukach Jun 30, 2026
d928228
Merge branch 'main' into worktree-dropdown-submenus
alukach Jun 30, 2026
78a8c72
feat(nav): profile submenu, + icons, truncation & logout divider
alukach Jun 30, 2026
2877306
fix(nav): only list accepted org memberships in the dropdown
alukach Jun 30, 2026
4577260
feat(nav): pending-invitation notifications + prominent Products link
alukach Jul 1, 2026
6e74cd9
feat(nav): order Products above Organizations in the dropdown
alukach Jul 1, 2026
17b8737
feat(nav): make Invitations a submenu
alukach Jul 1, 2026
2e70ae5
style(nav): differentiate entity names; make Products link match user…
alukach Jul 1, 2026
0c55938
feat(nav): raise org/product cap to 20 and show names in monospace
alukach Jul 1, 2026
9135d42
feat(nav): account-centric menu (browse you + your orgs and their pro…
alukach Jul 1, 2026
4cc75af
feat(nav): avatars + names in account menu, empty states, Products ba…
alukach Jul 1, 2026
a51649d
feat(nav): move Invitations down next to the Admin section
alukach Jul 1, 2026
f06a5bf
fix(nav): cap menu width so long names truncate instead of widening it
alukach Jul 1, 2026
e1208d0
fix(nav): restore ellipsis on truncated names
alukach Jul 1, 2026
5a7c006
fix(nav): don't leak restricted org products in the account menu
alukach Jul 1, 2026
5111e1d
feat(nav): move Products link to the navbar for all users
alukach Jul 1, 2026
8895f6b
feat(nav): float Products right of the divider next to the account menu
alukach Jul 1, 2026
bb5c266
feat(nav): style Products link like product headers + full-width mobi…
alukach Jul 1, 2026
9f76631
style(nav): Products link uses heading font, weight 500, gray-a11 → g…
alukach Jul 1, 2026
bf08675
feat(nav): proper mobile menu (hamburger → full-screen sheet)
alukach Jul 1, 2026
d092ffa
fix(nav): themed mobile sheet + hide Products link on marketing homepage
alukach Jul 1, 2026
4e0bf65
style(nav): mobile rows use vertical padding 0 (rely on 48px min-height)
alukach Jul 1, 2026
ff63870
style(nav): Products link in Berkeley Mono (var(--code-font-family))
alukach Jul 1, 2026
08b55a7
refactor(nav): apply ponytail cuts
alukach Jul 1, 2026
9695c91
style(nav): Products link font-size 2
alukach Jul 1, 2026
161ea85
fix(nav): restore stable DropdownItems keys (index before filter)
alukach Jul 1, 2026
add5690
feat(nav): logged-out mobile hamburger with Products + login
alukach Jul 1, 2026
a4a5254
feat(nav): add Docs link to header (desktop + mobile)
alukach Jul 1, 2026
b41211b
feat(nav): show create actions disabled with tooltip when unauthorized
alukach Jul 1, 2026
f897499
refactor(nav): label products, add page icon, group create actions in…
alukach Jul 1, 2026
507a5fe
feat(products): preselect owner in new-product form from ?owner
alukach Jul 1, 2026
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
14 changes: 13 additions & 1 deletion src/app/(app)/products/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { Actions, DataConnectionObjectSchema, MembershipState } from "@/types";
import { Heading, Text } from "@radix-ui/themes";
import { FormTitle } from "@/components/core";

export default async function NewProductPage() {
export default async function NewProductPage({
searchParams,
}: {
searchParams: Promise<{ owner?: string }>;
}) {
const { owner } = await searchParams;
const session = await getPageSession();
if (!session?.account) {
return (
Expand Down Expand Up @@ -64,6 +69,13 @@ export default async function NewProductPage() {
<ProductCreationForm
potentialOwnerAccounts={potentialOwnerAccounts}
dataConnections={dataConnections}
defaultOwnerId={
// Preselect the owner from ?owner=… (e.g. "New product" opened from an
// org's menu), but only if the user can actually own products there.
potentialOwnerAccounts.some((a) => a.account_id === owner)
? owner
: undefined
}
/>
</>
);
Expand Down
16 changes: 9 additions & 7 deletions src/components/features/products/ProductCreationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,24 @@ interface ProductCreationFormProps {
dataConnections?: DataConnection[]; // Connections the user may create against
product?: Product; // Optional product for edit mode
mode?: "create" | "edit"; // Mode of operation
defaultOwnerId?: string; // Preselected owner (e.g. from ?owner=…), create mode
}

export function ProductCreationForm({
potentialOwnerAccounts,
dataConnections = [],
product,
mode = "create",
defaultOwnerId,
}: ProductCreationFormProps) {
const isEditMode = mode === "edit" && product;

const [accountId, setAccountId] = useState(
isEditMode ? product.account_id : potentialOwnerAccounts[0]?.account_id
);
// In create mode, start on the preselected owner when given, else the first.
const initialOwnerId = isEditMode
? product.account_id
: (defaultOwnerId ?? potentialOwnerAccounts[0]?.account_id);

const [accountId, setAccountId] = useState(initialOwnerId);
const [productId, setProductId] = useState(
isEditMode ? product.product_id : ""
);
Expand All @@ -90,10 +95,7 @@ export function ProductCreationForm({
// Data connection selection. In edit mode the storage backend is fixed once
// the product exists, so we don't offer a selector — but we still resolve the
// product's connection to constrain the visibility options.
const initialAccountId = isEditMode
? product.account_id
: potentialOwnerAccounts[0]?.account_id;
const initialAvailable = connectionsForAccount(initialAccountId ?? "");
const initialAvailable = connectionsForAccount(initialOwnerId ?? "");

const [dataConnectionId, setDataConnectionId] = useState(
isEditMode
Expand Down
2 changes: 0 additions & 2 deletions src/components/features/uploader/UploadsSubmenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
DropdownMenu,
Text,
Badge,
Box,
Progress,
IconButton,
ScrollArea,
Expand Down Expand Up @@ -152,7 +151,6 @@ export function UploadsSubmenu() {
</ScrollArea>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
</>
);
}
Expand Down
226 changes: 171 additions & 55 deletions src/components/layout/AccountDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
"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, FileTextIcon } from "@radix-ui/react-icons";
import styles from "./Navigation.module.css";
import {
LOGGER,
CONFIG,
accountUrl,
editAccountProfileUrl,
newOrganizationUrl,
newProductUrl,
productUrl,
} from "@/lib";
import { DropdownSection } from "./DropdownSection";
import { DropdownSection, DropdownSubmenu } from "./DropdownSection";
import { logout } from "./logout";
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";
Expand All @@ -29,27 +28,47 @@ export function AccountDropdownSkeleton() {
);
}

export function AccountDropdown({ session }: { session: UserSession }) {
const [isOpen, setIsOpen] = useState(false);
// 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 = {
display: "block",
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};

export interface DropdownAccountProduct {
product_id: string;
title: string;
}

const handleLogout = async () => {
const response = await fetch(CONFIG.auth.routes.logout, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
LOGGER.error(
`Failed to logout: ${response.status} ${response.statusText}`,
{
operation: "logout",
metadata: { response },
}
);
return;
}
const { logout_url } = await response.json();
window.location.href = logout_url;
};
// An account the user can browse from the menu: themselves or an org they
// belong to, plus that account's products. The full account drives the avatar.
export interface DropdownAccount {
account: Account;
isSelf: boolean;
products: DropdownAccountProduct[];
}

export interface DropdownInvitation {
href: string;
label: string;
}

export function AccountDropdown({
session,
accounts = [],
pendingInvitations = [],
}: {
session: UserSession;
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);

return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
Expand All @@ -58,6 +77,21 @@ export function AccountDropdown({ session }: { session: UserSession }) {
<Box style={{ position: "relative" }}>
<ProfileAvatar account={session.account!} size="2" />
<UploadBadge />
{hasInvitations && (
<Box
aria-label="You have pending invitations"
style={{
position: "absolute",
bottom: -1,
right: -1,
width: 12,
height: 12,
borderRadius: "50%",
backgroundColor: "var(--red-9)",
border: "2px solid var(--color-background)",
}}
/>
)}
</Box>
<Box display={{ initial: "none", sm: "block" }}>
<Text>{session.account!.name}</Text>
Expand All @@ -70,40 +104,121 @@ export function AccountDropdown({ session }: { session: UserSession }) {
</DropdownMenu.Trigger>

<DropdownMenu.Content>
{/* 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 }) => (
<DropdownSubmenu
key={account.account_id}
label={
<Flex align="center" gap="2">
<ProfileAvatar account={account} size="1" />
<span style={entityNameStyle}>{account.name}</span>
{isSelf && (
<Text size="1" color="gray">
you
</Text>
)}
</Flex>
}
items={[
{
href: accountUrl(account.account_id),
children: isSelf ? "View profile" : "View organization",
},
]}
actionsLabel="Products"
actions={[
...(products.length > 0
? products.slice(0, 20).map((product) => ({
href: productUrl(account.account_id, product.product_id),
children: (
<>
<FileTextIcon />
<span style={entityNameStyle}>{product.title}</span>
</>
),
}))
: [
{
children: "No products yet",
color: "gray" as const,
disabled: true,
},
]),
// Create a product for this account, at the bottom of its list.
{
href: canCreateProduct
? newProductUrl(account.account_id)
: undefined,
disabled: !canCreateProduct,
tooltip: canCreateProduct
? undefined
: "You don't have permission to create products",
children: (
<>
<PlusIcon />
New product
</>
),
},
]}
/>
))}

<DropdownMenu.Separator />
{/* New organization, under the list of accounts you belong to.
Always shown; disabled with a tooltip when unauthorized. */}
<DropdownSection
label="Account"
items={[
{
href: accountUrl(session.account!.account_id),
children: "View Profile",
},
{
href: editAccountProfileUrl(session.account!.account_id),
children: "Edit Profile",
},
]}
/>
<DropdownSection
label="Organizations"
showSeparator={false}
items={[
{
href: newOrganizationUrl(session.account!.account_id),
children: "Create Organization",
condition: isAuthorized(session, "*", Actions.CreateAccount),
href: canCreateOrg
? newOrganizationUrl(session.account!.account_id)
: undefined,
disabled: !canCreateOrg,
tooltip: canCreateOrg
? undefined
: "You don't have permission to create organizations",
children: (
<>
<PlusIcon />
New organization
</>
),
},
]}
/>
<DropdownSection
label="Products"
items={[
{
href: newProductUrl(),
children: "Create Product",
condition: isAuthorized(session, "*", Actions.CreateRepository),
},
]}

<DropdownMenu.Separator />
{/* 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. */}
<DropdownSubmenu
label={
<span
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
Invitations
{hasInvitations && <span className={styles.mobileDot} />}
</span>
}
items={
hasInvitations
? pendingInvitations.slice(0, 20).map((invitation) => ({
href: invitation.href,
children: (
<span style={entityNameStyle}>{invitation.label}</span>
),
}))
: [
{
children: "No pending invitations",
color: "gray" as const,
disabled: true,
},
]
}
/>
<DropdownSection
<DropdownSubmenu
label="Admin"
condition={isAdmin(session)}
items={ADMIN_TOOLS.map((tool) => ({
Expand All @@ -112,10 +227,11 @@ export function AccountDropdown({ session }: { session: UserSession }) {
}))}
/>
<UploadsSubmenu />
<DropdownMenu.Separator />
<DropdownSection
items={[
{
onClick: handleLogout,
onClick: logout,
children: "Logout",
color: "red",
},
Expand Down
Loading
Loading