Skip to content
Draft
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
8 changes: 8 additions & 0 deletions site/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
},
"action": "create"
},
"viewWorkspaces": {
"object": {
"resource_type": "workspace",
"any_org": true,
"owner_id": "me"
},
"action": "read"
},
"updateTemplates": {
"object": { "resource_type": "template" },
"action": "update"
Expand Down
19 changes: 19 additions & 0 deletions site/src/modules/dashboard/DashboardRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { Navigate } from "react-router";
import { useAuthenticated } from "#/hooks/useAuthenticated";

/**
* Resolves the dashboard index route to a landing page based on the signed-in
* user's permissions. Users who cannot read workspaces land on their account
* settings.
*/
export const DashboardRedirect: FC = () => {
const { permissions } = useAuthenticated();

return (
<Navigate
to={permissions.viewWorkspaces ? "/workspaces" : "/settings/account"}
replace
/>
);
};
22 changes: 21 additions & 1 deletion site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { fn, userEvent, within } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import {
MockPrimaryWorkspaceProxy,
MockProxyLatencies,
Expand Down Expand Up @@ -40,6 +40,7 @@ const meta: Meta<typeof MobileMenu> = {
supportLinks: MockSupportLinks,
onSignOut: fn(),
isDefaultOpen: true,
canViewWorkspaces: true,
adminPermissions: {
canViewDeployment: true,
canViewOrganizations: true,
Expand Down Expand Up @@ -94,6 +95,25 @@ export const Member: Story = {
},
};

export const WithoutWorkspaceAccess: Story = {
args: {
user: MockUserMember,
adminPermissions: {},
canViewWorkspaces: false,
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Workspaces");

expect(
body.queryByRole("link", { name: "Workspaces" }),
).not.toBeInTheDocument();
expect(
body.queryByRole("menuitem", { name: /workspace proxy settings/i }),
).not.toBeInTheDocument();
},
};

export const ProxySettings: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
Expand Down
45 changes: 34 additions & 11 deletions site/src/modules/dashboard/Navbar/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
canViewAdminSettings,
} from "./AdminSettings";
import { sortProxiesByLatency } from "./proxyUtils";
import { RestrictedNavItem } from "./RestrictedNavItem";

const itemStyles = {
default: "px-9 h-10 no-underline",
Expand All @@ -42,6 +43,7 @@ const itemStyles = {
type MobileMenuProps = {
proxyContextValue?: ProxyContextValue;
adminPermissions: AdminSettingsPermissions;
canViewWorkspaces: boolean;
user?: TypesGen.User;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
Expand All @@ -51,6 +53,7 @@ type MobileMenuProps = {
export const MobileMenu: FC<MobileMenuProps> = ({
adminPermissions,
proxyContextValue,
canViewWorkspaces,
user,
supportLinks,
onSignOut,
Expand All @@ -76,17 +79,37 @@ export const MobileMenu: FC<MobileMenuProps> = ({
className="w-screen border-0 border-b border-solid p-0 py-2"
sideOffset={17}
>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/workspaces">Workspaces</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/templates">Templates</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/agents">Agents</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<ProxySettingsSub proxyContextValue={proxyContextValue} />
{canViewWorkspaces ? (
<>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/workspaces">Workspaces</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/templates">Templates</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className={itemStyles.default}>
<Link to="/agents">Agents</Link>
</DropdownMenuItem>
</>
) : (
<>
{["Workspaces", "Templates", "Agents"].map((label) => (
<DropdownMenuItem
key={label}
className={itemStyles.default}
onSelect={(event) => event.preventDefault()}
>
<RestrictedNavItem tabIndex={-1}>{label}</RestrictedNavItem>
</DropdownMenuItem>
))}
</>
)}
{canViewWorkspaces && (
<>
<DropdownMenuSeparator />
<ProxySettingsSub proxyContextValue={proxyContextValue} />
</>
)}

{canViewAdminSettings(adminPermissions) && (
<>
Expand Down
2 changes: 2 additions & 0 deletions site/src/modules/dashboard/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const Navbar: React.FC = () => {
permissions.viewAIGatewayKeys ||
permissions.editDeploymentConfig;
const canCreateChat = permissions.createChat;
const canViewWorkspaces = permissions.viewWorkspaces;

const uniqueLinks = new Map<string, LinkConfig>();
for (const link of appearance.support_links ?? []) {
Expand All @@ -54,6 +55,7 @@ export const Navbar: React.FC = () => {
canViewHealth,
}}
canCreateChat={canCreateChat}
canViewWorkspaces={canViewWorkspaces}
proxyContextValue={proxyContextValue}
/>
);
Expand Down
25 changes: 24 additions & 1 deletion site/src/modules/dashboard/Navbar/NavbarView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { expect, userEvent, within } from "storybook/test";
import type { TasksFilter } from "#/api/typesGenerated";
import {
MockBuildInfo,
Expand Down Expand Up @@ -40,6 +40,7 @@ const meta: Meta<typeof NavbarView> = {
canViewHealth: true,
},
canCreateChat: true,
canViewWorkspaces: true,
supportLinks: [],
},
decorators: [withDashboardProvider],
Expand Down Expand Up @@ -122,6 +123,28 @@ export const ForMemberWithAgentsAccess: Story = {
},
};

export const WithoutWorkspaceAccess: Story = {
parameters: { pixel: { matrix: pixelWithDesktop } },
args: {
user: MockUserMember,
adminPermissions: {},
canCreateChat: true,
canViewWorkspaces: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

for (const label of ["Workspaces", "Templates", "Tasks", "Agents"]) {
expect(
canvas.queryByRole("link", { name: label }),
).not.toBeInTheDocument();
}

await userEvent.hover(canvas.getByText("Workspaces"));
await within(canvasElement.ownerDocument.body).findByRole("tooltip");
},
};

export const IdleTasks: Story = {
parameters: {
queries: [
Expand Down
93 changes: 65 additions & 28 deletions site/src/modules/dashboard/Navbar/NavbarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { AdminSettingsDropdown } from "./DeploymentDropdown";
import { MobileMenu } from "./MobileMenu";
import { ProxyMenu } from "./ProxyMenu";
import { RestrictedNavItem } from "./RestrictedNavItem";
import { SupportIcon } from "./SupportIcon";
import { UserDropdown } from "./UserDropdown/UserDropdown";

Expand All @@ -33,6 +34,7 @@ interface NavbarViewProps {
onSignOut: () => void;
adminPermissions: AdminSettingsPermissions;
canCreateChat: boolean;
canViewWorkspaces: boolean;
proxyContextValue?: ProxyContextValue;
}

Expand All @@ -49,6 +51,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
onSignOut,
adminPermissions,
canCreateChat,
canViewWorkspaces,
proxyContextValue,
}) => {
const prerelease = getPrereleaseFlag(buildInfo);
Expand All @@ -73,14 +76,15 @@ export const NavbarView: FC<NavbarViewProps> = ({
: undefined,
}}
>
<NavLink to="/workspaces">
<NavLink to={canViewWorkspaces ? "/workspaces" : "/settings/account"}>
<ProductLogo className="h-7" />
</NavLink>

<NavItems
className="ml-4 hidden md:flex"
user={user}
canCreateChat={canCreateChat}
canViewWorkspaces={canViewWorkspaces}
/>

{prerelease && buildInfo?.version && (
Expand Down Expand Up @@ -111,7 +115,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
</div>
))}

{proxyContextValue && (
{proxyContextValue && canViewWorkspaces && (
<div className="hidden md:block">
<ProxyMenu proxyContextValue={proxyContextValue} />
</div>
Expand Down Expand Up @@ -146,6 +150,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
<MobileMenu
proxyContextValue={proxyContextValue}
adminPermissions={adminPermissions}
canViewWorkspaces={canViewWorkspaces}
user={user}
supportLinks={supportLinks}
onSignOut={onSignOut}
Expand All @@ -160,52 +165,76 @@ interface NavItemsProps {
className?: string;
user: TypesGen.User;
canCreateChat: boolean;
canViewWorkspaces: boolean;
}

const NavItems: FC<NavItemsProps> = ({ className, user, canCreateChat }) => {
const NavItems: FC<NavItemsProps> = ({
className,
user,
canCreateChat,
canViewWorkspaces,
}) => {
const location = useLocation();

return (
<nav className={cn("flex items-center gap-4 h-full", className)}>
<NavLink
className={({ isActive }) => {
if (location.pathname.startsWith("/@")) {
isActive = true;
}
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/workspaces"
>
Workspaces
</NavLink>
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/templates"
>
Templates
</NavLink>
<TasksNavItem user={user} />
{canCreateChat && (
{canViewWorkspaces ? (
<NavLink
className={({ isActive }) => {
if (location.pathname.startsWith("/@")) {
isActive = true;
}
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/agents"
to="/workspaces"
>
Agents
Workspaces
</NavLink>
) : (
<RestrictedNavItem className={linkStyles.default}>
Workspaces
</RestrictedNavItem>
)}
{canViewWorkspaces ? (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/templates"
>
Templates
</NavLink>
) : (
<RestrictedNavItem className={linkStyles.default}>
Templates
</RestrictedNavItem>
)}
<TasksNavItem user={user} canViewWorkspaces={canViewWorkspaces} />
{canCreateChat &&
(canViewWorkspaces ? (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/agents"
>
Agents
</NavLink>
) : (
<RestrictedNavItem className={linkStyles.default}>
Agents
</RestrictedNavItem>
))}
</nav>
);
};

type TasksNavItemProps = {
user: TypesGen.User;
canViewWorkspaces: boolean;
};

const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
const TasksNavItem: FC<TasksNavItemProps> = ({ user, canViewWorkspaces }) => {
const { metadata } = useEmbeddedMetadata();
const canSeeTasks = Boolean(
metadata["tasks-tab-visible"].value ||
Expand All @@ -219,7 +248,7 @@ const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
queryKey: ["tasks", filter],
queryFn: () => API.getTasks(filter),
refetchInterval: 1_000 * 60,
enabled: canSeeTasks,
enabled: canSeeTasks && canViewWorkspaces,
refetchOnWindowFocus: true,
initialData: [],
select: (data) =>
Expand All @@ -230,6 +259,14 @@ const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
return null;
}

if (!canViewWorkspaces) {
return (
<RestrictedNavItem className={linkStyles.default}>
Tasks
</RestrictedNavItem>
);
}

return (
<NavLink
to="/tasks"
Expand Down
Loading
Loading