diff --git a/app/controllers/crate/delete.js b/app/controllers/crate/delete.js
new file mode 100644
index 00000000000..861f27e91a7
--- /dev/null
+++ b/app/controllers/crate/delete.js
@@ -0,0 +1,32 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+import { task } from 'ember-concurrency';
+
+export default class CrateSettingsController extends Controller {
+ @service notifications;
+ @service router;
+
+ @tracked isConfirmed;
+
+ @action toggleConfirmation() {
+ this.isConfirmed = !this.isConfirmed;
+ }
+
+ deleteTask = task(async () => {
+ try {
+ await this.model.destroyRecord();
+ this.notifications.success(`The crate ${this.model.name} has been successfully deleted.`);
+ this.router.transitionTo('index');
+ } catch (error) {
+ let detail = error.errors?.[0]?.detail;
+ if (detail && !detail.startsWith('{')) {
+ this.notifications.error(`Failed to delete crate: ${detail}`);
+ } else {
+ this.notifications.error('Failed to delete crate');
+ }
+ }
+ });
+}
diff --git a/app/router.js b/app/router.js
index e0de0c9c4bd..f6bc3248d15 100644
--- a/app/router.js
+++ b/app/router.js
@@ -20,6 +20,7 @@ Router.map(function () {
this.route('owners');
this.route('settings');
+ this.route('delete');
// Well-known routes
this.route('docs');
diff --git a/app/routes/crate/delete.js b/app/routes/crate/delete.js
new file mode 100644
index 00000000000..0904623189c
--- /dev/null
+++ b/app/routes/crate/delete.js
@@ -0,0 +1,8 @@
+import AuthenticatedRoute from '../-authenticated-route';
+
+export default class SettingsRoute extends AuthenticatedRoute {
+ setupController(controller) {
+ super.setupController(...arguments);
+ controller.set('isConfirmed', false);
+ }
+}
diff --git a/app/styles/application.module.css b/app/styles/application.module.css
index 3a8d4b583d5..d072b623688 100644
--- a/app/styles/application.module.css
+++ b/app/styles/application.module.css
@@ -18,8 +18,10 @@
--orange-800: #9a3412;
--orange-900: #7c2d12;
+ --yellow100: hsl(44, 100%, 90%);
--yellow500: hsl(44, 100%, 60%);
--yellow700: hsl(44, 67%, 50%);
+ --yellow800: hsl(44, 67%, 20%);
--header-bg-color: light-dark(hsl(115, 31%, 20%), #141413);
diff --git a/app/styles/crate/delete.module.css b/app/styles/crate/delete.module.css
new file mode 100644
index 00000000000..bfd54673ee1
--- /dev/null
+++ b/app/styles/crate/delete.module.css
@@ -0,0 +1,73 @@
+.wrapper {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ place-items: center;
+ margin: var(--space-s);
+}
+
+.content {
+ max-width: 100%;
+ overflow-wrap: break-word;
+}
+
+.title {
+ margin-top: 0;
+}
+
+.warning-block {
+ background: light-dark(var(--yellow100), var(--yellow800));
+ border-color: var(--yellow500);
+ border-left-style: solid;
+ border-left-width: 4px;
+ border-top-right-radius: var(--space-3xs);
+ border-bottom-right-radius: var(--space-3xs);
+ padding: var(--space-xs);
+}
+
+.warning {
+ composes: warning-block;
+ display: flex;
+
+ svg {
+ flex-shrink: 0;
+ width: 1em;
+ height: 1em;
+ color: var(--yellow500);
+ }
+
+ p {
+ margin: 0 0 0 var(--space-xs);
+ text-wrap: pretty;
+ }
+}
+
+.confirmation {
+ composes: warning-block;
+ display: block;
+
+ input {
+ margin-right: var(--space-3xs);
+ }
+}
+
+.actions {
+ margin-top: var(--space-m);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.delete-button {
+ composes: red-button from '../shared/buttons.module.css';
+}
+
+.spinner-wrapper {
+ position: relative;
+}
+
+.spinner {
+ position: absolute;
+ --spinner-size: 1.5em;
+ top: calc(-.5 * var(--spinner-size));
+ margin-left: var(--space-xs);
+}
diff --git a/app/templates/crate/delete.hbs b/app/templates/crate/delete.hbs
new file mode 100644
index 00000000000..ece81a72cb4
--- /dev/null
+++ b/app/templates/crate/delete.hbs
@@ -0,0 +1,60 @@
+
+
+
Delete the {{@model.name}} crate?
+
+
Are you sure you want to delete the crate "{{@model.name}}"?
+
+
+ {{svg-jar "triangle-exclamation"}}
+
Important: This action will permanently delete the crate and its associated versions. Deleting a crate is irreversible!
+
+
+
+
Potential Impact:
+
+ - Users will no longer be able to download this crate.
+ - Any dependencies or projects relying on this crate will be broken.
+ - Deleted crates cannot be reinstated.
+
+
+
+
+
Requirements:
+
A crate can only be deleted if:
+
+ - the crate has been published for less than 72 hours, or
+ - the crate only has a single owner,
+ - the crate has been downloaded less than 100 times for each month it has been published,
+ - and the crate is not depended upon by any other crate on crates.io.
+
+
+
+
+
+
+
+ {{#if this.deleteTask.isRunning}}
+
+
+
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/e2e/routes/crate/delete.spec.ts b/e2e/routes/crate/delete.spec.ts
new file mode 100644
index 00000000000..ad7b32d253d
--- /dev/null
+++ b/e2e/routes/crate/delete.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from '@/e2e/helper';
+
+test.describe('Route: crate.delete', { tag: '@routes' }, () => {
+ async function prepare({ mirage }) {
+ await mirage.addHook(server => {
+ let user = server.create('user');
+
+ let crate = server.create('crate', { name: 'foo' });
+ server.create('version', { crate });
+ server.create('crate-ownership', { crate, user });
+
+ authenticateAs(user);
+ });
+ }
+
+ test('unauthenticated', async ({ mirage, page }) => {
+ await mirage.addHook(server => {
+ let crate = server.create('crate', { name: 'foo' });
+ server.create('version', { crate });
+ });
+
+ await page.goto('/crates/foo/delete');
+ await expect(page).toHaveURL('/crates/foo/delete');
+ await expect(page.locator('[data-test-title]')).toHaveText('This page requires authentication');
+ await expect(page.locator('[data-test-login]')).toBeVisible();
+ });
+
+ test('happy path', async ({ mirage, page, percy }) => {
+ await prepare({ mirage });
+
+ await page.goto('/crates/foo/delete');
+ await expect(page).toHaveURL('/crates/foo/delete');
+ await expect(page.locator('[data-test-title]')).toHaveText('Delete the foo crate?');
+ await percy.snapshot();
+
+ await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
+ await page.click('[data-test-confirmation-checkbox]');
+ await expect(page.locator('[data-test-delete-button]')).toBeEnabled();
+ await page.click('[data-test-delete-button]');
+
+ await expect(page).toHaveURL('/');
+
+ let message = 'The crate foo has been successfully deleted.';
+ await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message);
+
+ let crate = await page.evaluate(() => server.schema.crates.findBy({ name: 'foo' }));
+ expect(crate).toBeNull();
+ });
+
+ test('loading state', async ({ page, mirage }) => {
+ await prepare({ mirage });
+ await mirage.addHook(server => {
+ globalThis.deferred = require('rsvp').defer();
+ server.delete('/api/v1/crates/foo', () => globalThis.deferred.promise);
+ });
+
+ await page.goto('/crates/foo/delete');
+ await page.click('[data-test-confirmation-checkbox]');
+ await page.click('[data-test-delete-button]');
+ await expect(page.locator('[data-test-spinner]')).toBeVisible();
+ await expect(page.locator('[data-test-confirmation-checkbox]')).toBeDisabled();
+ await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
+
+ await page.evaluate(async () => globalThis.deferred.resolve());
+ await expect(page).toHaveURL('/');
+ });
+
+ test('error state', async ({ page, mirage }) => {
+ await prepare({ mirage });
+ await mirage.addHook(server => {
+ let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
+ server.delete('/api/v1/crates/foo', payload, 422);
+ });
+
+ await page.goto('/crates/foo/delete');
+ await page.click('[data-test-confirmation-checkbox]');
+ await page.click('[data-test-delete-button]');
+ await expect(page).toHaveURL('/crates/foo/delete');
+
+ let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
+ await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(message);
+ });
+});
diff --git a/public/assets/triangle-exclamation.svg b/public/assets/triangle-exclamation.svg
new file mode 100644
index 00000000000..d2348732909
--- /dev/null
+++ b/public/assets/triangle-exclamation.svg
@@ -0,0 +1,4 @@
+
diff --git a/tests/routes/crate/delete-test.js b/tests/routes/crate/delete-test.js
new file mode 100644
index 00000000000..9842c5b4378
--- /dev/null
+++ b/tests/routes/crate/delete-test.js
@@ -0,0 +1,93 @@
+import { click, currentURL, waitFor } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import { defer } from 'rsvp';
+
+import percySnapshot from '@percy/ember';
+import { Response } from 'miragejs';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+import { visit } from '../../helpers/visit-ignoring-abort';
+
+module('Route: crate.delete', function (hooks) {
+ setupApplicationTest(hooks);
+
+ function prepare(context) {
+ let user = context.server.create('user');
+
+ let crate = context.server.create('crate', { name: 'foo' });
+ context.server.create('version', { crate });
+ context.server.create('crate-ownership', { crate, user });
+
+ context.authenticateAs(user);
+
+ return { user };
+ }
+
+ test('unauthenticated', async function (assert) {
+ let crate = this.server.create('crate', { name: 'foo' });
+ this.server.create('version', { crate });
+
+ await visit('/crates/foo/delete');
+ assert.strictEqual(currentURL(), '/crates/foo/delete');
+ assert.dom('[data-test-title]').hasText('This page requires authentication');
+ assert.dom('[data-test-login]').exists();
+ });
+
+ test('happy path', async function (assert) {
+ prepare(this);
+
+ await visit('/crates/foo/delete');
+ assert.strictEqual(currentURL(), '/crates/foo/delete');
+ assert.dom('[data-test-title]').hasText('Delete the foo crate?');
+ await percySnapshot(assert);
+
+ assert.dom('[data-test-delete-button]').isDisabled();
+ await click('[data-test-confirmation-checkbox]');
+ assert.dom('[data-test-delete-button]').isEnabled();
+ await click('[data-test-delete-button]');
+
+ assert.strictEqual(currentURL(), '/');
+
+ let message = 'The crate foo has been successfully deleted.';
+ assert.dom('[data-test-notification-message="success"]').hasText(message);
+
+ let crate = this.server.schema.crates.findBy({ name: 'foo' });
+ assert.strictEqual(crate, null);
+ });
+
+ test('loading state', async function (assert) {
+ prepare(this);
+
+ let deferred = defer();
+ this.server.delete('/api/v1/crates/foo', deferred.promise);
+
+ await visit('/crates/foo/delete');
+ await click('[data-test-confirmation-checkbox]');
+ let clickPromise = click('[data-test-delete-button]');
+ await waitFor('[data-test-spinner]');
+ assert.dom('[data-test-confirmation-checkbox]').isDisabled();
+ assert.dom('[data-test-delete-button]').isDisabled();
+
+ deferred.resolve(new Response(204));
+ await clickPromise;
+
+ assert.strictEqual(currentURL(), '/');
+ });
+
+ test('error state', async function (assert) {
+ prepare(this);
+
+ let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
+ this.server.delete('/api/v1/crates/foo', payload, 422);
+
+ await visit('/crates/foo/delete');
+ await click('[data-test-confirmation-checkbox]');
+ await click('[data-test-delete-button]');
+ assert.strictEqual(currentURL(), '/crates/foo/delete');
+
+ let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
+ assert.dom('[data-test-notification-message="error"]').hasText(message);
+ });
+});