diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
index ee061bb..543915d 100644
--- a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
+++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
@@ -45,9 +45,21 @@
{% if credential %}
Delete SSH Key
+ {% if credential.hosts.exists or credential.global_configs.exists %}
+
+ This SSH key is still selected by {{ credential.hosts.count }} host(s) or
+ {{ credential.global_configs.count }} global config(s). Select another key there before deleting it.
+
+ {% else %}
+ Type {{ credential.name }} to confirm deletion.
+ {% endif %}
{% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
index 71ec49e..cc031a6 100644
--- a/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
+++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
@@ -23,6 +23,7 @@
Known hosts |
Hosts |
Updated |
+ Actions |
@@ -35,9 +36,10 @@
{{ credential.known_hosts|yesno:"yes,no" }} |
{{ credential.hosts.count }} |
{{ credential.updated_at }} |
+ Edit |
{% empty %}
- | No SSH credentials configured yet. |
+ | No SSH credentials configured yet. |
{% endfor %}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 6538c01..131603d 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -406,13 +406,46 @@ class ViewTests(TestCase):
generate_ssh_key(credential)
key_path = Path(credential.key_path)
- response = self.client.post(reverse("delete_ssh_credential", args=[credential.id]), follow=True)
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": credential.name},
+ follow=True,
+ )
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "SSH key deleted: generated-key.")
self.assertFalse(SshCredential.objects.exists())
self.assertFalse(key_path.exists())
+ def test_ssh_credentials_view_requires_delete_confirmation(self) -> None:
+ self.client.force_login(self.staff_user)
+ credential = SshCredential.objects.create(name="backup-key")
+
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": "wrong"},
+ follow=True,
+ )
+
+ self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
+ self.assertContains(response, "Type backup-key to confirm SSH key deletion.")
+ self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
+
+ def test_ssh_credentials_view_blocks_delete_when_key_is_in_use(self) -> None:
+ self.client.force_login(self.staff_user)
+ credential = SshCredential.objects.create(name="backup-key")
+ HostConfig.objects.create(host="web-01", address="web-01.example.test", ssh_credential=credential)
+
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": credential.name},
+ follow=True,
+ )
+
+ self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
+ self.assertContains(response, "SSH key backup-key is still in use and cannot be deleted.")
+ self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
+
def test_ssh_credentials_view_rejects_invalid_key(self) -> None:
self.client.force_login(self.staff_user)
@@ -476,7 +509,7 @@ class ViewTests(TestCase):
response = self.client.post(
reverse("edit_ssh_credential", args=[credential.id]),
{
- "name": "backup-key",
+ "name": "renamed-backup-key",
"private_key": "UPDATED KEY",
"public_key": "",
"known_hosts": "",
@@ -487,6 +520,7 @@ class ViewTests(TestCase):
self.assertRedirects(response, reverse("ssh_credentials"))
credential.refresh_from_db()
+ self.assertEqual(credential.name, "renamed-backup-key")
self.assertEqual(credential.private_key, "UPDATED KEY\n")
self.assertEqual(credential.public_key, "UPDATED PUBLIC KEY")
self.assertEqual(credential.notes, "rotated")
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index a6d7414..f0ef91c 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -239,6 +239,9 @@ def delete_ssh_credential(request, credential_id: int):
if credential.hosts.exists() or credential.global_configs.exists():
messages.error(request, f"SSH key {credential.name} is still in use and cannot be deleted.")
return redirect("edit_ssh_credential", credential_id=credential.id)
+ if request.POST.get("confirm_name", "").strip() != credential.name:
+ messages.error(request, f"Type {credential.name} to confirm SSH key deletion.")
+ return redirect("edit_ssh_credential", credential_id=credential.id)
name = credential.name
try: