diff --git a/site/src/components/Dialog/DeleteDialog.stories.tsx b/site/src/components/Dialog/DeleteDialog.stories.tsx new file mode 100644 index 0000000000000..d2a6c86ba5e62 --- /dev/null +++ b/site/src/components/Dialog/DeleteDialog.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { DeleteDialog } from "./DeleteDialog"; +import dayjs from "dayjs"; + +const meta: Meta = { + title: "components/Dialog/DeleteDialog", + component: DeleteDialog, + args: { + open: true, + description: ( + <> + Deleting this workspace will permanently destroy all of its Terraform + resources. + + ), + + resourceKind: "workspace", + resourceName: "coffee-tahr-38", + + onDelete: fn(), + + onCancel: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = {}; + +export const WithTimeAndOwner: Story = { + args: { + resourceLastUsed: dayjs().subtract(5, "minutes"), + resourceOwnedBy: "Pumpkaboo", + }, +}; + +export const Archive: Story = { + args: { + description: ( + <> + Archiving this workspace will permanently destroy all of its Terraform + resources. + + ), + deleteAction: "Archive", + }, +}; + +export const Deleting: Story = { + args: { + deleteLoading: true, + }, +}; + +export const AdditionalInfo: Story = { + args: { + additionalInfo: "Oh hell yeah", + }, +}; + +export const WithNameInput: Story = { + args: { + requireAcknowledgingName: true, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = await body.findByRole("button", { name: "Delete" }); + await expect(confirmButton).toBeDisabled(); + const input = await body.findByLabelText( + `Confirm the name of the workspace`, + ); + await user.type(input, "coffee-tahr-38"); + await expect(confirmButton).toBeEnabled(); + }, +}; + +export const AdditionalInfoWithNameInput: Story = { + args: { + requireAcknowledgingName: true, + additionalInfo: "Oh hell yeah", + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = await body.findByRole("button", { name: "Delete" }); + await expect(confirmButton).toBeDisabled(); + const input = await body.findByLabelText( + `Confirm the name of the workspace`, + ); + await user.type(input, "coffee-tahr-38"); + await expect(confirmButton).toBeEnabled(); + }, +}; + +export const WithNameInputFilledWrong: Story = { + args: { + requireAcknowledgingName: true, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const confirmButton = await body.findByRole("button", { name: "Delete" }); + await expect(confirmButton).toBeDisabled(); + const input = await body.findByLabelText( + `Confirm the name of the workspace`, + ); + await user.type(input, "tahr-coffee-83"); + await expect(confirmButton).toBeEnabled(); + }, +}; diff --git a/site/src/components/Dialog/DeleteDialog.tsx b/site/src/components/Dialog/DeleteDialog.tsx new file mode 100644 index 0000000000000..e9c5fb008778a --- /dev/null +++ b/site/src/components/Dialog/DeleteDialog.tsx @@ -0,0 +1,157 @@ +import { TimerIcon, UserIcon } from "lucide-react"; +import { useId, useState } from "react"; +import { Alert } from "#/components/Alert/Alert"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { + Dialog, + DialogActions, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./Dialog"; +import { type DateTimeInput, relativeTime } from "#/utils/time"; + +type DeleteDialogProps = { + open: boolean; + description: React.ReactNode; + requireAcknowledgingName?: boolean; + additionalInfo?: React.ReactNode; + + resourceKind: string; + resourceName: string; + resourceLastUsed?: DateTimeInput; + resourceOwnedBy?: string; + + deleteAction: React.ReactNode; + deleteLoading?: boolean; + onDelete: () => void; + + onCancel: () => void; +}; + +export const DeleteDialog: React.FC = ({ + open, + description, + requireAcknowledgingName, + additionalInfo, + + resourceKind, + resourceName, + resourceLastUsed, + resourceOwnedBy, + + deleteAction = "Delete", + deleteLoading, + onDelete, + + onCancel, +}) => { + const [acknowledged, setAcknowledged] = useState(false); + + const includeSubtitle = resourceLastUsed || resourceOwnedBy; + + return ( + { + if (!nextOpen) { + onCancel(); + } + }} + > + + + + {deleteAction} {resourceName}? + + {includeSubtitle && ( +
+ {resourceLastUsed && ( +
+ + last used {relativeTime(resourceLastUsed)} +
+ )} + {resourceOwnedBy && ( +
+ + owned by {resourceOwnedBy} +
+ )} +
+ )} +
+
+
+ {description} + {Boolean(additionalInfo) && ( + + {additionalInfo} + + )} +
+ {requireAcknowledgingName && ( + + )} + + + + +
+
+ ); +}; + +type DeleteDialogAcknowledgmentInputProps = { + resourceName: string; + resourceKind: string; + setAcknowledged: (acknowledged: boolean) => void; +}; + +const DeleteDialogAcknowledgmentInput: React.FC< + DeleteDialogAcknowledgmentInputProps +> = ({ resourceName, resourceKind, setAcknowledged }) => { + const inputId = useId(); + const errorId = useId(); + const [showError, setShowError] = useState(false); + + return ( +
+ + { + const inputValue = event.target.value; + setAcknowledged(inputValue === resourceName); + }} + onFocus={() => setShowError(false)} + onBlur={(event) => { + const inputValue = event.target.value; + setShowError(inputValue.length > 0 && inputValue !== resourceName); + }} + aria-invalid={showError} + aria-describedby={showError ? errorId : undefined} + autoFocus + /> + {showError && ( + + Please enter the name of the {resourceKind} + + )} +
+ ); +}; diff --git a/site/src/components/Dialog/Dialog.stories.tsx b/site/src/components/Dialog/Dialog.stories.tsx index b0f732bf643f3..f45aa4694ddc2 100644 --- a/site/src/components/Dialog/Dialog.stories.tsx +++ b/site/src/components/Dialog/Dialog.stories.tsx @@ -26,7 +26,7 @@ const meta: Meta = { Dialog Description text - + diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index d1cbfeb10b814..20cf0e091c513 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -110,7 +110,7 @@ export const DialogFooter: React.FC> = ({ type DialogActionsProps = { /** Text to display in the confirm button */ - confirmText?: React.ReactNode; + confirmAction?: React.ReactNode; /** Whether or not confirm is loading, also disables cancel when true */ confirmLoading?: boolean; /** Whether or not the submit button is disabled */ @@ -130,7 +130,7 @@ type DialogActionsProps = { * Quickly handles most modals actions, some combination of a cancel and confirm button */ export const DialogActions: React.FC = ({ - confirmText = "Confirm", + confirmAction = "Confirm", confirmLoading = false, confirmDisabled = false, confirmVariant, @@ -163,7 +163,7 @@ export const DialogActions: React.FC = ({ type="submit" > - {confirmText} + {confirmAction} )} diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 3e70e8f01fd83..cd7baa2380d2b 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -16,7 +16,7 @@ import { } from "#/api/queries/users"; import type { User } from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { DeleteDialog } from "#/components/Dialog/DeleteDialog"; import { useFilter } from "#/components/Filter/Filter"; import { useStatusFilterMenu } from "#/components/Filter/UsersFilter"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -157,36 +157,54 @@ const UsersPage: React.FC = () => { isUpdatingRoles={updateUserRolesMutation.isPending} /> - setUserToDelete(undefined)} - onConfirm={async () => { - if (!userToDelete) { - return; + {userToDelete && ( + + {userToDelete.name ?? userToDelete.username} will + immediately lose access once + {userToDelete.is_service_account ? " it has " : " they have "} + been deleted. + } - try { - await deleteUserMutation.mutateAsync(userToDelete.id); - setUserToDelete(undefined); - toast.success( - `User "${userToDelete.username}" deleted successfully.`, - ); - } catch (e) { - toast.error( - getErrorMessage( - e, - `Error deleting user "${userToDelete.username}".`, - ), - { - description: getErrorDetail(e), - }, - ); + additionalInfo={ + <> + To delete a + {userToDelete.is_service_account + ? " service account, it " + : " user, they "} + must not have any workspaces. + } - }} - /> + resourceKind="user" + resourceName={userToDelete.username} + deleteAction="Delete" + onDelete={async () => { + if (!userToDelete) { + return; + } + try { + await deleteUserMutation.mutateAsync(userToDelete.id); + setUserToDelete(undefined); + toast.success(`${userToDelete.username} has been deleted.`); + } catch (e) { + toast.error( + getErrorMessage( + e, + `Error deleting user "${userToDelete.username}".`, + ), + { + description: getErrorDetail(e), + }, + ); + } + }} + deleteLoading={deleteUserMutation.isPending} + onCancel={() => setUserToDelete(undefined)} + /> + )} { }} description={ <> - Do you want to suspend the user{" "} + Do you want to suspend{" "} {userToSuspend?.username ?? ""}? } diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index bcf0571d90a10..a1d0f1dd09568 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -588,14 +588,19 @@ const WorkspaceActionsCell: FC = ({ + Are you sure you want to stop the workspace "{workspace.name}"? This + will terminate all running processes and disconnect any active + sessions. + + } confirmText="Stop" onClose={() => setIsStopConfirmOpen(false)} onConfirm={() => { stopWorkspaceMutation.mutate({}); setIsStopConfirmOpen(false); }} - type="delete" />