mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: require typed confirmation for license removal (#22082)
## Summary Adds a typed-confirmation step before deleting a deployment license to reduce accidental removals. <img width="457" height="440" alt="Screenshot 2026-02-13 at 15 31 58" src="https://github.com/user-attachments/assets/b13320a7-4b10-43fa-ab01-56f3284435b6" /> ## Changes - Swapped the license removal dialog from `ConfirmDialog` to `DeleteDialog`, requiring the admin to type the license ID before enabling **Remove**. - Added interaction coverage to verify the confirmation guard.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { MockLicenseResponse } from "testHelpers/entities";
|
||||
import { render } from "testHelpers/renderHelpers";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
|
||||
@@ -56,7 +56,8 @@ describe("LicenseCard", () => {
|
||||
const removeButton = await screen.findByRole("button", { name: /remove/i });
|
||||
await user.click(removeButton);
|
||||
|
||||
await screen.findByText(/This license has already expired/);
|
||||
const dialog = await screen.findByTestId("dialog");
|
||||
expect(dialog).toHaveTextContent(/This license has already expired/);
|
||||
});
|
||||
|
||||
it("shows disabling features warning for active licenses", async () => {
|
||||
@@ -106,4 +107,40 @@ describe("LicenseCard", () => {
|
||||
// Then
|
||||
await screen.findByText("1 / 3");
|
||||
});
|
||||
|
||||
it("requires typing the license ID before allowing removal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
const license = MockLicenseResponse[0];
|
||||
|
||||
render(
|
||||
<LicenseCard
|
||||
license={license}
|
||||
userLimitActual={1}
|
||||
userLimitLimit={10}
|
||||
onRemove={onRemove}
|
||||
isRemoving={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /remove/i }));
|
||||
|
||||
const dialog = await screen.findByTestId("dialog");
|
||||
const dialogScope = within(dialog);
|
||||
const confirmButton = dialogScope.getByRole("button", { name: "Remove" });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const confirmationInput = dialogScope.getByTestId(
|
||||
"delete-dialog-name-confirmation",
|
||||
);
|
||||
await user.type(confirmationInput, "wrong");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
await user.clear(confirmationInput);
|
||||
await user.type(confirmationInput, String(license.id));
|
||||
expect(confirmButton).toBeEnabled();
|
||||
|
||||
await user.click(confirmButton);
|
||||
expect(onRemove).toHaveBeenCalledWith(license.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import dayjs from "dayjs";
|
||||
import { type FC, useState } from "react";
|
||||
@@ -25,6 +25,7 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
>(undefined);
|
||||
|
||||
const currentUserLimit = license.claims.features.user_limit || userLimitLimit;
|
||||
const confirmationName = licenseIDMarkedForRemoval?.toString() ?? "";
|
||||
|
||||
const isExpired = dayjs
|
||||
.unix(license.claims.license_expires)
|
||||
@@ -41,26 +42,27 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
key={license.id}
|
||||
className="license-card rounded-lg border border-solid border-border bg-surface-secondary p-4 text-sm shadow-sm"
|
||||
>
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
hideCancel={false}
|
||||
open={licenseIDMarkedForRemoval !== undefined}
|
||||
<DeleteDialog
|
||||
key={licenseIDMarkedForRemoval}
|
||||
isOpen={licenseIDMarkedForRemoval !== undefined}
|
||||
onConfirm={() => {
|
||||
if (!licenseIDMarkedForRemoval) {
|
||||
return;
|
||||
}
|
||||
if (!licenseIDMarkedForRemoval) return;
|
||||
onRemove(licenseIDMarkedForRemoval);
|
||||
setLicenseIDMarkedForRemoval(undefined);
|
||||
}}
|
||||
onClose={() => setLicenseIDMarkedForRemoval(undefined)}
|
||||
title="Confirm License Removal"
|
||||
confirmLoading={isRemoving}
|
||||
onCancel={() => setLicenseIDMarkedForRemoval(undefined)}
|
||||
entity="license"
|
||||
name={confirmationName}
|
||||
label="ID of the license to remove"
|
||||
title="Confirm license removal"
|
||||
verb="Removing"
|
||||
confirmText="Remove"
|
||||
description={
|
||||
info={
|
||||
isExpired
|
||||
? "This license has already expired and is not providing any features. Removing it will not affect your current entitlements."
|
||||
: "Removing this license will disable all Premium features. You can add a new license at any time."
|
||||
}
|
||||
confirmLoading={isRemoving}
|
||||
/>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<span className="text-content-secondary text-lg font-semibold">
|
||||
|
||||
Reference in New Issue
Block a user