Skip to content

Commit 496473f

Browse files
committed
Refactor base menu component
1 parent bd65914 commit 496473f

File tree

14 files changed

+196
-76
lines changed

14 files changed

+196
-76
lines changed

site/src/api/api.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,10 +482,10 @@ class ApiMethods {
482482
return response.data;
483483
};
484484

485-
checkAuthorization = async (
485+
checkAuthorization = async <TResponse extends TypesGen.AuthorizationResponse>(
486486
params: TypesGen.AuthorizationRequest,
487-
): Promise<TypesGen.AuthorizationResponse> => {
488-
const response = await this.axios.post<TypesGen.AuthorizationResponse>(
487+
) => {
488+
const response = await this.axios.post<TResponse>(
489489
"/api/v2/authcheck",
490490
params,
491491
);

site/src/api/queries/authCheck.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { API } from "api/api";
2-
import type { AuthorizationRequest } from "api/typesGenerated";
2+
import type {
3+
AuthorizationRequest,
4+
AuthorizationResponse,
5+
} from "api/typesGenerated";
36

47
const AUTHORIZATION_KEY = "authorization";
58

69
export const getAuthorizationKey = (req: AuthorizationRequest) =>
710
[AUTHORIZATION_KEY, req] as const;
811

9-
export const checkAuthorization = (req: AuthorizationRequest) => {
12+
export const checkAuthorization = <TResponse extends AuthorizationResponse>(
13+
req: AuthorizationRequest,
14+
) => {
1015
return {
1116
queryKey: getAuthorizationKey(req),
12-
queryFn: () => API.checkAuthorization(req),
17+
queryFn: () => API.checkAuthorization<TResponse>(req),
1318
};
1419
};

site/src/api/queries/workspaces.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import type {
1919
} from "react-query";
2020
import { disabledRefetchOptions } from "./util";
2121
import { workspaceBuildsKey } from "./workspaceBuilds";
22+
import {
23+
workspaceChecks,
24+
type WorkspacePermissions,
25+
} from "modules/workspaces/permissions";
26+
import { checkAuthorization } from "./authCheck";
2227

2328
export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [
2429
"workspace",
@@ -390,3 +395,14 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => {
390395
refetchIntervalInBackground: true,
391396
};
392397
};
398+
399+
export const workspacePermissions = (workspace?: Workspace) => {
400+
return {
401+
...checkAuthorization<WorkspacePermissions>({
402+
checks: workspace ? workspaceChecks(workspace) : {},
403+
}),
404+
queryKey: ["workspaces", workspace?.id, "permissions"],
405+
enabled: !!workspace,
406+
staleTime: Number.POSITIVE_INFINITY,
407+
};
408+
};
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Button } from "components/Button/Button";
2+
import {
3+
DropdownMenu,
4+
DropdownMenuContent,
5+
DropdownMenuItem,
6+
DropdownMenuSeparator,
7+
DropdownMenuTrigger,
8+
} from "components/DropdownMenu/DropdownMenu";
9+
import {
10+
EllipsisVertical,
11+
SettingsIcon,
12+
HistoryIcon,
13+
TrashIcon,
14+
CopyIcon,
15+
DownloadIcon,
16+
} from "lucide-react";
17+
import { useState, type FC } from "react";
18+
import { Link as RouterLink } from "react-router-dom";
19+
20+
type WorkspaceMoreActionsProps = {
21+
isDuplicationReady: boolean;
22+
disabled?: boolean;
23+
onDuplicate: () => void;
24+
onDelete: () => void;
25+
onChangeVersion?: () => void;
26+
permissions?: {
27+
changeWorkspaceVersion?: boolean;
28+
};
29+
};
30+
31+
export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
32+
disabled,
33+
isDuplicationReady,
34+
onDuplicate,
35+
onDelete,
36+
onChangeVersion,
37+
permissions,
38+
}) => {
39+
// Download logs
40+
const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false);
41+
const canChangeVersion = permissions?.changeWorkspaceVersion !== false;
42+
43+
return (
44+
<>
45+
<DropdownMenu>
46+
<DropdownMenuTrigger asChild>
47+
<Button
48+
size="icon-lg"
49+
variant="subtle"
50+
data-testid="workspace-options-button"
51+
aria-controls="workspace-options"
52+
disabled={disabled}
53+
>
54+
<EllipsisVertical aria-hidden="true" />
55+
<span className="sr-only">Workspace actions</span>
56+
</Button>
57+
</DropdownMenuTrigger>
58+
59+
<DropdownMenuContent id="workspace-options" align="end">
60+
<DropdownMenuItem asChild>
61+
<RouterLink to="settings">
62+
<SettingsIcon />
63+
Settings
64+
</RouterLink>
65+
</DropdownMenuItem>
66+
67+
{onChangeVersion && canChangeVersion && (
68+
<DropdownMenuItem onClick={onChangeVersion}>
69+
<HistoryIcon />
70+
Change version&hellip;
71+
</DropdownMenuItem>
72+
)}
73+
74+
<DropdownMenuItem
75+
onClick={onDuplicate}
76+
disabled={!isDuplicationReady}
77+
>
78+
<CopyIcon />
79+
Duplicate&hellip;
80+
</DropdownMenuItem>
81+
82+
<DropdownMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
83+
<DownloadIcon />
84+
Download logs&hellip;
85+
</DropdownMenuItem>
86+
87+
<DropdownMenuSeparator />
88+
89+
<DropdownMenuItem
90+
className="text-content-destructive focus:text-content-destructive"
91+
onClick={onDelete}
92+
data-testid="delete-button"
93+
>
94+
<TrashIcon />
95+
Delete&hellip;
96+
</DropdownMenuItem>
97+
</DropdownMenuContent>
98+
</DropdownMenu>
99+
</>
100+
);
101+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AuthorizationCheck, Workspace } from "api/typesGenerated";
2+
3+
export const workspaceChecks = (workspace: Workspace) =>
4+
({
5+
readWorkspace: {
6+
object: {
7+
resource_type: "workspace",
8+
resource_id: workspace.id,
9+
owner_id: workspace.owner_id,
10+
},
11+
action: "read",
12+
},
13+
updateWorkspace: {
14+
object: {
15+
resource_type: "workspace",
16+
resource_id: workspace.id,
17+
owner_id: workspace.owner_id,
18+
},
19+
action: "update",
20+
},
21+
updateWorkspaceVersion: {
22+
object: {
23+
resource_type: "template",
24+
resource_id: workspace.template_id,
25+
},
26+
action: "update",
27+
},
28+
// We only want to allow template admins to delete failed workspaces since
29+
// they can leave orphaned resources.
30+
deleteFailedWorkspace: {
31+
object: {
32+
resource_type: "template",
33+
resource_id: workspace.template_id,
34+
},
35+
action: "update",
36+
},
37+
// To run a build in debug mode we need to be able to read the deployment
38+
// config (enable_terraform_debug_mode).
39+
deploymentConfig: {
40+
object: {
41+
resource_type: "deployment_config",
42+
},
43+
action: "read",
44+
},
45+
}) satisfies Record<string, AuthorizationCheck>;
46+
47+
export type WorkspacePermissions = Record<
48+
keyof ReturnType<typeof workspaceChecks>,
49+
boolean
50+
>;

site/src/pages/WorkspacePage/Workspace.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
55
import * as Mocks from "testHelpers/entities";
66
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
77
import { Workspace } from "./Workspace";
8-
import type { WorkspacePermissions } from "./permissions";
8+
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
99

1010
// Helper function to create timestamps easily - Copied from AppStatuses.stories.tsx
1111
const createTimestamp = (

site/src/pages/WorkspacePage/Workspace.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "./WorkspaceBuildProgress";
2525
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
2626
import { WorkspaceTopbar } from "./WorkspaceTopbar";
27-
import type { WorkspacePermissions } from "./permissions";
27+
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
2828
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
2929

3030
export interface WorkspaceProps {
@@ -41,7 +41,6 @@ export interface WorkspaceProps {
4141
isUpdating: boolean;
4242
isRestarting: boolean;
4343
workspace: TypesGen.Workspace;
44-
canChangeVersions: boolean;
4544
hideSSHButton?: boolean;
4645
hideVSCodeDesktopButton?: boolean;
4746
buildInfo?: TypesGen.BuildInfoResponse;
@@ -73,7 +72,6 @@ export const Workspace: FC<WorkspaceProps> = ({
7372
workspace,
7473
isUpdating,
7574
isRestarting,
76-
canChangeVersions,
7775
hideSSHButton,
7876
hideVSCodeDesktopButton,
7977
buildInfo,
@@ -155,7 +153,6 @@ export const Workspace: FC<WorkspaceProps> = ({
155153
handleDormantActivate={handleDormantActivate}
156154
handleToggleFavorite={handleToggleFavorite}
157155
canDebugMode={canDebugMode}
158-
canChangeVersions={canChangeVersions}
159156
isUpdating={isUpdating}
160157
isRestarting={isRestarting}
161158
canUpdateWorkspace={permissions.updateWorkspace}

site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { docs } from "utils/docs";
1212

1313
interface WorkspaceDeleteDialogProps {
1414
workspace: Workspace;
15-
canUpdateTemplate: boolean;
15+
canDeleteFailedWorkspace: boolean;
1616
isOpen: boolean;
1717
onCancel: () => void;
1818
onConfirm: (arg: CreateWorkspaceBuildRequest["orphan"]) => void;
@@ -21,7 +21,7 @@ interface WorkspaceDeleteDialogProps {
2121

2222
export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
2323
workspace,
24-
canUpdateTemplate,
24+
canDeleteFailedWorkspace,
2525
isOpen,
2626
onCancel,
2727
onConfirm,
@@ -102,7 +102,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
102102
// Orphaning is sort of a "last resort" that should really only
103103
// be used if Terraform is failing to apply while deleting, which
104104
// usually means that builds are failing as well.
105-
canUpdateTemplate &&
105+
canDeleteFailedWorkspace &&
106106
workspace.latest_build.status === "failed" && (
107107
<div css={styles.orphanContainer}>
108108
<div css={{ flexDirection: "column" }}>

site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useDashboard } from "modules/dashboard/useDashboard";
1515
import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage";
1616
import { type FC, useEffect, useState } from "react";
1717
import { useQuery } from "react-query";
18-
import type { WorkspacePermissions } from "../permissions";
18+
import type { WorkspacePermissions } from "../../../modules/workspaces/permissions";
1919
import {
2020
NotificationActionButton,
2121
type NotificationItem,

site/src/pages/WorkspacePage/WorkspacePage.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { watchWorkspace } from "api/api";
22
import { checkAuthorization } from "api/queries/authCheck";
33
import { template as templateQueryOptions } from "api/queries/templates";
44
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
5-
import { workspaceByOwnerAndName } from "api/queries/workspaces";
5+
import {
6+
workspaceByOwnerAndName,
7+
workspacePermissions,
8+
} from "api/queries/workspaces";
69
import type { Workspace } from "api/typesGenerated";
710
import { ErrorAlert } from "components/Alert/ErrorAlert";
811
import { displayError } from "components/GlobalSnackbar/utils";
@@ -15,7 +18,6 @@ import { type FC, useEffect } from "react";
1518
import { useQuery, useQueryClient } from "react-query";
1619
import { useParams } from "react-router-dom";
1720
import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
18-
import { type WorkspacePermissions, workspaceChecks } from "./permissions";
1921

2022
const WorkspacePage: FC = () => {
2123
const queryClient = useQueryClient();
@@ -43,13 +45,8 @@ const WorkspacePage: FC = () => {
4345
const template = templateQuery.data;
4446

4547
// Permissions
46-
const checks =
47-
workspace && template ? workspaceChecks(workspace, template) : {};
48-
const permissionsQuery = useQuery({
49-
...checkAuthorization({ checks }),
50-
enabled: workspace !== undefined && template !== undefined,
51-
});
52-
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
48+
const permissionsQuery = useQuery(workspacePermissions(workspace));
49+
const permissions = permissionsQuery.data;
5350

5451
// Watch workspace changes
5552
const updateWorkspaceData = useEffectEvent(

site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { ChangeVersionDialog } from "./ChangeVersionDialog";
3636
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
3737
import { Workspace } from "./Workspace";
3838
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
39-
import type { WorkspacePermissions } from "./permissions";
39+
import type { WorkspacePermissions } from "modules/workspaces/permissions";
4040

4141
interface WorkspaceReadyPageProps {
4242
template: TypesGen.Template;
@@ -62,7 +62,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
6262
// Debug mode
6363
const { data: deploymentValues } = useQuery({
6464
...deploymentConfig(),
65-
enabled: permissions.viewDeploymentConfig,
65+
enabled: permissions.deploymentConfig,
6666
});
6767

6868
// Build logs
@@ -99,7 +99,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
9999
}, []);
100100

101101
// Change version
102-
const canChangeVersions = permissions.updateTemplate;
103102
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
104103
const changeVersionMutation = useMutation(
105104
changeVersion(workspace, queryClient),
@@ -121,9 +120,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
121120
latestVersion,
122121
});
123122

124-
// If a user can update the template then they can force a delete
125-
// (via orphan).
126-
const canUpdateTemplate = Boolean(permissions.updateTemplate);
127123
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
128124
const deleteWorkspaceMutation = useMutation(
129125
deleteWorkspace(workspace, queryClient),
@@ -267,7 +263,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
267263
toggleFavoriteMutation.mutate();
268264
}}
269265
latestVersion={latestVersion}
270-
canChangeVersions={canChangeVersions}
271266
hideSSHButton={featureVisibility.browser_only}
272267
hideVSCodeDesktopButton={featureVisibility.browser_only}
273268
buildInfo={buildInfoQuery.data}
@@ -279,7 +274,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
279274

280275
<WorkspaceDeleteDialog
281276
workspace={workspace}
282-
canUpdateTemplate={canUpdateTemplate}
277+
canDeleteFailedWorkspace={permissions.deleteFailedWorkspace}
283278
isOpen={isConfirmingDelete}
284279
onCancel={() => {
285280
setIsConfirmingDelete(false);

0 commit comments

Comments
 (0)